Compare commits
23
Commits
d9bb94cd3f
...
ai-8-26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
928f72ec3d | ||
|
|
b4c11881b4 | ||
|
|
58ffde808f | ||
|
|
486acc465d | ||
|
|
398f9f3726 | ||
|
|
5bd5eae62d | ||
|
|
456dd667df | ||
|
|
ed48f8be31 | ||
|
|
43ad07208f | ||
|
|
4b8b4eb649 | ||
|
|
75e214dc08 | ||
|
|
74ff568ba4 | ||
|
|
2fa8492c56 | ||
|
|
b5b14516a1 | ||
|
|
381fe65367 | ||
|
|
af603a4e9a | ||
|
|
f24afa116f | ||
|
|
b8ccbaf567 | ||
|
|
af1db59c07 | ||
|
|
47094cc617 | ||
|
|
01c38d8c5b | ||
|
|
1f3e580cf8 | ||
|
|
562fe0ea0e |
@@ -100,6 +100,7 @@ export interface OssCredentialsResponse {
|
|||||||
host?: string
|
host?: string
|
||||||
cdn_domain?: string
|
cdn_domain?: string
|
||||||
key_prefix?: string
|
key_prefix?: string
|
||||||
|
object_key?: string
|
||||||
max_size?: number
|
max_size?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
expired_time?: number
|
expired_time?: number
|
||||||
@@ -111,8 +112,10 @@ export interface OssCredentialsResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OssDirectUploadType = 'video' | 'voice' | 'desktop_package'
|
||||||
|
|
||||||
/** 申请 STS 临时凭证 */
|
/** 申请 STS 临时凭证 */
|
||||||
export function getOssCredentials(params: { type: 'video' }) {
|
export function getOssCredentials(params: { type: OssDirectUploadType; name?: string }) {
|
||||||
return request.post({
|
return request.post({
|
||||||
url: '/upload/ossCredentials',
|
url: '/upload/ossCredentials',
|
||||||
params
|
params
|
||||||
@@ -121,7 +124,7 @@ export function getOssCredentials(params: { type: 'video' }) {
|
|||||||
|
|
||||||
/** 直传完成回执:写 file 表 + HEAD 校验 */
|
/** 直传完成回执:写 file 表 + HEAD 校验 */
|
||||||
export function confirmOssUpload(params: {
|
export function confirmOssUpload(params: {
|
||||||
type: 'video'
|
type: OssDirectUploadType
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
size: number
|
size: number
|
||||||
|
|||||||
+152
-13
@@ -154,6 +154,51 @@ export function firstVisitConversionOverview(params: FirstVisitConversionParams)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FirstVisitConversionFansDetailParams extends FirstVisitConversionParams {
|
||||||
|
entity_type: 'dept' | 'member'
|
||||||
|
entity_id: string | number
|
||||||
|
admin_id?: number
|
||||||
|
page_no: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstVisitConversionFanDetailRow {
|
||||||
|
external_userid: string
|
||||||
|
customer_name: string
|
||||||
|
wecom_userid: string
|
||||||
|
wecom_staff_name: string
|
||||||
|
add_time: string | null
|
||||||
|
is_deleted?: boolean
|
||||||
|
delete_time?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstVisitConversionFansDetailResult {
|
||||||
|
can_view_deleted_fans?: boolean
|
||||||
|
lists?: FirstVisitConversionFanDetailRow[]
|
||||||
|
rows?: FirstVisitConversionFanDetailRow[]
|
||||||
|
total?: number
|
||||||
|
count?: number
|
||||||
|
page_no?: number
|
||||||
|
page_size?: number
|
||||||
|
entity?: {
|
||||||
|
type?: 'dept' | 'member'
|
||||||
|
id?: string | number
|
||||||
|
admin_id?: number
|
||||||
|
name?: string
|
||||||
|
add_fans_count?: number
|
||||||
|
deleted_fans_count?: number
|
||||||
|
}
|
||||||
|
date_range?: [string, string]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一诊加粉明细:时间、渠道及行实体均由调用方显式透传,服务端仍需按 DataScope 收窄。 */
|
||||||
|
export function firstVisitConversionFansDetail(params: FirstVisitConversionFansDetailParams) {
|
||||||
|
return request.get(
|
||||||
|
{ url: '/firstvisit.conversion/fansDetail', params, timeout: 120000 },
|
||||||
|
{ ignoreCancelToken: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface FirstVisitRegistrationStatsParams {
|
export interface FirstVisitRegistrationStatsParams {
|
||||||
time_type: 'today' | 'yesterday' | 'week' | 'month'
|
time_type: 'today' | 'yesterday' | 'week' | 'month'
|
||||||
dept_id?: number
|
dept_id?: number
|
||||||
@@ -190,21 +235,115 @@ export function wecomPromotionOverview() {
|
|||||||
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
|
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionTagGroup {
|
||||||
|
group_id: string
|
||||||
|
group_name: string
|
||||||
|
tag: Array<{ id: string; name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionTagOptions() {
|
||||||
|
return request.get<{ tag_groups: WecomPromotionTagGroup[] }>({
|
||||||
|
url: '/firstvisit.wecomPromotion/tagOptions'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionCreateTag(params: { name: string }) {
|
||||||
|
return request.post<{ tag: { id: string; name: string }; group_id: string; group_name: string; reused: boolean }>({
|
||||||
|
url: '/firstvisit.wecomPromotion/createTag', params, timeout: 30000
|
||||||
|
}, { ignoreCancelToken: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionUploadWelcomeMedia(file: File, type: 'image' | 'video' | 'file') {
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('file', file)
|
||||||
|
data.append('type', type)
|
||||||
|
return request.post<{ asset_id: string; name: string; type: string }>({
|
||||||
|
url: '/firstvisit.wecomPromotion/uploadWelcomeMedia',
|
||||||
|
data,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 120000
|
||||||
|
}, { ignoreCancelToken: true })
|
||||||
|
}
|
||||||
|
|
||||||
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
|
||||||
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionBatchSetOperatorsParams {
|
||||||
|
pool_ids: number[]
|
||||||
|
operator_admin_ids: number[]
|
||||||
|
action: 'grant' | 'revoke'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionBatchSetOperatorsResult {
|
||||||
|
action: 'grant' | 'revoke'
|
||||||
|
pool_ids: number[]
|
||||||
|
operator_admin_ids: number[]
|
||||||
|
affected: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionBatchSetOperators(params: WecomPromotionBatchSetOperatorsParams) {
|
||||||
|
return request.post<WecomPromotionBatchSetOperatorsResult>({
|
||||||
|
url: '/firstvisit.wecomPromotion/batchSetOperators',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionBatchUpdatePoolsParams {
|
||||||
|
pool_ids: number[]
|
||||||
|
changes: {
|
||||||
|
skip_verify?: 0 | 1
|
||||||
|
fallback_url?: string
|
||||||
|
status?: 0 | 1
|
||||||
|
automation_config?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionBatchUpdatePoolResult {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
success: boolean
|
||||||
|
sync_error?: string
|
||||||
|
sync_queued?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionBatchUpdatePoolsResult {
|
||||||
|
pool_ids: number[]
|
||||||
|
updated: number
|
||||||
|
failed: number
|
||||||
|
sync_error_count: number
|
||||||
|
sync_queued_count: number
|
||||||
|
results: WecomPromotionBatchUpdatePoolResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionBatchUpdatePools(params: WecomPromotionBatchUpdatePoolsParams) {
|
||||||
|
return request.post<WecomPromotionBatchUpdatePoolsResult>({
|
||||||
|
url: '/firstvisit.wecomPromotion/batchUpdatePools',
|
||||||
|
params,
|
||||||
|
timeout: 120000
|
||||||
|
}, { ignoreCancelToken: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||||
|
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
|
||||||
|
}
|
||||||
|
|
||||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
|
||||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
|
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
|
export function wecomPromotionSaveMember(params: Record<string, unknown>) {
|
||||||
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
|
return request.post({ url: '/firstvisit.wecomPromotion/saveMember', params, timeout: 120000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionToggleMember(params: { id: number; status: number }) {
|
||||||
|
return request.post({ url: '/firstvisit.wecomPromotion/toggleMember', params, timeout: 120000 })
|
||||||
|
}
|
||||||
|
|
||||||
export function wecomPromotionCheckApiPermission() {
|
export function wecomPromotionCheckApiPermission() {
|
||||||
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
|
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ export function qywxCustomerLists(params: any) {
|
|||||||
return request.get({ url: '/qywx.customer/lists', params })
|
return request.get({ url: '/qywx.customer/lists', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除一条本地企业微信客户同步记录
|
||||||
|
export function qywxCustomerDelete(params: { id: number }) {
|
||||||
|
return request.post({ url: '/qywx.customer/delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
// 同步企业微信客户
|
// 同步企业微信客户
|
||||||
export function qywxCustomerSync() {
|
export function qywxCustomerSync() {
|
||||||
return request.post({ url: '/qywx.customer/sync' })
|
return request.post({ url: '/qywx.customer/sync' })
|
||||||
|
|||||||
@@ -578,6 +578,11 @@ export function prescriptionOrderAddLog(params: {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
return request.post({ url: '/tcm.prescriptionOrder/addLog', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 移除单笔收款关联,总金额不变,同步更新已付金额和需代收 */
|
||||||
|
export function prescriptionOrderUnlinkPayOrder(params: { id: number; pay_order_id: number }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/unlinkPayOrder', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 修改订单金额 */
|
/** 修改订单金额 */
|
||||||
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
export function prescriptionOrderUpdateAmount(params: { id: number; amount: number }) {
|
||||||
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ import useAppStore from '@/stores/modules/app'
|
|||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import {
|
import {
|
||||||
|
DirectUploadApiError,
|
||||||
DirectUploadFallbackError,
|
DirectUploadFallbackError,
|
||||||
uploadVideoDirectToCos
|
uploadDirectToCos,
|
||||||
|
type DirectUploadType
|
||||||
} from '@/utils/oss-direct-upload'
|
} from '@/utils/oss-direct-upload'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -83,7 +85,7 @@ export default defineComponent({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
|
// 直传到对象存储,绕开服务器中转
|
||||||
direct: {
|
direct: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
@@ -102,8 +104,10 @@ export default defineComponent({
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const fileList = ref<any[]>([])
|
const fileList = ref<any[]>([])
|
||||||
|
|
||||||
// 仅 video/voice + direct 时才接管 http-request
|
const directTypes: DirectUploadType[] = ['video', 'voice', 'desktop_package']
|
||||||
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
|
const useDirect = computed(
|
||||||
|
() => props.direct && directTypes.includes(props.type as DirectUploadType)
|
||||||
|
)
|
||||||
|
|
||||||
const handleProgress = () => {
|
const handleProgress = () => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -131,7 +135,10 @@ export default defineComponent({
|
|||||||
fileList.value = []
|
fileList.value = []
|
||||||
emit('allSuccess')
|
emit('allSuccess')
|
||||||
}
|
}
|
||||||
feedback.msgError(`${file.name}文件上传失败`)
|
if (!(event instanceof DirectUploadApiError)) {
|
||||||
|
const message = event instanceof Error ? event.message : ''
|
||||||
|
feedback.msgError(message || `${file.name}文件上传失败`)
|
||||||
|
}
|
||||||
uploadRefs.value?.abort(file)
|
uploadRefs.value?.abort(file)
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('change', file)
|
emit('change', file)
|
||||||
@@ -153,18 +160,20 @@ export default defineComponent({
|
|||||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||||
|
case 'desktop_package':
|
||||||
|
return '.exe,.zip'
|
||||||
default:
|
default:
|
||||||
return '*'
|
return '*'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
|
// 走 COS 直传:成功时模拟老接口的响应 envelope
|
||||||
const httpRequest = async (options: UploadRequestOptions) => {
|
const httpRequest = async (options: UploadRequestOptions) => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
try {
|
try {
|
||||||
const data = await uploadVideoDirectToCos({
|
const data = await uploadDirectToCos({
|
||||||
file: options.file,
|
file: options.file,
|
||||||
type: props.type as any,
|
type: props.type as DirectUploadType,
|
||||||
cid: Number((options.data as any)?.cid ?? 0),
|
cid: Number((options.data as any)?.cid ?? 0),
|
||||||
onProgress(info) {
|
onProgress(info) {
|
||||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||||
@@ -178,6 +187,12 @@ export default defineComponent({
|
|||||||
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
|
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err instanceof DirectUploadFallbackError) {
|
if (err instanceof DirectUploadFallbackError) {
|
||||||
|
if (props.type === 'desktop_package') {
|
||||||
|
;(options as any).onError?.(
|
||||||
|
new Error('当前未启用腾讯云 COS,安装包无法直传,请配置 COS 后重试')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
|
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
|
||||||
await defaultXhrUpload(options)
|
await defaultXhrUpload(options)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import COS from 'cos-js-sdk-v5'
|
|||||||
import {
|
import {
|
||||||
confirmOssUpload,
|
confirmOssUpload,
|
||||||
getOssCredentials,
|
getOssCredentials,
|
||||||
type OssCredentialsResponse
|
type OssCredentialsResponse,
|
||||||
|
type OssDirectUploadType
|
||||||
} from '@/api/file'
|
} from '@/api/file'
|
||||||
|
|
||||||
export type DirectUploadType = 'video'
|
export type DirectUploadType = OssDirectUploadType
|
||||||
|
|
||||||
export interface DirectUploadProgress {
|
export interface DirectUploadProgress {
|
||||||
/** 0-100 */
|
/** 0-100 */
|
||||||
@@ -37,8 +38,17 @@ export interface DirectUploadOptions {
|
|||||||
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
|
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
const ASYNC_LIMIT = 3
|
const ASYNC_LIMIT = 3
|
||||||
|
|
||||||
|
async function callDirectUploadApi<T>(request: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await request()
|
||||||
|
} catch (error) {
|
||||||
|
// request 拦截器已经展示过接口/网络错误,上传组件只负责收口失败状态
|
||||||
|
throw new DirectUploadApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildKey(prefix: string, file: File): string {
|
function buildKey(prefix: string, file: File): string {
|
||||||
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
|
const ext = (file.name.split('.').pop() || 'bin').toLowerCase()
|
||||||
const ts = Date.now()
|
const ts = Date.now()
|
||||||
const rand = Math.random().toString(36).slice(2, 10)
|
const rand = Math.random().toString(36).slice(2, 10)
|
||||||
return `${prefix}${ts}-${rand}.${ext}`
|
return `${prefix}${ts}-${rand}.${ext}`
|
||||||
@@ -48,8 +58,13 @@ function buildKey(prefix: string, file: File): string {
|
|||||||
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
|
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
|
||||||
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
|
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
|
||||||
*/
|
*/
|
||||||
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
export async function uploadDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
||||||
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
|
const credentials: OssCredentialsResponse = await callDirectUploadApi(() =>
|
||||||
|
getOssCredentials({
|
||||||
|
type: options.type,
|
||||||
|
name: options.file.name
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
if (credentials.fallback) {
|
if (credentials.fallback) {
|
||||||
const handled = options.onFallback?.(credentials.provider) ?? false
|
const handled = options.onFallback?.(credentials.provider) ?? false
|
||||||
@@ -66,7 +81,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
|||||||
|
|
||||||
if (credentials.max_size && options.file.size > credentials.max_size) {
|
if (credentials.max_size && options.file.size > credentials.max_size) {
|
||||||
const mb = Math.round(credentials.max_size / 1024 / 1024)
|
const mb = Math.round(credentials.max_size / 1024 / 1024)
|
||||||
throw new Error(`视频体积超出上限(${mb}MB)`)
|
throw new Error(`文件体积超出上限(${mb}MB)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cred = credentials.credentials
|
const cred = credentials.credentials
|
||||||
@@ -85,7 +100,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const key = buildKey(credentials.key_prefix, options.file)
|
const key = credentials.object_key || buildKey(credentials.key_prefix, options.file)
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
cos.uploadFile(
|
cos.uploadFile(
|
||||||
@@ -117,14 +132,16 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const confirmed = await confirmOssUpload({
|
const confirmed = await callDirectUploadApi(() =>
|
||||||
type: options.type,
|
confirmOssUpload({
|
||||||
key,
|
type: options.type,
|
||||||
name: options.file.name,
|
key,
|
||||||
size: options.file.size,
|
name: options.file.name,
|
||||||
content_type: options.file.type || '',
|
size: options.file.size,
|
||||||
cid: options.cid ?? 0
|
content_type: options.file.type || '',
|
||||||
})
|
cid: options.cid ?? 0
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
|
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
|
||||||
|
|
||||||
@@ -140,3 +157,14 @@ export class DirectUploadFallbackError extends Error {
|
|||||||
this.provider = provider
|
this.provider = provider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 请求层已经展示过错误,避免 ElUpload 再弹一条通用失败提示。 */
|
||||||
|
export class DirectUploadApiError extends Error {
|
||||||
|
readonly originalError: unknown
|
||||||
|
|
||||||
|
constructor(error: unknown) {
|
||||||
|
super('')
|
||||||
|
this.name = 'DirectUploadApiError'
|
||||||
|
this.originalError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -541,6 +541,19 @@
|
|||||||
{{ formatOrderTime(row.create_time) }}
|
{{ formatOrderTime(row.create_time) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column v-if="canUnlinkPayOrder" label="操作" width="80" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
link
|
||||||
|
:loading="unlinkPayOrderId === Number(row.id)"
|
||||||
|
:disabled="unlinkPayOrderId !== null || ![2, 5].includes(Number(row.status))"
|
||||||
|
:title="Number(row.status) === 4 ? '已退款收款记录不可移除' : '解除关联并更新已付金额,总金额不变'"
|
||||||
|
@click="confirmUnlinkPayOrder(row)"
|
||||||
|
>移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<el-empty v-else description="未关联收款单" :image-size="60" />
|
<el-empty v-else description="未关联收款单" :image-size="60" />
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -1014,6 +1027,7 @@ import {
|
|||||||
prescriptionOrderLogisticsTrace,
|
prescriptionOrderLogisticsTrace,
|
||||||
prescriptionOrderLogisticsJdUpdate,
|
prescriptionOrderLogisticsJdUpdate,
|
||||||
prescriptionOrderPaidPayOrders,
|
prescriptionOrderPaidPayOrders,
|
||||||
|
prescriptionOrderUnlinkPayOrder,
|
||||||
prescriptionOrderPatchPrescriptionUsage
|
prescriptionOrderPatchPrescriptionUsage
|
||||||
} from '@/api/tcm'
|
} from '@/api/tcm'
|
||||||
import { getDictData } from '@/api/app'
|
import { getDictData } from '@/api/app'
|
||||||
@@ -1107,6 +1121,38 @@ const detailLoading = ref(false)
|
|||||||
const detailData = ref<Record<string, any> | null>(null)
|
const detailData = ref<Record<string, any> | null>(null)
|
||||||
const detailLogs = ref<any[]>([])
|
const detailLogs = ref<any[]>([])
|
||||||
const detailUnlinkedPayOrders = ref<any[]>([])
|
const detailUnlinkedPayOrders = ref<any[]>([])
|
||||||
|
const unlinkPayOrderId = ref<number | null>(null)
|
||||||
|
const canUnlinkPayOrder = computed(() =>
|
||||||
|
!props.readonly && canUpdateAmount(detailData.value) && hasPerm('tcm.prescriptionOrder/unlinkPayOrder')
|
||||||
|
)
|
||||||
|
|
||||||
|
async function confirmUnlinkPayOrder(row: { id: number; order_no?: string; amount: number | string; status: number | string }) {
|
||||||
|
const orderId = Number(detailData.value?.id)
|
||||||
|
const payOrderId = Number(row.id)
|
||||||
|
if (!canUnlinkPayOrder.value || !orderId || !payOrderId || unlinkPayOrderId.value !== null) return
|
||||||
|
if (![2, 5].includes(Number(row.status))) return
|
||||||
|
|
||||||
|
unlinkPayOrderId.value = payOrderId
|
||||||
|
try {
|
||||||
|
await feedback.confirm(
|
||||||
|
`确定移除收款记录「${row.order_no || '#' + payOrderId}」(¥${formatMoney(row.amount)})?` +
|
||||||
|
'仅解除关联,原收款记录保留;订单总金额不变,已付金额与需代收金额按剩余关联收款重新计算。'
|
||||||
|
)
|
||||||
|
if (!detailVisible.value || Number(detailData.value?.id) !== orderId) return
|
||||||
|
const res: any = await prescriptionOrderUnlinkPayOrder({ id: orderId, pay_order_id: payOrderId })
|
||||||
|
const updated = res?.data ?? res
|
||||||
|
if (Number(detailData.value?.id) === orderId && Number(updated?.id) === orderId) {
|
||||||
|
Object.assign(detailData.value!, updated)
|
||||||
|
}
|
||||||
|
feedback.msgSuccess('收款关联已移除,金额已同步更新')
|
||||||
|
emit('detail-changed')
|
||||||
|
await refreshIfCurrent(orderId)
|
||||||
|
} catch {
|
||||||
|
/* 取消不提交;接口错误由拦截器提示,保留当前收款记录 */
|
||||||
|
} finally {
|
||||||
|
unlinkPayOrderId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const detailPrescription = computed(() => {
|
const detailPrescription = computed(() => {
|
||||||
const p = detailData.value?.prescription
|
const p = detailData.value?.prescription
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export function logActionText(act: string) {
|
|||||||
ship: '确认发货',
|
ship: '确认发货',
|
||||||
withdraw: '撤销',
|
withdraw: '撤销',
|
||||||
link_pay_order: '关联支付单',
|
link_pay_order: '关联支付单',
|
||||||
|
unlink_pay_order: '移除收款关联',
|
||||||
completion_request: '完单申请',
|
completion_request: '完单申请',
|
||||||
auto_complete: '自动完成',
|
auto_complete: '自动完成',
|
||||||
revoke_rx_audit: '撤回处方审核',
|
revoke_rx_audit: '撤回处方审核',
|
||||||
|
|||||||
+299
-21
@@ -94,15 +94,32 @@
|
|||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="跟进人">
|
<el-form-item label="跟进人">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="queryParams.follow_user"
|
v-model="queryParams.follow_user"
|
||||||
placeholder="跟进人姓名(后台姓名或企微账号)"
|
placeholder="跟进人姓名(后台姓名或企微账号)"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="添加时间">
|
<el-form-item label="渠道">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.add_way"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="选择或搜索添加渠道"
|
||||||
|
style="width: 240px"
|
||||||
|
@change="resetPage"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in ADD_WAY_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="添加时间">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="addTimeRange"
|
v-model="addTimeRange"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
@@ -235,6 +252,32 @@
|
|||||||
<span v-else class="text-gray-400">—</span>
|
<span v-else class="text-gray-400">—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="添加渠道" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="customerAddSources(row).length" class="flex items-center gap-1">
|
||||||
|
<el-tooltip
|
||||||
|
v-for="source in customerAddSources(row).slice(0, 1)"
|
||||||
|
:key="source.key"
|
||||||
|
:content="addSourceTooltip(source)"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="inline-block max-w-[150px] truncate align-middle">
|
||||||
|
{{ source.label }}
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip
|
||||||
|
v-if="customerAddSources(row).length > 1"
|
||||||
|
:content="remainingAddSourcesTooltip(row)"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="text-primary whitespace-nowrap cursor-help">
|
||||||
|
另 {{ customerAddSources(row).length - 1 }} 条
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400">未记录</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="添加时间" width="160">
|
<el-table-column label="添加时间" width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ formatTime(firstExternalAddTime(row)) }}
|
{{ formatTime(firstExternalAddTime(row)) }}
|
||||||
@@ -245,9 +288,19 @@
|
|||||||
{{ formatTime(row.update_time) }}
|
{{ formatTime(row.update_time) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120" fixed="right">
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
|
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['qywx.customer/delete']"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
:loading="deletingCustomerId === Number(row.id)"
|
||||||
|
:disabled="deletingCustomerId !== null"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -500,6 +553,21 @@
|
|||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="添加时间" :span="2">
|
<el-descriptions-item label="添加时间" :span="2">
|
||||||
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="添加渠道" :span="2">
|
||||||
|
<div v-if="customerAddSources(currentCustomer).length" class="flex flex-wrap gap-1">
|
||||||
|
<el-tooltip
|
||||||
|
v-for="source in customerAddSources(currentCustomer)"
|
||||||
|
:key="source.key"
|
||||||
|
:content="addSourceTooltip(source)"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag size="small" type="info" effect="plain">
|
||||||
|
{{ source.label }}
|
||||||
|
</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400">未记录</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="更新时间" :span="2">
|
<el-descriptions-item label="更新时间" :span="2">
|
||||||
{{ formatTime(currentCustomer.update_time) }}
|
{{ formatTime(currentCustomer.update_time) }}
|
||||||
@@ -549,8 +617,9 @@ import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-v
|
|||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import {
|
import {
|
||||||
qywxCustomerLists,
|
qywxCustomerLists,
|
||||||
qywxCustomerSync,
|
qywxCustomerDelete,
|
||||||
|
qywxCustomerSync,
|
||||||
qywxCustomerStats,
|
qywxCustomerStats,
|
||||||
qywxSyncSettingsGet,
|
qywxSyncSettingsGet,
|
||||||
qywxSyncSettingsSave,
|
qywxSyncSettingsSave,
|
||||||
@@ -563,6 +632,7 @@ const syncing = ref(false)
|
|||||||
const showSyncSettings = ref(false)
|
const showSyncSettings = ref(false)
|
||||||
const showDetail = ref(false)
|
const showDetail = ref(false)
|
||||||
const currentCustomer = ref<any>(null)
|
const currentCustomer = ref<any>(null)
|
||||||
|
const deletingCustomerId = ref<number | null>(null)
|
||||||
|
|
||||||
const stats = reactive({
|
const stats = reactive({
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -591,17 +661,19 @@ const syncSettings = reactive({
|
|||||||
interval: 3600
|
interval: 3600
|
||||||
})
|
})
|
||||||
|
|
||||||
const queryParams = reactive<{
|
const queryParams = reactive<{
|
||||||
name: string
|
name: string
|
||||||
follow_user: string
|
follow_user: string
|
||||||
tag_ids: string[]
|
add_way: number | ''
|
||||||
|
tag_ids: string[]
|
||||||
add_time_start: string
|
add_time_start: string
|
||||||
add_time_end: string
|
add_time_end: string
|
||||||
dedupe_mode: 'first' | 'any'
|
dedupe_mode: 'first' | 'any'
|
||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
follow_user: '',
|
follow_user: '',
|
||||||
tag_ids: [],
|
add_way: '',
|
||||||
|
tag_ids: [],
|
||||||
add_time_start: '',
|
add_time_start: '',
|
||||||
add_time_end: '',
|
add_time_end: '',
|
||||||
dedupe_mode: 'first'
|
dedupe_mode: 'first'
|
||||||
@@ -639,6 +711,22 @@ interface TagStatsPayload {
|
|||||||
groups: TagGroup[]
|
groups: TagGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AddChannel {
|
||||||
|
state: string
|
||||||
|
label: string
|
||||||
|
source_type: 'promotion_pool' | 'state'
|
||||||
|
pool_id: number
|
||||||
|
user_id: string
|
||||||
|
event_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddSource extends AddChannel {
|
||||||
|
key: string
|
||||||
|
add_way: number | null
|
||||||
|
channel_label: string
|
||||||
|
staff_name: string
|
||||||
|
}
|
||||||
|
|
||||||
const tagStats = reactive<TagStatsPayload>({
|
const tagStats = reactive<TagStatsPayload>({
|
||||||
total_tags: 0,
|
total_tags: 0,
|
||||||
total_relations: 0,
|
total_relations: 0,
|
||||||
@@ -847,10 +935,11 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
|||||||
params: queryParams
|
params: queryParams
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
queryParams.name = ''
|
queryParams.name = ''
|
||||||
queryParams.follow_user = ''
|
queryParams.follow_user = ''
|
||||||
queryParams.tag_ids = []
|
queryParams.add_way = ''
|
||||||
|
queryParams.tag_ids = []
|
||||||
queryParams.add_time_start = ''
|
queryParams.add_time_start = ''
|
||||||
queryParams.add_time_end = ''
|
queryParams.add_time_end = ''
|
||||||
queryParams.dedupe_mode = 'first'
|
queryParams.dedupe_mode = 'first'
|
||||||
@@ -964,6 +1053,34 @@ function viewDetail(row: any) {
|
|||||||
showDetail.value = true
|
showDetail.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: Record<string, any>) {
|
||||||
|
const id = Number(row.id)
|
||||||
|
if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return
|
||||||
|
|
||||||
|
const customerName = String(row.name || row.external_userid || '该客户')
|
||||||
|
try {
|
||||||
|
await feedback.confirm(
|
||||||
|
`确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。`
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingCustomerId.value = id
|
||||||
|
try {
|
||||||
|
await qywxCustomerDelete({ id })
|
||||||
|
if (pager.page > 1 && pager.lists.length === 1) {
|
||||||
|
pager.page -= 1
|
||||||
|
}
|
||||||
|
await Promise.all([getLists(), loadStats(), loadTagStats()])
|
||||||
|
feedback.msgSuccess('删除成功')
|
||||||
|
} catch (e: any) {
|
||||||
|
feedback.msgError(e?.message || e?.msg || '删除失败')
|
||||||
|
} finally {
|
||||||
|
deletingCustomerId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 列表接口会写入 admin_name(admin.work_wechat_userid = userid) */
|
/** 列表接口会写入 admin_name(admin.work_wechat_userid = userid) */
|
||||||
function formatFollowUser(user: Record<string, any>) {
|
function formatFollowUser(user: Record<string, any>) {
|
||||||
const adminName = String(user?.admin_name ?? '').trim()
|
const adminName = String(user?.admin_name ?? '').trim()
|
||||||
@@ -985,6 +1102,167 @@ function followStaffTooltip(user: Record<string, any>) {
|
|||||||
return parts.join('|')
|
return parts.join('|')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customerAddChannels(row: Record<string, any> | null | undefined): AddChannel[] {
|
||||||
|
if (!row) return []
|
||||||
|
if (Array.isArray(row.add_channels)) {
|
||||||
|
return row.add_channels
|
||||||
|
.map((channel: Record<string, any>): AddChannel => ({
|
||||||
|
state: String(channel?.state ?? '').trim(),
|
||||||
|
label: String(channel?.label ?? channel?.state ?? '').trim(),
|
||||||
|
source_type: channel?.source_type === 'promotion_pool' ? 'promotion_pool' : 'state',
|
||||||
|
pool_id: Number(channel?.pool_id ?? 0),
|
||||||
|
user_id: String(channel?.user_id ?? '').trim(),
|
||||||
|
event_time: Number(channel?.event_time ?? 0)
|
||||||
|
}))
|
||||||
|
.filter((channel: AddChannel) => channel.state !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容仅返回原始渠道数组的旧接口/灰度节点。
|
||||||
|
if (!Array.isArray(row.add_channel_states)) return []
|
||||||
|
return row.add_channel_states
|
||||||
|
.map((state: unknown) => String(state ?? '').trim())
|
||||||
|
.filter((state: string) => state !== '')
|
||||||
|
.map((state: string) => ({
|
||||||
|
state,
|
||||||
|
label: state,
|
||||||
|
source_type: 'state' as const,
|
||||||
|
pool_id: 0,
|
||||||
|
user_id: '',
|
||||||
|
event_time: 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_WAY_LABELS: Record<number, string> = {
|
||||||
|
0: '未知添加方式',
|
||||||
|
1: '通过扫描二维码添加',
|
||||||
|
2: '通过搜索手机号添加',
|
||||||
|
3: '通过名片分享添加',
|
||||||
|
4: '通过群聊添加',
|
||||||
|
5: '通过手机通讯录添加',
|
||||||
|
6: '通过微信联系人添加',
|
||||||
|
8: '安装第三方应用时自动添加',
|
||||||
|
9: '通过搜索邮箱添加',
|
||||||
|
10: '通过视频号添加',
|
||||||
|
11: '通过日程参与人添加',
|
||||||
|
12: '通过会议参与人添加',
|
||||||
|
13: '通过微信好友添加',
|
||||||
|
14: '通过智慧硬件专属客服添加',
|
||||||
|
15: '通过上门服务客服添加',
|
||||||
|
16: '通过获客链接添加',
|
||||||
|
17: '通过定制开发添加',
|
||||||
|
18: '通过需求回复添加',
|
||||||
|
21: '通过第三方售前客服添加',
|
||||||
|
22: '通过可能的商务伙伴添加',
|
||||||
|
24: '通过接受微信好友申请添加',
|
||||||
|
201: '通过内部成员共享添加',
|
||||||
|
202: '通过管理员或负责人分配添加'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_WAY_OPTIONS = Object.entries(ADD_WAY_LABELS).map(([value, label]) => ({
|
||||||
|
value: Number(value),
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
|
||||||
|
function normalizeAddWay(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value
|
||||||
|
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return null
|
||||||
|
return Number(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWayLabel(addWay: number) {
|
||||||
|
return ADD_WAY_LABELS[addWay] || `其他添加方式(${addWay})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerAddSources(row: Record<string, any> | null | undefined): AddSource[] {
|
||||||
|
if (!row) return []
|
||||||
|
|
||||||
|
const channels = customerAddChannels(row)
|
||||||
|
const usedChannelIndexes = new Set<number>()
|
||||||
|
const sources: AddSource[] = []
|
||||||
|
const followUsers = Array.isArray(row.follow_users) ? row.follow_users : []
|
||||||
|
|
||||||
|
followUsers.forEach((user: Record<string, any>, index: number) => {
|
||||||
|
const userId = String(user?.userid ?? user?.UserId ?? '').trim()
|
||||||
|
const state = String(user?.state ?? user?.State ?? '').trim()
|
||||||
|
const addWay = normalizeAddWay(user?.add_way ?? user?.AddWay)
|
||||||
|
|
||||||
|
let channelIndex = channels.findIndex(
|
||||||
|
(channel, i) =>
|
||||||
|
!usedChannelIndexes.has(i) &&
|
||||||
|
userId !== '' &&
|
||||||
|
state !== '' &&
|
||||||
|
channel.user_id === userId &&
|
||||||
|
channel.state === state
|
||||||
|
)
|
||||||
|
if (channelIndex < 0 && state !== '') {
|
||||||
|
channelIndex = channels.findIndex(
|
||||||
|
(channel, i) => !usedChannelIndexes.has(i) && channel.state === state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (channelIndex < 0 && userId !== '') {
|
||||||
|
channelIndex = channels.findIndex(
|
||||||
|
(channel, i) => !usedChannelIndexes.has(i) && channel.user_id === userId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = channelIndex >= 0 ? channels[channelIndex] : undefined
|
||||||
|
if (channelIndex >= 0) usedChannelIndexes.add(channelIndex)
|
||||||
|
if (addWay === null && state === '' && !channel) return
|
||||||
|
|
||||||
|
const labelFromApi = String(user?.add_way_label ?? '').trim()
|
||||||
|
const sourceType = channel?.source_type ?? (/^zyt_pool:[1-9]\d*$/.test(state) ? 'promotion_pool' : 'state')
|
||||||
|
const label = labelFromApi || (addWay !== null
|
||||||
|
? addWayLabel(addWay)
|
||||||
|
: sourceType === 'promotion_pool'
|
||||||
|
? '通过获客链接添加'
|
||||||
|
: '通过其他渠道添加')
|
||||||
|
|
||||||
|
sources.push({
|
||||||
|
key: `follow:${index}:${userId}:${addWay ?? 'unknown'}:${state}`,
|
||||||
|
add_way: addWay,
|
||||||
|
label,
|
||||||
|
state: state || channel?.state || '',
|
||||||
|
channel_label: channel?.label || '',
|
||||||
|
source_type: sourceType,
|
||||||
|
pool_id: channel?.pool_id || 0,
|
||||||
|
user_id: userId || channel?.user_id || '',
|
||||||
|
staff_name: formatFollowUser(user),
|
||||||
|
event_time: channel?.event_time || Number(user?.createtime ?? 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 兼容事件日志中仍有记录、但当前 follow_users 已不存在或旧接口未返回 add_way 的客户。
|
||||||
|
channels.forEach((channel, index) => {
|
||||||
|
if (usedChannelIndexes.has(index)) return
|
||||||
|
sources.push({
|
||||||
|
...channel,
|
||||||
|
key: `channel:${index}:${channel.user_id}:${channel.state}`,
|
||||||
|
add_way: channel.source_type === 'promotion_pool' ? 16 : null,
|
||||||
|
label: channel.source_type === 'promotion_pool' ? '通过获客链接添加' : '通过其他渠道添加',
|
||||||
|
channel_label: channel.label,
|
||||||
|
staff_name: channel.user_id || '—'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return sources.sort((a, b) => b.event_time - a.event_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSourceTooltip(source: AddSource) {
|
||||||
|
const parts: string[] = []
|
||||||
|
parts.push(`添加方式:${source.label}`)
|
||||||
|
if (source.source_type === 'promotion_pool' && source.channel_label) {
|
||||||
|
parts.push(`获客助手方案:${source.channel_label}`)
|
||||||
|
}
|
||||||
|
if (source.staff_name && source.staff_name !== '—') parts.push(`跟进人:${source.staff_name}`)
|
||||||
|
if (source.event_time > 0) parts.push(`添加时间:${formatTime(source.event_time)}`)
|
||||||
|
if (source.state) parts.push(`渠道参数:${source.state}`)
|
||||||
|
return parts.join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingAddSourcesTooltip(row: Record<string, any>) {
|
||||||
|
return customerAddSources(row).slice(1).map(addSourceTooltip).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
|
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
|
||||||
* 再解析 follow_users;最后退回 create_time
|
* 再解析 follow_users;最后退回 create_time
|
||||||
|
|||||||
@@ -98,8 +98,17 @@
|
|||||||
<section class="metric-grid" aria-label="综合转化指标">
|
<section class="metric-grid" aria-label="综合转化指标">
|
||||||
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
|
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
|
||||||
<span>{{ metric.label }}</span>
|
<span>{{ metric.label }}</span>
|
||||||
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
|
<strong class="metric-value">
|
||||||
<small>{{ metric.hint }}</small>
|
{{ formatMetric(metric.key, metric.type) }}
|
||||||
|
<em
|
||||||
|
v-if="metric.key === 'add_fans_count' && hasDeletedFans(dashboard.summary.deleted_fans_count)"
|
||||||
|
class="deleted-fans-marker"
|
||||||
|
title="加粉总数中已删除"
|
||||||
|
>
|
||||||
|
(-{{ formatNumber(dashboard.summary.deleted_fans_count) }})
|
||||||
|
</em>
|
||||||
|
</strong>
|
||||||
|
<small>{{ metric.hint }}<template v-if="metric.key === 'add_fans_count' && canViewDeletedFans">;(-N)为其中已删除</template></small>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -145,7 +154,7 @@
|
|||||||
<div class="panel-heading panel-heading--table">
|
<div class="panel-heading panel-heading--table">
|
||||||
<div>
|
<div>
|
||||||
<h2>明细数据列表</h2>
|
<h2>明细数据列表</h2>
|
||||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
<p>展开部门可查看人员明细;加粉=总进线=区间新增加粉(按员工+客户去重,包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加)<template v-if="canViewDeletedFans">,(-N)表示加粉总数中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +187,27 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
|
<el-table-column prop="add_fans_count" label="加粉" min-width="92" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<button
|
||||||
|
v-if="hasFans(row.add_fans_count)"
|
||||||
|
type="button"
|
||||||
|
class="fan-count-value fan-detail-trigger fan-detail-trigger--table"
|
||||||
|
title="查看该行加粉客户明细"
|
||||||
|
@click.stop="openFansDetail(row)"
|
||||||
|
>
|
||||||
|
{{ formatNumber(row.add_fans_count) }}
|
||||||
|
<em
|
||||||
|
v-if="hasDeletedFans(row.deleted_fans_count)"
|
||||||
|
class="deleted-fans-marker"
|
||||||
|
title="加粉总数中已删除"
|
||||||
|
>
|
||||||
|
(-{{ formatNumber(row.deleted_fans_count) }})
|
||||||
|
</em>
|
||||||
|
</button>
|
||||||
|
<span v-else class="fan-count-value is-empty">0</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
|
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
|
||||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
|
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
|
||||||
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
|
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
|
||||||
@@ -270,6 +299,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="fansDetailVisible"
|
||||||
|
:title="fansDetailTitle"
|
||||||
|
width="920px"
|
||||||
|
top="7vh"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
class="fans-detail-dialog"
|
||||||
|
@closed="resetFansDetail"
|
||||||
|
>
|
||||||
|
<div class="fans-detail-context">
|
||||||
|
<span><b>统计区间</b>{{ detailRangeText }}</span>
|
||||||
|
<span><b>媒体渠道</b>{{ detailChannelText }}</span>
|
||||||
|
<span><b>数据范围</b>{{ dashboard.meta.scope_label || '当前权限范围' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="fansDetailLoading"
|
||||||
|
:data="fansDetailRows"
|
||||||
|
:row-class-name="fansDetailRowClassName"
|
||||||
|
height="430"
|
||||||
|
class="fans-detail-table"
|
||||||
|
>
|
||||||
|
<el-table-column label="客户" min-width="190">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="customer-cell">
|
||||||
|
<strong class="customer-name" :class="{ 'is-deleted': isDeletedFan(row) }">
|
||||||
|
{{ fanCustomerName(row) }}
|
||||||
|
</strong>
|
||||||
|
<small v-if="row.customer_name && row.external_userid">{{ row.external_userid }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="归属员工" min-width="160">
|
||||||
|
<template #default="{ row }">{{ fanOwnerName(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="添加时间" min-width="180">
|
||||||
|
<template #default="{ row }">{{ fanAddTime(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="canViewDeletedFansDetail" label="状态" width="150" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="isDeletedFan(row)" class="deleted-status">
|
||||||
|
<el-tag type="danger" effect="dark" size="small">已删除</el-tag>
|
||||||
|
<small v-if="row.delete_time">{{ row.delete_time }}</small>
|
||||||
|
</div>
|
||||||
|
<el-tag v-else type="success" effect="plain" size="small">正常</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty :image-size="68" description="当前条件下暂无加粉客户明细" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="fans-detail-footer">
|
||||||
|
<span>共 {{ formatNumber(fansDetailPager.total) }} 位客户</span>
|
||||||
|
<el-pagination
|
||||||
|
v-if="fansDetailPager.total > 0"
|
||||||
|
v-model:current-page="fansDetailPager.page_no"
|
||||||
|
v-model:page-size="fansDetailPager.page_size"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="fansDetailPager.total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
background
|
||||||
|
@current-change="loadFansDetail"
|
||||||
|
@size-change="handleFansDetailSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -278,7 +376,14 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Lock, Refresh } from '@element-plus/icons-vue'
|
import { Lock, Refresh } from '@element-plus/icons-vue'
|
||||||
import vCharts from 'vue-echarts'
|
import vCharts from 'vue-echarts'
|
||||||
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
|
import {
|
||||||
|
firstVisitConversionFansDetail,
|
||||||
|
firstVisitConversionOverview,
|
||||||
|
type FirstVisitConversionFanDetailRow,
|
||||||
|
type FirstVisitConversionFansDetailParams,
|
||||||
|
type FirstVisitConversionFansDetailResult,
|
||||||
|
type FirstVisitConversionParams
|
||||||
|
} from '@/api/first_visit'
|
||||||
|
|
||||||
type MetricType = 'count' | 'money' | 'ratio'
|
type MetricType = 'count' | 'money' | 'ratio'
|
||||||
type MediaChannelOption = {
|
type MediaChannelOption = {
|
||||||
@@ -298,7 +403,7 @@ const emptyDashboard = () => ({
|
|||||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden',
|
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden',
|
||||||
can_view_finance: false
|
can_view_finance: false, can_view_deleted_fans: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
departments: [] as any[],
|
departments: [] as any[],
|
||||||
@@ -324,6 +429,14 @@ const query = reactive<FirstVisitConversionParams>({
|
|||||||
media_channel_code: ''
|
media_channel_code: ''
|
||||||
})
|
})
|
||||||
const customDateRange = ref<string[]>([])
|
const customDateRange = ref<string[]>([])
|
||||||
|
const fansDetailVisible = ref(false)
|
||||||
|
const fansDetailLoading = ref(false)
|
||||||
|
const fansDetailCanViewDeletedFans = ref(false)
|
||||||
|
const fansDetailRows = ref<FirstVisitConversionFanDetailRow[]>([])
|
||||||
|
const fansDetailPager = reactive({ page_no: 1, page_size: 20, total: 0 })
|
||||||
|
const fansDetailEntity = ref<{ type: 'dept' | 'member'; id: string | number; adminId?: number; name: string }>({
|
||||||
|
type: 'dept', id: 0, name: '当前行'
|
||||||
|
})
|
||||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
const timeOptions = [
|
const timeOptions = [
|
||||||
{ label: '今日', value: 'today' },
|
{ label: '今日', value: 'today' },
|
||||||
@@ -335,7 +448,7 @@ const timeOptions = [
|
|||||||
{ label: '自定义', value: 'custom' }
|
{ label: '自定义', value: 'custom' }
|
||||||
]
|
]
|
||||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,剔除已删客户、继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加' },
|
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增加粉(含已删除)' },
|
||||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||||
@@ -353,6 +466,9 @@ const scopeDescription = computed(() => {
|
|||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
})
|
})
|
||||||
const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
|
const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance))
|
||||||
|
// 仅服务端按登录账号精确判定为admin时放行;缺失、字符串或角色权限均不能兜底放行。
|
||||||
|
const canViewDeletedFans = computed(() => dashboard.meta.can_view_deleted_fans === true)
|
||||||
|
const canViewDeletedFansDetail = computed(() => canViewDeletedFans.value && fansDetailCanViewDeletedFans.value)
|
||||||
const visibleMetricCards = computed(() =>
|
const visibleMetricCards = computed(() =>
|
||||||
canViewFinance.value
|
canViewFinance.value
|
||||||
? metricCards
|
? metricCards
|
||||||
@@ -402,6 +518,13 @@ const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
|||||||
))
|
))
|
||||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||||
|
const fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉客户明细`)
|
||||||
|
const detailRangeText = computed(() => {
|
||||||
|
const startDate = dashboard.meta.start_date || query.start_date || ''
|
||||||
|
const endDate = dashboard.meta.end_date || query.end_date || ''
|
||||||
|
return startDate && endDate ? `${startDate} 至 ${endDate}` : (dashboard.meta.time_label || '当前区间')
|
||||||
|
})
|
||||||
|
const detailChannelText = computed(() => dashboard.meta.selected_media_channel_name || '全部渠道')
|
||||||
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||||
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
|
||||||
const targetChartOption = computed(() => ({
|
const targetChartOption = computed(() => ({
|
||||||
@@ -418,10 +541,12 @@ const targetChartOption = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
let latestDashboardRequestId = 0
|
let latestDashboardRequestId = 0
|
||||||
|
let latestFansDetailRequestId = 0
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
const requestId = ++latestDashboardRequestId
|
const requestId = ++latestDashboardRequestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
dashboard.meta.can_view_deleted_fans = false
|
||||||
try {
|
try {
|
||||||
const params: FirstVisitConversionParams = { ...query }
|
const params: FirstVisitConversionParams = { ...query }
|
||||||
if (params.time_type === 'custom') {
|
if (params.time_type === 'custom') {
|
||||||
@@ -470,6 +595,92 @@ function handleDeptChange() {
|
|||||||
loadDashboard()
|
loadDashboard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openFansDetail(row: any) {
|
||||||
|
fansDetailEntity.value = {
|
||||||
|
type: row.type ? 'member' : 'dept',
|
||||||
|
id: row.id,
|
||||||
|
adminId: Number(row.admin_id || 0) || undefined,
|
||||||
|
name: row.name || '当前行'
|
||||||
|
}
|
||||||
|
fansDetailPager.page_no = 1
|
||||||
|
fansDetailRows.value = []
|
||||||
|
fansDetailPager.total = 0
|
||||||
|
fansDetailVisible.value = true
|
||||||
|
loadFansDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFansDetail() {
|
||||||
|
if (!fansDetailVisible.value) return
|
||||||
|
const requestId = ++latestFansDetailRequestId
|
||||||
|
fansDetailLoading.value = true
|
||||||
|
fansDetailCanViewDeletedFans.value = false
|
||||||
|
try {
|
||||||
|
const params: FirstVisitConversionFansDetailParams = {
|
||||||
|
...query,
|
||||||
|
time_type: query.time_type,
|
||||||
|
start_date: dashboard.meta.start_date || query.start_date,
|
||||||
|
end_date: dashboard.meta.end_date || query.end_date,
|
||||||
|
media_channel_code: dashboard.meta.selected_media_channel_code || query.media_channel_code || '',
|
||||||
|
entity_type: fansDetailEntity.value.type,
|
||||||
|
entity_id: fansDetailEntity.value.id,
|
||||||
|
admin_id: fansDetailEntity.value.adminId,
|
||||||
|
page_no: fansDetailPager.page_no,
|
||||||
|
page_size: fansDetailPager.page_size
|
||||||
|
}
|
||||||
|
const result = await firstVisitConversionFansDetail(params) as FirstVisitConversionFansDetailResult
|
||||||
|
if (requestId !== latestFansDetailRequestId) return
|
||||||
|
fansDetailCanViewDeletedFans.value = result?.can_view_deleted_fans === true
|
||||||
|
const rows = Array.isArray(result?.lists) ? result.lists : (Array.isArray(result?.rows) ? result.rows : [])
|
||||||
|
fansDetailRows.value = rows
|
||||||
|
fansDetailPager.total = Number(result?.count ?? result?.total ?? rows.length) || 0
|
||||||
|
} catch (error: any) {
|
||||||
|
if (requestId !== latestFansDetailRequestId) return
|
||||||
|
fansDetailRows.value = []
|
||||||
|
fansDetailPager.total = 0
|
||||||
|
ElMessage.error(error?.message || '加粉客户明细加载失败')
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestFansDetailRequestId) fansDetailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFansDetailSizeChange() {
|
||||||
|
fansDetailPager.page_no = 1
|
||||||
|
loadFansDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFansDetail() {
|
||||||
|
++latestFansDetailRequestId
|
||||||
|
fansDetailLoading.value = false
|
||||||
|
fansDetailCanViewDeletedFans.value = false
|
||||||
|
fansDetailRows.value = []
|
||||||
|
fansDetailPager.page_no = 1
|
||||||
|
fansDetailPager.total = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFans(value: any) {
|
||||||
|
return Math.round(Number(value || 0)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeletedFan(row: FirstVisitConversionFanDetailRow) {
|
||||||
|
return canViewDeletedFansDetail.value && Boolean(row.is_deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fanCustomerName(row: FirstVisitConversionFanDetailRow) {
|
||||||
|
return row.customer_name || row.external_userid || '未知客户'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fanOwnerName(row: FirstVisitConversionFanDetailRow) {
|
||||||
|
return row.wecom_staff_name || row.wecom_userid || '未绑定员工'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fanAddTime(row: FirstVisitConversionFanDetailRow) {
|
||||||
|
return row.add_time || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fansDetailRowClassName({ row }: { row: FirstVisitConversionFanDetailRow }) {
|
||||||
|
return isDeletedFan(row) ? 'is-deleted-fan-row' : ''
|
||||||
|
}
|
||||||
|
|
||||||
function formatMetric(key: string, type: MetricType) {
|
function formatMetric(key: string, type: MetricType) {
|
||||||
const value = dashboard.summary[key]
|
const value = dashboard.summary[key]
|
||||||
if (type === 'money') return formatMoney(value)
|
if (type === 'money') return formatMoney(value)
|
||||||
@@ -481,6 +692,10 @@ function formatNumber(value: any) {
|
|||||||
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
|
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDeletedFans(value: any) {
|
||||||
|
return canViewDeletedFans.value && Math.round(Number(value || 0)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
function formatMoney(value: any) {
|
function formatMoney(value: any) {
|
||||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
}
|
}
|
||||||
@@ -621,6 +836,41 @@ onMounted(loadDashboard)
|
|||||||
small { color: #a0a9b6; font-size: 11px; }
|
small { color: #a0a9b6; font-size: 11px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-value,
|
||||||
|
.fan-count-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 3px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value { display: flex; justify-content: flex-start; }
|
||||||
|
.fan-detail-trigger {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible { outline: 2px solid #2f78df; outline-offset: 3px; border-radius: 3px; }
|
||||||
|
&:disabled { cursor: default; }
|
||||||
|
}
|
||||||
|
.deleted-fans-marker {
|
||||||
|
color: #c65f26;
|
||||||
|
font-size: .58em;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-count-value .deleted-fans-marker { font-size: 11px; }
|
||||||
|
.fan-detail-trigger--table { color: #1769aa; font-weight: 650; }
|
||||||
|
.fan-detail-trigger--table:hover { color: #0f4f82; }
|
||||||
|
.fan-count-value.is-empty { color: #98a2b3; }
|
||||||
|
|
||||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
|
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
|
||||||
|
|
||||||
@@ -683,6 +933,47 @@ onMounted(loadDashboard)
|
|||||||
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
|
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
|
||||||
.target-chart { width: 100%; height: 260px; }
|
.target-chart { width: 100%; height: 260px; }
|
||||||
|
|
||||||
|
.fans-detail-context {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 20px;
|
||||||
|
margin: -4px 0 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e4e9ef;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #5d6b7e;
|
||||||
|
background: #f7f9fb;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
span { display: inline-flex; gap: 7px; }
|
||||||
|
b { color: #8a95a5; font-weight: 500; }
|
||||||
|
}
|
||||||
|
.fans-detail-table {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.customer-cell { display: grid; gap: 3px; }
|
||||||
|
.customer-name { color: #273347; font-weight: 650; }
|
||||||
|
.customer-name.is-deleted { color: #c24137; text-decoration: line-through; text-decoration-color: #e8a39e; }
|
||||||
|
.customer-cell small { overflow: hidden; color: #98a2b3; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
:deep(.is-deleted-fan-row > td.el-table__cell) { background: #fff3f2 !important; }
|
||||||
|
}
|
||||||
|
.deleted-status {
|
||||||
|
display: inline-grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
small { color: #b14e47; font-size: 10px; }
|
||||||
|
}
|
||||||
|
.fans-detail-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #8491a2;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||||
.range-text { margin-left: 0; }
|
.range-text { margin-left: 0; }
|
||||||
@@ -698,5 +989,7 @@ onMounted(loadDashboard)
|
|||||||
.employee-select, .dept-select, .channel-select { width: 100%; }
|
.employee-select, .dept-select, .channel-select { width: 100%; }
|
||||||
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
|
||||||
.target-summary { grid-template-columns: 1fr; }
|
.target-summary { grid-template-columns: 1fr; }
|
||||||
|
.fans-detail-footer { align-items: flex-start; flex-direction: column; }
|
||||||
|
.fans-detail-footer :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div class="automation-form">
|
||||||
|
<el-alert class="automation-note" type="info" show-icon :closable="false" title="自动化设置只作用于之后新添加的客户,不会写入企微获客链接详情中的“欢迎语/客户标签”配置。" description="系统会在客户添加回调中立即发送渠道欢迎语并添加标签,后台任务负责失败重试及其他补偿。测试时请使用系统复制的、带渠道参数的链接。" />
|
||||||
|
|
||||||
|
<section class="automation-section" :class="{ 'is-disabled': receptionDisabled }">
|
||||||
|
<h3 class="form-section-title">接待设置</h3>
|
||||||
|
<el-form-item label="接待模式">
|
||||||
|
<el-radio-group v-model="config.reception_mode" :disabled="receptionDisabled">
|
||||||
|
<el-radio value="always">全天接待</el-radio>
|
||||||
|
<el-radio value="scheduled">按星期时段自动上下线</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<p class="field-help">成员仍受每日上限、有效期及启用状态限制;时间统一使用北京时间。</p>
|
||||||
|
</el-form-item>
|
||||||
|
<div v-if="config.reception_mode === 'scheduled'" class="reception-schedules">
|
||||||
|
<div v-for="(slot, index) in config.reception_schedule" :key="index" class="schedule-card">
|
||||||
|
<div class="schedule-heading"><strong>接待时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="receptionDisabled" @click="config.reception_schedule.splice(index, 1)">删除时段</el-button></div>
|
||||||
|
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="receptionDisabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
||||||
|
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||||
|
<el-select v-model="slot.member_admin_ids" :disabled="receptionDisabled" multiple filterable clearable placeholder="从上方主接待成员中选择" style="width: 100%">
|
||||||
|
<el-option v-for="member in mainMembers" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" />
|
||||||
|
</el-select>
|
||||||
|
<p v-if="slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))" class="inline-error">该时段含已从主接待移除的成员,请重新选择。</p>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Plus" :disabled="receptionDisabled || config.reception_schedule.length >= 30" @click="addReceptionSlot">添加接待时段</el-button>
|
||||||
|
<p class="field-help">最多 30 个时段。跨午夜时段归属开始日,例如星期一 22:00 至 02:00 包含星期二凌晨;接待时段重叠时取成员并集。</p>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="备用成员" :required="config.reception_mode === 'scheduled'">
|
||||||
|
<el-select v-model="config.backup_member_admin_ids" :disabled="receptionDisabled" multiple filterable clearable collapse-tags collapse-tags-tooltip :max-collapse-tags="3" :multiple-limit="500" placeholder="主接待成员均不可用时由备用成员接待" style="width: 100%">
|
||||||
|
<el-option v-for="member in members" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" :disabled="backupExcludedIds.includes(Number(member.id))" />
|
||||||
|
<el-option v-for="id in missingBackupIds" :key="`missing-${id}`" :value="id" :label="`成员 ${id}(当前不可选,请移除后重新选择)`" disabled />
|
||||||
|
</el-select>
|
||||||
|
<p class="field-help">备用成员不能与主接待重复。按时段模式至少配置一名备用成员;仅当无可用主接待时进入官方成员范围。</p>
|
||||||
|
</el-form-item>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="automation-section" :class="{ 'is-disabled': customerDisabled }">
|
||||||
|
<h3 class="form-section-title">客户设置</h3>
|
||||||
|
<el-form-item label="自动添加客户标签">
|
||||||
|
<el-switch v-model="config.tags_enabled" :disabled="customerDisabled || tagsCreating" />
|
||||||
|
<div v-if="hasMultipleTags" class="legacy-tags-warning full-width" role="alert">
|
||||||
|
<p>原方案设置了多个标签:{{ selectedTagNames }}。现在仅支持单选,请重新选择一个标签,或清空原标签。</p>
|
||||||
|
<el-button size="small" :disabled="customerDisabled || tagsCreating" @click="selectedTag = ''">清空原标签</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="config.tags_enabled" class="full-width tags-content">
|
||||||
|
<div class="tag-select-row">
|
||||||
|
<el-select v-model="selectedTag" :disabled="customerDisabled || tagsCreating" :loading="tagsLoading" filterable clearable placeholder="选择一个企业微信客户标签" aria-label="企业微信客户标签" class="tag-select">
|
||||||
|
<el-option-group v-for="group in tagGroups" :key="group.group_id" :label="group.group_name">
|
||||||
|
<el-option v-for="tag in group.tag" :key="tag.id" :value="tag.id" :label="tag.name" />
|
||||||
|
</el-option-group>
|
||||||
|
<el-option-group v-if="unknownTagIds.length" label="已选标签(名称暂不可用)"><el-option v-for="id in unknownTagIds" :key="id" :value="id" :label="`已选标签 · ${id}`" /></el-option-group>
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Plus" :disabled="customerDisabled || tagsCreating" @click="showCustomTag = !showCustomTag">自定义标签</el-button>
|
||||||
|
<el-button :icon="Refresh" :disabled="customerDisabled || tagsCreating" :loading="tagsLoading" @click="loadTags">{{ tagsError ? '重试' : '刷新标签' }}</el-button>
|
||||||
|
</div>
|
||||||
|
<p v-if="tagsError" role="alert" class="inline-error">{{ tagsError }} 已保留原有标签,点击“重试”重新加载。</p>
|
||||||
|
<p v-else class="field-help">每个方案只选一个标签,可选择已有企业微信标签,也可自定义创建。客户添加成功后由系统调用企微接口打标,不会显示在企微获客链接详情的“客户标签”配置中。</p>
|
||||||
|
<div v-if="showCustomTag" class="custom-tag-editor">
|
||||||
|
<label for="promotion-custom-tag-name">自定义标签名称</label>
|
||||||
|
<div class="custom-tag-row">
|
||||||
|
<el-input id="promotion-custom-tag-name" v-model="customTagName" :disabled="customerDisabled || tagsCreating" maxlength="30" show-word-limit placeholder="例如:官网咨询" @input="customTagError = ''" @keydown.enter.prevent="createCustomTag" />
|
||||||
|
<el-button type="primary" :disabled="customerDisabled || tagsLoading" :loading="tagsCreating" @click="createCustomTag">创建并选用</el-button>
|
||||||
|
</div>
|
||||||
|
<p class="field-help">创建到企业微信“推广渠道”分组,同组同名标签会复用。创建后即保存到企微标签库,取消方案编辑不会删除标签。</p>
|
||||||
|
<p v-if="customTagError" role="alert" class="inline-error">{{ customTagError }} 原有选择未改变。</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="customTagSuccess" role="status" class="tag-success">{{ customTagSuccess }}</p>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自动设置客户备注">
|
||||||
|
<el-switch v-model="config.remark_enabled" :disabled="customerDisabled" />
|
||||||
|
<div v-if="config.remark_enabled" class="full-width remark-content">
|
||||||
|
<div class="token-buttons"><el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="customerDisabled" @click="insertRemark(token.value)">插入{{ token.label }}</el-button></div>
|
||||||
|
<el-input ref="remarkInput" v-model="config.remark_template" :disabled="customerDisabled" maxlength="200" show-word-limit placeholder="例如:官网-{customer_name}" @select="rememberRemarkSelection" @keyup="rememberRemarkSelection" @click="rememberRemarkSelection" @blur="rememberRemarkSelection" />
|
||||||
|
<div class="remark-preview"><span>备注预览</span><strong>{{ remarkPreview || '—' }}</strong><small>{{ Array.from(remarkPreview).length }}/20 字</small></div>
|
||||||
|
<p class="field-help">示例客户:张女士;员工:{{ employeeName }}。添加时间格式为 YYYY-MM-DD,生成后的备注最多保留前 20 字。</p>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自动设置客户描述">
|
||||||
|
<el-switch v-model="config.description_enabled" :disabled="customerDisabled" />
|
||||||
|
<el-input v-if="config.description_enabled" v-model="config.description" class="description-input" :disabled="customerDisabled" type="textarea" :rows="3" maxlength="150" show-word-limit placeholder="请输入客户描述,最多 150 字" />
|
||||||
|
</el-form-item>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="automation-section" :class="{ 'is-disabled': welcomeDisabled }">
|
||||||
|
<h3 class="form-section-title">欢迎语设置</h3>
|
||||||
|
<el-form-item label="欢迎语模式">
|
||||||
|
<el-radio-group v-model="config.welcome_mode" :disabled="welcomeDisabled || anyUploading">
|
||||||
|
<el-radio value="channel">渠道欢迎语</el-radio>
|
||||||
|
<el-radio value="default">默认欢迎语</el-radio>
|
||||||
|
<el-radio value="none">不发送欢迎语</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<p v-if="config.welcome_mode === 'default'" class="field-help">沿用企业微信后台配置,本系统不发送欢迎语。</p>
|
||||||
|
<p v-else-if="config.welcome_mode === 'none'" class="field-help warning-help">仅关闭本系统欢迎语,无法覆盖或关闭企业微信后台已配置的欢迎语。</p>
|
||||||
|
<p v-else class="field-help warning-help">渠道欢迎语由客户添加回调即时发送,不会显示在企微获客链接详情中。同一接待成员若已配置企微原生欢迎语,企微可能不再下发欢迎码,请先关闭冲突配置。</p>
|
||||||
|
</el-form-item>
|
||||||
|
<template v-if="config.welcome_mode === 'channel'">
|
||||||
|
<div class="welcome-block"><h4>基础渠道欢迎语</h4><p class="field-help">未开启分时欢迎语,或新客户添加时间未匹配任何时段时,使用以下内容。</p>
|
||||||
|
<WelcomeMessageEditor v-model="config.welcome" :disabled="welcomeDisabled" :employee-name="employeeName" @busy="(busy) => updateBusy('basic', busy)" />
|
||||||
|
</div>
|
||||||
|
<el-form-item class="schedule-switch" label="分时欢迎语"><el-switch v-model="config.welcome_schedule_enabled" :disabled="welcomeDisabled || anyUploading" /><span class="switch-help">按客户添加时的北京时间匹配,时段不能重叠</span></el-form-item>
|
||||||
|
<div v-if="config.welcome_schedule_enabled">
|
||||||
|
<div v-for="(slot, index) in config.welcome_schedule" :key="index" class="schedule-card welcome-schedule">
|
||||||
|
<div class="schedule-heading"><strong>欢迎语时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="welcomeDisabled || anyUploading" @click="removeWelcomeSlot(index)">删除时段</el-button></div>
|
||||||
|
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="welcomeDisabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
||||||
|
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||||
|
<WelcomeMessageEditor :model-value="slot" :disabled="welcomeDisabled" :employee-name="employeeName" @update:model-value="(message) => Object.assign(slot, message)" @busy="(busy) => updateBusy(`slot-${index}`, busy)" />
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Plus" :disabled="welcomeDisabled || anyUploading || config.welcome_schedule.length >= 30" @click="addWelcomeSlot">添加欢迎语时段</el-button>
|
||||||
|
<p class="field-help">最多 30 个时段,支持跨午夜。时段外自动使用基础渠道欢迎语,不会随机选择内容。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { wecomPromotionCreateTag, wecomPromotionTagOptions } from '@/api/first_visit'
|
||||||
|
import type { WecomPromotionTagGroup } from '@/api/first_visit'
|
||||||
|
import WelcomeMessageEditor from './WelcomeMessageEditor.vue'
|
||||||
|
import { previewTemplate, templateTokens, validateCustomTagName, weekdays } from './promotion-automation'
|
||||||
|
import type { PromotionAutomationConfig, PromotionMemberChoice } from './promotion-automation'
|
||||||
|
|
||||||
|
type AutomationSection = 'reception' | 'customer' | 'welcome'
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: PromotionAutomationConfig
|
||||||
|
mainMemberIds: number[]
|
||||||
|
members: PromotionMemberChoice[]
|
||||||
|
disabled?: boolean
|
||||||
|
disabledSections?: AutomationSection[]
|
||||||
|
backupExcludedMemberIds?: number[]
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>()
|
||||||
|
const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||||
|
const sectionDisabled = (section: AutomationSection) => Boolean(props.disabled || props.disabledSections?.includes(section))
|
||||||
|
const receptionDisabled = computed(() => sectionDisabled('reception'))
|
||||||
|
const customerDisabled = computed(() => sectionDisabled('customer'))
|
||||||
|
const welcomeDisabled = computed(() => sectionDisabled('welcome'))
|
||||||
|
const backupExcludedIds = computed(() => props.backupExcludedMemberIds || props.mainMemberIds)
|
||||||
|
const mainMembers = computed(() => props.members.filter((member) => props.mainMemberIds.includes(Number(member.id))))
|
||||||
|
const missingBackupIds = computed(() => config.value.backup_member_admin_ids.filter((id) => !props.members.some((member) => Number(member.id) === id)))
|
||||||
|
const employeeName = computed(() => mainMembers.value[0]?.name || '小陈')
|
||||||
|
const remarkPreview = computed(() => previewTemplate(config.value.remark_template, employeeName.value, 20))
|
||||||
|
const remarkInput = ref<{ input?: HTMLInputElement }>()
|
||||||
|
const remarkSelection = ref({ start: 0, end: 0 })
|
||||||
|
const tagGroups = ref<WecomPromotionTagGroup[]>([])
|
||||||
|
const tagsLoading = ref(false)
|
||||||
|
const tagsError = ref('')
|
||||||
|
const tagsLoaded = ref(false)
|
||||||
|
const tagsCreating = ref(false)
|
||||||
|
const showCustomTag = ref(false)
|
||||||
|
const customTagName = ref('')
|
||||||
|
const customTagError = ref('')
|
||||||
|
const customTagSuccess = ref('')
|
||||||
|
const hasMultipleTags = computed(() => config.value.tag_ids.length > 1)
|
||||||
|
const selectedTag = computed({
|
||||||
|
get: () => config.value.tag_ids.length === 1 ? config.value.tag_ids[0] : '',
|
||||||
|
set: (id: string | undefined) => {
|
||||||
|
config.value.tag_ids = id ? [id] : []
|
||||||
|
customTagSuccess.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const selectedTagNames = computed(() => {
|
||||||
|
const tags = tagGroups.value.flatMap((group) => group.tag)
|
||||||
|
return config.value.tag_ids.map((id) => tags.find((tag) => tag.id === id)?.name || id).join('、')
|
||||||
|
})
|
||||||
|
const busyEditors = ref(new Set<string>())
|
||||||
|
const anyUploading = computed(() => busyEditors.value.size > 0)
|
||||||
|
watch([tagsCreating, anyUploading], ([creating, uploading]) => emit('busy', creating || uploading), { flush: 'sync' })
|
||||||
|
const unknownTagIds = computed(() => {
|
||||||
|
const ids = new Set(tagGroups.value.flatMap((group) => group.tag.map((tag) => tag.id)))
|
||||||
|
return config.value.tag_ids.filter((id) => !ids.has(id))
|
||||||
|
})
|
||||||
|
watch([() => config.value.tags_enabled, customerDisabled], ([enabled, sectionIsDisabled]) => {
|
||||||
|
if (enabled && !sectionIsDisabled && !tagsLoaded.value && !tagsLoading.value) void loadTags()
|
||||||
|
}, { immediate: true })
|
||||||
|
function memberLabel(member: PromotionMemberChoice) { return `${member.name} · ${member.dept_names?.join(' / ') || member.userid || '未分部门'}` }
|
||||||
|
function addReceptionSlot() { config.value.reception_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', member_admin_ids: [...props.mainMemberIds] }) }
|
||||||
|
function addWelcomeSlot() { config.value.welcome_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', text: '', attachments: [] }) }
|
||||||
|
function removeWelcomeSlot(index: number) { config.value.welcome_schedule.splice(index, 1); busyEditors.value.clear() }
|
||||||
|
function updateBusy(key: string, busy: boolean) { busy ? busyEditors.value.add(key) : busyEditors.value.delete(key) }
|
||||||
|
async function loadTags() {
|
||||||
|
if (tagsCreating.value || tagsLoading.value) return
|
||||||
|
tagsLoading.value = true
|
||||||
|
tagsError.value = ''
|
||||||
|
try {
|
||||||
|
const result = await wecomPromotionTagOptions()
|
||||||
|
if (!Array.isArray(result?.tag_groups)) throw new Error('标签接口未返回有效的标签列表。')
|
||||||
|
tagGroups.value = result.tag_groups.map((group) => ({ ...group, tag: Array.isArray(group.tag) ? group.tag : [] }))
|
||||||
|
tagsLoaded.value = true
|
||||||
|
} catch (error: unknown) {
|
||||||
|
tagsError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '企业微信标签加载失败。'
|
||||||
|
} finally { tagsLoading.value = false }
|
||||||
|
}
|
||||||
|
async function createCustomTag() {
|
||||||
|
if (customerDisabled.value || tagsCreating.value || tagsLoading.value) return
|
||||||
|
customTagError.value = validateCustomTagName(customTagName.value)
|
||||||
|
customTagSuccess.value = ''
|
||||||
|
if (customTagError.value) return
|
||||||
|
tagsCreating.value = true
|
||||||
|
try {
|
||||||
|
const result = await wecomPromotionCreateTag({ name: customTagName.value.trim() })
|
||||||
|
if (!result?.tag?.id || !result.tag.name || !result.group_id) throw new Error('企业微信未返回有效的标签 ID,请刷新标签后确认。')
|
||||||
|
let group = tagGroups.value.find((item) => item.group_id === result.group_id)
|
||||||
|
if (!group) {
|
||||||
|
group = { group_id: result.group_id, group_name: result.group_name, tag: [] }
|
||||||
|
tagGroups.value.push(group)
|
||||||
|
}
|
||||||
|
const existing = group.tag.findIndex((tag) => tag.id === result.tag.id)
|
||||||
|
if (existing >= 0) group.tag[existing] = result.tag
|
||||||
|
else group.tag.push(result.tag)
|
||||||
|
selectedTag.value = result.tag.id
|
||||||
|
customTagSuccess.value = `${result.reused ? '已选用已有' : '已创建并选用'}标签“${result.tag.name}”,保存方案后生效。`
|
||||||
|
customTagName.value = ''
|
||||||
|
showCustomTag.value = false
|
||||||
|
} catch (error: unknown) {
|
||||||
|
customTagError.value = error instanceof Error ? error.message : typeof error === 'string' ? error : '自定义标签创建失败,请刷新确认或重试。'
|
||||||
|
} finally { tagsCreating.value = false }
|
||||||
|
}
|
||||||
|
function rememberRemarkSelection() {
|
||||||
|
const input = remarkInput.value?.input
|
||||||
|
if (input) remarkSelection.value = { start: input.selectionStart || 0, end: input.selectionEnd || 0 }
|
||||||
|
}
|
||||||
|
async function insertRemark(token: string) {
|
||||||
|
const { start, end } = remarkSelection.value
|
||||||
|
config.value.remark_template = config.value.remark_template.slice(0, start) + token + config.value.remark_template.slice(end)
|
||||||
|
await nextTick()
|
||||||
|
const cursor = start + token.length
|
||||||
|
remarkInput.value?.input?.focus()
|
||||||
|
remarkInput.value?.input?.setSelectionRange(cursor, cursor)
|
||||||
|
remarkSelection.value = { start: cursor, end: cursor }
|
||||||
|
}
|
||||||
|
onBeforeUnmount(() => emit('busy', false))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.automation-form { width: 100%; }.automation-note { margin-top: 22px; }.automation-note :deep(.el-alert__description) { line-height: 1.7; }
|
||||||
|
.automation-section { min-width: 0; transition: opacity .2s ease; }.automation-section.is-disabled { opacity: .58; }
|
||||||
|
.form-section-title { margin: 28px 0 18px; padding: 0 0 12px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; }.field-help { width: 100%; font-size: 12px; line-height: 1.7; margin: 6px 0 0; color: #909399; }.warning-help { color: #9f6d14; }.full-width { width: 100%; }.inline-error { width: 100%; color: #d93026; font-size: 12px; line-height: 1.7; margin: 8px 0 0; }
|
||||||
|
.schedule-card { padding: 16px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fafbfd; margin-bottom: 12px; }.schedule-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 13px; }.weekday-select { display: flex; flex-wrap: wrap; gap: 0 18px; }.weekday-select :deep(.el-checkbox) { margin-right: 0; }.time-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }.time-row :deep(.el-date-editor.el-input) { width: 150px; }.time-row > span { font-size: 12px; color: #909399; }.time-row > small { font-size: 12px; color: #b88230; }.reception-schedules { margin: 0 0 20px; }
|
||||||
|
.tags-content, .remark-content, .description-input { margin-top: 12px; }.tag-select-row { display: flex; gap: 10px; width: 100%; }.tag-select { flex: 1; min-width: 0; }.token-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }.token-buttons .el-button + .el-button { margin-left: 0; }.remark-preview { display: flex; gap: 14px; align-items: center; padding: 10px 12px; background: #f5f7fa; margin-top: 8px; border-radius: 4px; line-height: 1.7; }.remark-preview span, .remark-preview small { color: #909399; font-size: 12px; }.remark-preview strong { color: #303133; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }.remark-preview small { margin-left: auto; white-space: nowrap; }.welcome-block h4 { font-size: 13px; font-weight: 600; margin: 0 0 4px; }.welcome-block > .field-help { margin-bottom: 12px; }.schedule-switch { margin-top: 24px; }.switch-help { margin-left: 12px; color: #909399; font-size: 12px; }.welcome-schedule { background: #fff; }
|
||||||
|
.tag-select-row .el-button + .el-button { margin-left: 0; }
|
||||||
|
.custom-tag-editor { margin-top: 12px; padding: 14px; background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; }
|
||||||
|
.custom-tag-editor label { display: block; font-size: 13px; color: #606266; margin-bottom: 8px; }
|
||||||
|
.custom-tag-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.custom-tag-row .el-input { flex: 1; min-width: 0; }
|
||||||
|
.legacy-tags-warning { margin-top: 10px; padding: 10px 12px; background: #fdf6ec; border: 1px solid #faecd8; border-radius: 4px; color: #9f6d14; }
|
||||||
|
.legacy-tags-warning p { margin: 0 0 6px; font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
|
.tag-success { margin: 8px 0 0; color: #27864c; font-size: 12px; line-height: 1.7; }
|
||||||
|
@media (max-width: 620px) { .tag-select-row { flex-direction: column; }.remark-preview { flex-wrap: wrap; }.automation-form :deep(.el-radio) { margin-right: 14px; }.weekday-select { gap: 0 12px; } }
|
||||||
|
</style>
|
||||||
+6
-3
@@ -239,7 +239,7 @@
|
|||||||
|
|
||||||
<div class="install-tip">
|
<div class="install-tip">
|
||||||
<el-icon><CircleCheck /></el-icon>
|
<el-icon><CircleCheck /></el-icon>
|
||||||
<span><strong>无需暴露链接池</strong>浮窗点击后仍由服务端执行可用性筛选、权重随机和访问统计。</span>
|
<span><strong>一个方案,一个官方链接</strong>浮窗会直接打开企业微信链接,多名医助由企业微信执行均衡分流。</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -274,6 +274,8 @@ interface PromotionPool {
|
|||||||
name?: string
|
name?: string
|
||||||
public_key?: string
|
public_key?: string
|
||||||
script_url?: string
|
script_url?: string
|
||||||
|
main_url?: string
|
||||||
|
compat_go_url?: string
|
||||||
go_url?: string
|
go_url?: string
|
||||||
install_code?: string
|
install_code?: string
|
||||||
trigger_code?: string
|
trigger_code?: string
|
||||||
@@ -437,11 +439,12 @@ async function copySnippet(value: string | undefined, label: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openTestLink() {
|
function openTestLink() {
|
||||||
if (!props.pool.go_url) {
|
const targetUrl = props.pool.main_url || props.pool.go_url || props.pool.compat_go_url
|
||||||
|
if (!targetUrl) {
|
||||||
ElMessage.warning('当前方案暂无测试链接')
|
ElMessage.warning('当前方案暂无测试链接')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.open(props.pool.go_url, '_blank', 'noopener,noreferrer')
|
window.open(targetUrl, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div class="welcome-editor">
|
||||||
|
<div class="welcome-editor__fields">
|
||||||
|
<div class="text-tools">
|
||||||
|
<el-popover placement="bottom-start" trigger="click" :width="240" :disabled="disabled">
|
||||||
|
<template #reference><el-button size="small" :disabled="disabled">☺ 插入表情</el-button></template>
|
||||||
|
<div class="emoji-grid"><button v-for="emoji in emojis" :key="emoji" type="button" @click="insertText(emoji)">{{ emoji }}</button></div>
|
||||||
|
</el-popover>
|
||||||
|
<el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="disabled" @click="insertText(token.value)">{{ token.label }}</el-button>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
ref="textInput"
|
||||||
|
:model-value="modelValue.text"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
:disabled="disabled"
|
||||||
|
placeholder="请输入欢迎语,也可插入客户昵称、员工昵称和添加日期"
|
||||||
|
@update:model-value="updateText"
|
||||||
|
@select="rememberSelection"
|
||||||
|
@keyup="rememberSelection"
|
||||||
|
@click="rememberSelection"
|
||||||
|
@blur="rememberSelection"
|
||||||
|
/>
|
||||||
|
<div class="text-count" :class="{ 'is-error': textTooLong }">{{ Array.from(modelValue.text).length }}/1200 字 · {{ utf8Length(modelValue.text) }}/4000 字节</div>
|
||||||
|
<div class="attachments-heading">
|
||||||
|
<strong>附件 <span>{{ modelValue.attachments.length }}/9</span></strong>
|
||||||
|
<el-dropdown :disabled="disabled || modelValue.attachments.length >= 9 || uploading" @command="addAttachment">
|
||||||
|
<el-button size="small" :disabled="disabled || modelValue.attachments.length >= 9 || uploading" :icon="Plus">添加附件</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-for="item in attachmentTypes" :key="item.value" :command="item.value">{{ item.label }}</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
<div v-if="!modelValue.attachments.length" class="attachment-empty">可添加图片、网页、小程序、视频或文件。</div>
|
||||||
|
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="attachment-card">
|
||||||
|
<div class="attachment-card__heading">
|
||||||
|
<strong>{{ index + 1 }}. {{ attachmentLabel(attachment.msgtype) }}</strong>
|
||||||
|
<div>
|
||||||
|
<el-button text size="small" :disabled="disabled || uploading || index === 0" @click="moveAttachment(index, -1)">上移</el-button>
|
||||||
|
<el-button text size="small" :disabled="disabled || uploading || index === modelValue.attachments.length - 1" @click="moveAttachment(index, 1)">下移</el-button>
|
||||||
|
<el-button text type="danger" size="small" :disabled="disabled || uploading" @click="removeAttachment(index)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="attachment.msgtype === 'image' || attachment.msgtype === 'video' || attachment.msgtype === 'file'">
|
||||||
|
<div class="upload-field">
|
||||||
|
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, attachment.msgtype)">{{ assetId(attachment) ? '重新上传' : '上传' }}{{ attachmentLabel(attachment.msgtype) }}</el-button>
|
||||||
|
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="field-tip">{{ attachment.msgtype === 'image' ? 'JPG / PNG,最多 10MB。' : attachment.msgtype === 'video' ? 'MP4,最多 10MB。' : '文件最多 20MB。' }}上传成功后由服务端保存素材;未上传完成不能提交。</small>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="attachment.msgtype === 'link'">
|
||||||
|
<label class="attachment-label">网页标题 <span>{{ utf8Length(attachment.link.title) }}/128 字节</span></label>
|
||||||
|
<el-input v-model="attachment.link.title" :disabled="disabled" placeholder="必填:网页标题" />
|
||||||
|
<label class="attachment-label">网页地址</label>
|
||||||
|
<el-input v-model="attachment.link.url" :disabled="disabled" placeholder="https://" />
|
||||||
|
<label class="attachment-label">网页描述 <span>{{ utf8Length(attachment.link.desc) }}/512 字节</span></label>
|
||||||
|
<el-input v-model="attachment.link.desc" :disabled="disabled" type="textarea" :rows="2" placeholder="选填:网页摘要" />
|
||||||
|
<label class="attachment-label">网页封面地址</label>
|
||||||
|
<el-input v-model="attachment.link.picurl" :disabled="disabled" placeholder="选填:公网可访问的 HTTPS 图片地址" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="attachment.msgtype === 'miniprogram'">
|
||||||
|
<label class="attachment-label">小程序标题 <span>{{ utf8Length(attachment.miniprogram.title) }}/64 字节</span></label>
|
||||||
|
<el-input v-model="attachment.miniprogram.title" :disabled="disabled" placeholder="必填:小程序标题" />
|
||||||
|
<label class="attachment-label">AppID</label>
|
||||||
|
<el-input v-model="attachment.miniprogram.appid" :disabled="disabled" placeholder="必填:小程序 AppID" />
|
||||||
|
<label class="attachment-label">页面路径</label>
|
||||||
|
<el-input v-model="attachment.miniprogram.page" :disabled="disabled" placeholder="pages/index/index,可包含查询参数" />
|
||||||
|
<div class="upload-field mini-upload">
|
||||||
|
<el-button size="small" :icon="Upload" :loading="uploadingIndex === index" :disabled="disabled || uploading" @click="selectFile(index, 'image')">{{ attachment.miniprogram.pic_asset_id ? '更换封面' : '上传小程序封面' }}</el-button>
|
||||||
|
<span :class="assetId(attachment) ? 'asset-ready' : 'muted'">{{ assetName(attachment) }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="field-tip">JPG / PNG,最多 10MB。</small>
|
||||||
|
</template>
|
||||||
|
<div v-if="uploadErrors[index]" role="alert" class="upload-error">{{ uploadErrors[index] }} 请重试上传,原素材未被替换。</div>
|
||||||
|
</div>
|
||||||
|
<input ref="fileInput" class="file-input" type="file" :accept="fileAccept" @change="uploadSelectedFile" />
|
||||||
|
</div>
|
||||||
|
<aside class="welcome-preview" aria-label="欢迎语手机预览">
|
||||||
|
<div class="phone-heading"><span>‹</span><strong>{{ employeeName || '接待员工' }}</strong><span>···</span></div>
|
||||||
|
<div class="phone-content">
|
||||||
|
<div class="preview-time">添加成功 · 预览示例</div>
|
||||||
|
<div v-if="modelValue.text" class="chat-row"><span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span><div class="chat-bubble">{{ previewTemplate(modelValue.text, employeeName) }}</div></div>
|
||||||
|
<div v-for="(attachment, index) in modelValue.attachments" :key="index" class="chat-row">
|
||||||
|
<span class="chat-avatar">{{ (employeeName || '员').slice(0, 1) }}</span>
|
||||||
|
<div class="chat-bubble attachment-preview">
|
||||||
|
<template v-if="attachment.msgtype === 'image'">
|
||||||
|
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="欢迎语图片预览" />
|
||||||
|
<div v-else class="media-placeholder"><el-icon><Picture /></el-icon><span>{{ assetId(attachment) ? '已保存的图片素材' : '待上传图片' }}</span></div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="attachment.msgtype === 'link'">
|
||||||
|
<strong>{{ attachment.link.title || '网页标题' }}</strong><p>{{ attachment.link.desc || '网页摘要' }}</p><small>网页链接</small>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="attachment.msgtype === 'miniprogram'">
|
||||||
|
<small>小程序</small><strong>{{ attachment.miniprogram.title || '小程序标题' }}</strong>
|
||||||
|
<img v-if="assetPreview(attachment)" :src="assetPreview(attachment)" alt="小程序封面预览" />
|
||||||
|
<div v-else class="media-placeholder"><el-icon><Grid /></el-icon><span>小程序封面</span></div>
|
||||||
|
</template>
|
||||||
|
<template v-else><el-icon class="file-icon"><VideoPlay v-if="attachment.msgtype === 'video'" /><Document v-else /></el-icon><strong>{{ assetName(attachment) }}</strong><small>{{ attachmentLabel(attachment.msgtype) }}</small></template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!modelValue.text && !modelValue.attachments.length" class="preview-empty">编辑内容后在这里预览</div>
|
||||||
|
</div>
|
||||||
|
<div class="phone-input"><span>⊕</span><span class="phone-input__blank" /><span>☺</span></div>
|
||||||
|
<p class="preview-note">仅为排版示意,昵称与时间会替换为实际值。</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Document, Grid, Picture, Plus, Upload, VideoPlay } from '@element-plus/icons-vue'
|
||||||
|
import { wecomPromotionUploadWelcomeMedia } from '@/api/first_visit'
|
||||||
|
import { previewTemplate, templateTokens, utf8Length } from './promotion-automation'
|
||||||
|
import type { WelcomeAttachment, WelcomeMessage } from './promotion-automation'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: WelcomeMessage; disabled?: boolean; employeeName?: string }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: WelcomeMessage]; busy: [value: boolean] }>()
|
||||||
|
const textInput = ref<{ textarea?: HTMLTextAreaElement }>()
|
||||||
|
const fileInput = ref<HTMLInputElement>()
|
||||||
|
const selection = ref({ start: 0, end: 0 })
|
||||||
|
const uploadingIndex = ref(-1)
|
||||||
|
const uploading = computed(() => uploadingIndex.value >= 0)
|
||||||
|
const uploadErrors = ref<Record<number, string>>({})
|
||||||
|
const assetCache = ref<Record<string, { name: string; url?: string }>>({})
|
||||||
|
const selectedUpload = ref<{ index: number; type: 'image' | 'video' | 'file' }>({ index: 0, type: 'image' })
|
||||||
|
const fileAccept = ref('image/jpeg,image/png')
|
||||||
|
const emojis = ['😊', '😀', '👋', '🌹', '❤️', '👍', '🙏', '🎉', '☀️', '✨', '💐', '🤝', '💬', '✅', '🌿', '🍀']
|
||||||
|
const attachmentTypes: Array<{ value: WelcomeAttachment['msgtype']; label: string }> = [
|
||||||
|
{ value: 'image', label: '图片' }, { value: 'link', label: '网页' }, { value: 'miniprogram', label: '小程序' },
|
||||||
|
{ value: 'video', label: '视频' }, { value: 'file', label: '文件' }
|
||||||
|
]
|
||||||
|
const textTooLong = computed(() => Array.from(props.modelValue.text).length > 1200 || utf8Length(props.modelValue.text) > 4000)
|
||||||
|
|
||||||
|
function updateText(text: string) { emit('update:modelValue', { ...props.modelValue, text }) }
|
||||||
|
function rememberSelection() {
|
||||||
|
const textarea = textInput.value?.textarea
|
||||||
|
if (textarea) selection.value = { start: textarea.selectionStart, end: textarea.selectionEnd }
|
||||||
|
}
|
||||||
|
async function insertText(text: string) {
|
||||||
|
if (props.disabled) return
|
||||||
|
const { start, end } = selection.value
|
||||||
|
updateText(props.modelValue.text.slice(0, start) + text + props.modelValue.text.slice(end))
|
||||||
|
await nextTick()
|
||||||
|
const cursor = start + text.length
|
||||||
|
textInput.value?.textarea?.focus()
|
||||||
|
textInput.value?.textarea?.setSelectionRange(cursor, cursor)
|
||||||
|
selection.value = { start: cursor, end: cursor }
|
||||||
|
}
|
||||||
|
function attachmentLabel(type: string) { return attachmentTypes.find((item) => item.value === type)?.label || '附件' }
|
||||||
|
function addAttachment(type: WelcomeAttachment['msgtype']) {
|
||||||
|
if (props.disabled || uploading.value || props.modelValue.attachments.length >= 9) return
|
||||||
|
let attachment: WelcomeAttachment
|
||||||
|
if (type === 'image') attachment = { msgtype: 'image', image: { asset_id: '' } }
|
||||||
|
else if (type === 'video') attachment = { msgtype: 'video', video: { asset_id: '' } }
|
||||||
|
else if (type === 'file') attachment = { msgtype: 'file', file: { asset_id: '' } }
|
||||||
|
else if (type === 'link') attachment = { msgtype: 'link', link: { title: '', url: '', desc: '', picurl: '' } }
|
||||||
|
else attachment = { msgtype: 'miniprogram', miniprogram: { title: '', appid: '', page: '', pic_asset_id: '' } }
|
||||||
|
emit('update:modelValue', { ...props.modelValue, attachments: [...props.modelValue.attachments, attachment] })
|
||||||
|
}
|
||||||
|
function removeAttachment(index: number) {
|
||||||
|
uploadErrors.value = {}
|
||||||
|
emit('update:modelValue', { ...props.modelValue, attachments: props.modelValue.attachments.filter((_, position) => position !== index) })
|
||||||
|
}
|
||||||
|
function moveAttachment(index: number, direction: number) {
|
||||||
|
const attachments = [...props.modelValue.attachments]
|
||||||
|
;[attachments[index], attachments[index + direction]] = [attachments[index + direction], attachments[index]]
|
||||||
|
uploadErrors.value = {}
|
||||||
|
emit('update:modelValue', { ...props.modelValue, attachments })
|
||||||
|
}
|
||||||
|
function assetId(attachment: WelcomeAttachment): string {
|
||||||
|
if (attachment.msgtype === 'image') return attachment.image.asset_id || ''
|
||||||
|
if (attachment.msgtype === 'video') return attachment.video.asset_id
|
||||||
|
if (attachment.msgtype === 'file') return attachment.file.asset_id
|
||||||
|
if (attachment.msgtype === 'miniprogram') return attachment.miniprogram.pic_asset_id
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
function assetName(attachment: WelcomeAttachment) {
|
||||||
|
const id = assetId(attachment)
|
||||||
|
if (id) return assetCache.value[id]?.name || `已保存素材 ${id.slice(-10)}`
|
||||||
|
return attachment.msgtype === 'image' && attachment.image.pic_url ? '已保存的企微图片' : '尚未上传'
|
||||||
|
}
|
||||||
|
function assetPreview(attachment: WelcomeAttachment) { return assetCache.value[assetId(attachment)]?.url || '' }
|
||||||
|
async function selectFile(index: number, type: 'image' | 'video' | 'file') {
|
||||||
|
selectedUpload.value = { index, type }
|
||||||
|
fileAccept.value = type === 'image' ? '.jpg,.jpeg,.png' : type === 'video' ? '.mp4' : '*'
|
||||||
|
await nextTick()
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
async function uploadSelectedFile(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ''
|
||||||
|
if (!file) return
|
||||||
|
const { index, type } = selectedUpload.value
|
||||||
|
const limit = type === 'file' ? 20 : 10
|
||||||
|
let error = ''
|
||||||
|
if (file.size <= 5 || file.size > limit * 1024 * 1024) error = `文件必须大于 5 字节且不超过 ${limit}MB`
|
||||||
|
else if (type === 'image' && !/\.(jpe?g|png)$/i.test(file.name)) error = '图片仅支持 JPG / PNG 格式'
|
||||||
|
else if (type === 'video' && !/\.mp4$/i.test(file.name)) error = '视频仅支持 MP4 格式'
|
||||||
|
if (error) { uploadErrors.value[index] = error; return }
|
||||||
|
const attachment = props.modelValue.attachments[index]
|
||||||
|
if (!attachment) return
|
||||||
|
uploadingIndex.value = index
|
||||||
|
emit('busy', true)
|
||||||
|
delete uploadErrors.value[index]
|
||||||
|
try {
|
||||||
|
const result = await wecomPromotionUploadWelcomeMedia(file, type)
|
||||||
|
if (!result?.asset_id) throw new Error('上传接口未返回素材 ID')
|
||||||
|
const id = String(result.asset_id)
|
||||||
|
assetCache.value[id] = { name: result.name || file.name, url: type === 'image' ? URL.createObjectURL(file) : undefined }
|
||||||
|
if (attachment.msgtype === 'image') { attachment.image = { asset_id: id } }
|
||||||
|
else if (attachment.msgtype === 'video') attachment.video.asset_id = id
|
||||||
|
else if (attachment.msgtype === 'file') attachment.file.asset_id = id
|
||||||
|
else if (attachment.msgtype === 'miniprogram') attachment.miniprogram.pic_asset_id = id
|
||||||
|
ElMessage.success('素材已上传')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
uploadErrors.value[index] = error instanceof Error ? error.message : typeof error === 'string' ? error : '素材上传失败'
|
||||||
|
} finally {
|
||||||
|
uploadingIndex.value = -1
|
||||||
|
emit('busy', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
Object.values(assetCache.value).forEach((asset) => { if (asset.url) URL.revokeObjectURL(asset.url) })
|
||||||
|
emit('busy', false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.welcome-editor { display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 22px; width: 100%; }
|
||||||
|
.welcome-editor__fields { min-width: 0; }
|
||||||
|
.text-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }.text-tools .el-button + .el-button { margin-left: 0; }
|
||||||
|
.emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; }.emoji-grid button { border: 0; background: none; padding: 4px; cursor: pointer; font-size: 20px; }
|
||||||
|
.text-count { text-align: right; font-size: 12px; color: #909399; margin-top: 4px; }.text-count.is-error, .upload-error { color: #d93026; }
|
||||||
|
.attachments-heading, .attachment-card__heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; }.attachments-heading { margin: 16px 0 10px; }.attachments-heading strong { font-size: 13px; }.attachments-heading strong span { color: #909399; font-weight: 400; }
|
||||||
|
.attachment-empty { padding: 18px 12px; color: #909399; background: #f7f8fa; border: 1px dashed #dcdfe6; border-radius: 4px; font-size: 12px; }
|
||||||
|
.attachment-card { border: 1px solid #e4e7ed; border-radius: 5px; padding: 12px; margin-top: 10px; }.attachment-card__heading { margin-bottom: 10px; }.attachment-card__heading strong { font-size: 13px; }.attachment-card__heading .el-button { padding: 4px; margin: 0; }
|
||||||
|
.attachment-label { display: block; font-size: 12px; color: #606266; margin: 10px 0 4px; }.attachment-label span { color: #909399; float: right; }.upload-field { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 12px; overflow-wrap: anywhere; }.mini-upload { margin-top: 12px; }.asset-ready { color: #178758; }.muted { color: #909399; }.field-tip { display: block; color: #909399; line-height: 1.6; margin-top: 6px; }.upload-error { font-size: 12px; line-height: 1.6; margin-top: 6px; }.file-input { display: none; }
|
||||||
|
.welcome-preview { width: 260px; border: 1px solid #dcdfe6; border-radius: 20px; padding: 7px; background: #fff; overflow: hidden; }
|
||||||
|
.phone-heading { display: flex; justify-content: space-between; align-items: center; padding: 13px 12px; background: #ededed; border-radius: 14px 14px 0 0; font-size: 13px; }.phone-heading > span { font-size: 19px; }
|
||||||
|
.phone-content { min-height: 330px; max-height: 520px; overflow: auto; background: #ededed; padding: 0 10px 18px; }.preview-time { font-size: 10px; text-align: center; color: #999; padding: 12px 0 18px; }.chat-row { display: flex; gap: 7px; margin-bottom: 12px; align-items: flex-start; }.chat-avatar { width: 27px; height: 27px; background: #6e92ae; color: white; flex-shrink: 0; border-radius: 4px; display: grid; place-items: center; font-size: 11px; }.chat-bubble { background: #fff; padding: 9px 10px; border-radius: 4px; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; max-width: 172px; }.attachment-preview { width: 172px; }.attachment-preview strong { display: block; font-weight: 500; font-size: 12px; }.attachment-preview p { color: #909399; font-size: 10px; margin: 6px 0; }.attachment-preview small { display: block; font-size: 9px; color: #909399; margin-top: 7px; }.attachment-preview img { width: 100%; max-height: 160px; object-fit: contain; display: block; }.media-placeholder { background: #f2f5f7; height: 85px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: #909399; font-size: 10px; }.media-placeholder .el-icon, .file-icon { font-size: 28px; color: #8babc3; }.preview-empty { text-align: center; color: #aaa; font-size: 12px; margin-top: 100px; }.phone-input { display: flex; gap: 10px; padding: 9px; background: #f6f6f6; border-radius: 0 0 14px 14px; align-items: center; color: #909399; }.phone-input__blank { flex: 1; height: 24px; border-radius: 3px; background: white; }.preview-note { margin: 10px 6px 6px; font-size: 11px; color: #909399; line-height: 1.6; }
|
||||||
|
@media (max-width: 850px) { .welcome-editor { grid-template-columns: 1fr; }.welcome-preview { margin: 8px auto 0; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
export interface PromotionMemberChoice {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
userid: string
|
||||||
|
dept_names: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WelcomeAttachment =
|
||||||
|
| { msgtype: 'image'; image: { asset_id?: string; pic_url?: string } }
|
||||||
|
| { msgtype: 'video'; video: { asset_id: string } }
|
||||||
|
| { msgtype: 'file'; file: { asset_id: string } }
|
||||||
|
| { msgtype: 'link'; link: { title: string; url: string; desc: string; picurl?: string } }
|
||||||
|
| { msgtype: 'miniprogram'; miniprogram: { title: string; appid: string; page: string; pic_asset_id: string } }
|
||||||
|
|
||||||
|
export interface WelcomeMessage {
|
||||||
|
text: string
|
||||||
|
attachments: WelcomeAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklySlot {
|
||||||
|
weekdays: number[]
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceptionSlot extends WeeklySlot {
|
||||||
|
member_admin_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WelcomeSlot extends WeeklySlot, WelcomeMessage {}
|
||||||
|
|
||||||
|
export interface PromotionAutomationConfig {
|
||||||
|
reception_mode: 'always' | 'scheduled'
|
||||||
|
reception_schedule: ReceptionSlot[]
|
||||||
|
backup_member_admin_ids: number[]
|
||||||
|
tags_enabled: boolean
|
||||||
|
tag_ids: string[]
|
||||||
|
remark_enabled: boolean
|
||||||
|
remark_template: string
|
||||||
|
description_enabled: boolean
|
||||||
|
description: string
|
||||||
|
welcome_mode: 'default' | 'channel' | 'none'
|
||||||
|
welcome: WelcomeMessage
|
||||||
|
welcome_schedule_enabled: boolean
|
||||||
|
welcome_schedule: WelcomeSlot[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromotionAutomationSaveConfig = Omit<PromotionAutomationConfig,
|
||||||
|
'tags_enabled' | 'remark_enabled' | 'description_enabled' | 'welcome_schedule_enabled'> & {
|
||||||
|
tags_enabled: 0 | 1
|
||||||
|
remark_enabled: 0 | 1
|
||||||
|
description_enabled: 0 | 1
|
||||||
|
welcome_schedule_enabled: 0 | 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export const weekdays = ['星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']
|
||||||
|
export const templateTokens = [
|
||||||
|
{ label: '客户昵称', value: '{customer_name}' },
|
||||||
|
{ label: '员工昵称', value: '{employee_name}' },
|
||||||
|
{ label: '添加时间', value: '{add_time}' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const utf8Length = (value: string): number => new TextEncoder().encode(value).length
|
||||||
|
|
||||||
|
export function validateCustomTagName(value: string): string {
|
||||||
|
if (!value.trim()) return '请输入自定义标签名称'
|
||||||
|
if (/[\p{C}\u2028\u2029]/u.test(value)) return '标签名称不能包含换行、控制字符或不可见格式字符'
|
||||||
|
if (Array.from(value.trim()).length > 30) return '标签名称最多 30 个字符'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAutomationConfig(): PromotionAutomationConfig {
|
||||||
|
return {
|
||||||
|
reception_mode: 'always', reception_schedule: [], backup_member_admin_ids: [],
|
||||||
|
tags_enabled: false, tag_ids: [], remark_enabled: false, remark_template: '{customer_name}',
|
||||||
|
description_enabled: false, description: '', welcome_mode: 'default',
|
||||||
|
welcome: { text: '', attachments: [] }, welcome_schedule_enabled: false, welcome_schedule: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only copy the editable contract. Server-resolved userids never come back in a save payload.
|
||||||
|
export function cloneAutomationConfig(source?: Partial<PromotionAutomationConfig> | null): PromotionAutomationConfig {
|
||||||
|
const defaults = defaultAutomationConfig()
|
||||||
|
if (!source) return defaults
|
||||||
|
const copy = JSON.parse(JSON.stringify(source)) as PromotionAutomationConfig
|
||||||
|
return {
|
||||||
|
reception_mode: copy.reception_mode === 'scheduled' ? 'scheduled' : 'always',
|
||||||
|
reception_schedule: (copy.reception_schedule || []).map(({ weekdays, start, end, member_admin_ids }) => ({
|
||||||
|
weekdays: [...weekdays], start, end, member_admin_ids: member_admin_ids.map(Number)
|
||||||
|
})),
|
||||||
|
backup_member_admin_ids: (copy.backup_member_admin_ids || []).map(Number),
|
||||||
|
tags_enabled: Boolean(copy.tags_enabled), tag_ids: [...(copy.tag_ids || [])],
|
||||||
|
remark_enabled: Boolean(copy.remark_enabled), remark_template: copy.remark_template ?? defaults.remark_template,
|
||||||
|
description_enabled: Boolean(copy.description_enabled), description: copy.description || '',
|
||||||
|
welcome_mode: copy.welcome_mode || 'default',
|
||||||
|
welcome: { text: copy.welcome?.text || '', attachments: copy.welcome?.attachments || [] },
|
||||||
|
welcome_schedule_enabled: Boolean(copy.welcome_schedule_enabled),
|
||||||
|
welcome_schedule: (copy.welcome_schedule || []).map(({ weekdays, start, end, text, attachments }) => ({
|
||||||
|
weekdays: [...weekdays], start, end, text: text || '', attachments: attachments || []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the save payload compatible with backends and transports that use 0/1 switches. */
|
||||||
|
export function serializeAutomationConfig(config: PromotionAutomationConfig): PromotionAutomationSaveConfig {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
tags_enabled: config.tags_enabled ? 1 : 0,
|
||||||
|
remark_enabled: config.remark_enabled ? 1 : 0,
|
||||||
|
description_enabled: config.description_enabled ? 1 : 0,
|
||||||
|
welcome_schedule_enabled: config.welcome_schedule_enabled ? 1 : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewTemplate(template: string, employee = '小陈', limit?: number): string {
|
||||||
|
const date = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
|
||||||
|
}).format(new Date())
|
||||||
|
const text = template.replace(/\{(customer_name|employee_name|add_time)\}/g, (token) => ({
|
||||||
|
'{customer_name}': '张女士', '{employee_name}': employee, '{add_time}': date
|
||||||
|
}[token] || token))
|
||||||
|
return limit ? Array.from(text).slice(0, limit).join('') : text
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebUrl(value: string): boolean {
|
||||||
|
try { return ['https:', 'http:'].includes(new URL(value).protocol) } catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSlot(slot: WeeklySlot, label: string): string {
|
||||||
|
if (!slot.weekdays.length || slot.weekdays.some((day) => day < 1 || day > 7)) return `${label}请选择星期`
|
||||||
|
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.start) || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(slot.end)) return `${label}请填写有效的起止时间`
|
||||||
|
if (slot.start === slot.end) return `${label}开始与结束时间不能相同,全天接待请选择全天模式`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateWelcomeMessage(message: WelcomeMessage, label: string, required = true): string {
|
||||||
|
if (Array.from(message.text).length > 1200) return `${label}正文不能超过 1200 字`
|
||||||
|
if (utf8Length(message.text) > 4000) return `${label}正文不能超过 4000 UTF-8 字节,请减少表情或文字`
|
||||||
|
if (message.attachments.length > 9) return `${label}最多添加 9 个附件`
|
||||||
|
if (required && !message.text.trim() && !message.attachments.length) return `${label}请填写正文或添加附件`
|
||||||
|
for (const [index, attachment] of message.attachments.entries()) {
|
||||||
|
const prefix = `${label}第 ${index + 1} 个附件:`
|
||||||
|
switch (attachment.msgtype) {
|
||||||
|
case 'image':
|
||||||
|
if (!attachment.image.asset_id && !isWebUrl(attachment.image.pic_url || '')) return prefix + '请上传图片或填写有效图片地址'
|
||||||
|
break
|
||||||
|
case 'video':
|
||||||
|
if (!attachment.video.asset_id) return prefix + '请先上传视频'
|
||||||
|
break
|
||||||
|
case 'file':
|
||||||
|
if (!attachment.file.asset_id) return prefix + '请先上传文件'
|
||||||
|
break
|
||||||
|
case 'link':
|
||||||
|
if (!attachment.link.title.trim() || !isWebUrl(attachment.link.url)) return prefix + '请填写网页标题和有效的 HTTP/HTTPS 链接'
|
||||||
|
if (utf8Length(attachment.link.title) > 128 || utf8Length(attachment.link.desc) > 512) return prefix + '网页标题限 128 字节,描述限 512 字节'
|
||||||
|
if (attachment.link.picurl && !isWebUrl(attachment.link.picurl)) return prefix + '网页封面地址无效'
|
||||||
|
break
|
||||||
|
case 'miniprogram':
|
||||||
|
if (!attachment.miniprogram.title.trim() || !attachment.miniprogram.appid.trim() || !attachment.miniprogram.page.trim() || !attachment.miniprogram.pic_asset_id) return prefix + '请填写小程序标题、AppID、页面路径并上传封面'
|
||||||
|
if (utf8Length(attachment.miniprogram.title) > 64) return prefix + '小程序标题不能超过 64 字节'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weekly minute occupancy also catches Sunday-to-Monday and overnight overlap. */
|
||||||
|
export function welcomeScheduleOverlap(slots: WeeklySlot[]): boolean {
|
||||||
|
const minutes = new Set<number>()
|
||||||
|
for (const slot of slots) {
|
||||||
|
const [sh, sm] = slot.start.split(':').map(Number)
|
||||||
|
const [eh, em] = slot.end.split(':').map(Number)
|
||||||
|
const start = sh * 60 + sm
|
||||||
|
const duration = (eh * 60 + em - start + 1440) % 1440
|
||||||
|
for (const day of new Set(slot.weekdays)) {
|
||||||
|
for (let offset = 0; offset < duration; offset++) {
|
||||||
|
const minute = ((day - 1) * 1440 + start + offset) % 10080
|
||||||
|
if (minutes.has(minute)) return true
|
||||||
|
minutes.add(minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAutomationConfig(config: PromotionAutomationConfig, mainMemberIds: number[]): string {
|
||||||
|
if (config.backup_member_admin_ids.some((id) => mainMemberIds.includes(id))) return '备用成员不能与主接待成员重复'
|
||||||
|
if (config.reception_schedule.length > 30 || config.welcome_schedule.length > 30) return '每类时段最多添加 30 条'
|
||||||
|
if (config.reception_mode === 'scheduled') {
|
||||||
|
if (!config.reception_schedule.length) return '按时段接待至少需要一个接待时段'
|
||||||
|
if (!config.backup_member_admin_ids.length) return '按时段接待至少需要一名备用成员,以承接非接待时段的客户'
|
||||||
|
}
|
||||||
|
for (const [index, slot] of config.reception_schedule.entries()) {
|
||||||
|
const error = validateSlot(slot, `接待时段 ${index + 1}:`)
|
||||||
|
if (error) return error
|
||||||
|
if (!slot.member_admin_ids.length) return `接待时段 ${index + 1}:请至少选择一名成员`
|
||||||
|
if (slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))) return `接待时段 ${index + 1}:成员必须来自主接待成员,请重新选择`
|
||||||
|
}
|
||||||
|
if (config.tags_enabled && !config.tag_ids.length) return '启用客户标签后,请选择一个企业微信标签,或创建自定义标签'
|
||||||
|
if (config.tag_ids.length > 1) return '客户标签只能选择一个,请重新选择或清空原标签'
|
||||||
|
if (config.remark_enabled && !config.remark_template.trim()) return '请填写客户备注模板'
|
||||||
|
if (Array.from(config.remark_template).length > 200) return '客户备注模板不能超过 200 字'
|
||||||
|
if (config.description_enabled && !config.description.trim()) return '请填写客户描述'
|
||||||
|
if (Array.from(config.description).length > 150) return '客户描述不能超过 150 字'
|
||||||
|
const error = validateWelcomeMessage(config.welcome, '基础渠道欢迎语:', config.welcome_mode === 'channel')
|
||||||
|
if (error) return error
|
||||||
|
if (config.welcome_mode === 'channel' && config.welcome_schedule_enabled && !config.welcome_schedule.length) return '请至少添加一个分时欢迎语时段'
|
||||||
|
for (const [index, slot] of config.welcome_schedule.entries()) {
|
||||||
|
const error = validateSlot(slot, `欢迎语时段 ${index + 1}:`) || validateWelcomeMessage(slot, `欢迎语时段 ${index + 1}:`)
|
||||||
|
if (error) return error
|
||||||
|
}
|
||||||
|
if (welcomeScheduleOverlap(config.welcome_schedule)) return '分时欢迎语的时间范围不能重叠(跨午夜时段归属开始日)'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -677,6 +677,32 @@
|
|||||||
<el-option label="驼奶费用" :value="8" />
|
<el-option label="驼奶费用" :value="8" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-if="canEditOrderTime && isEditPaymentTimeEditable"
|
||||||
|
label="支付时间"
|
||||||
|
prop="payment_time"
|
||||||
|
>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="editOrderForm.payment_time"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="请选择支付时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:clearable="false"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="canEditOrderTime" label="创建时间" prop="create_time">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="editOrderForm.create_time"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="请选择创建时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:clearable="false"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="editOrderDialogVisible = false">取消</el-button>
|
<el-button @click="editOrderDialogVisible = false">取消</el-button>
|
||||||
@@ -782,6 +808,7 @@
|
|||||||
<script setup lang="ts" name="orderList">
|
<script setup lang="ts" name="orderList">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
import {
|
import {
|
||||||
orderLists,
|
orderLists,
|
||||||
orderDetail,
|
orderDetail,
|
||||||
@@ -1015,9 +1042,33 @@ const editOrderFormRef = ref()
|
|||||||
const editOrderLoading = ref(false)
|
const editOrderLoading = ref(false)
|
||||||
const editPatientLoading = ref(false)
|
const editPatientLoading = ref(false)
|
||||||
const editPatientList = ref<any[]>([])
|
const editPatientList = ref<any[]>([])
|
||||||
const editOrderForm = ref<{ id: number; patient_id: number | null; order_type: number } | null>(null)
|
type EditOrderForm = {
|
||||||
|
id: number
|
||||||
|
patient_id: number | null
|
||||||
|
order_type: number
|
||||||
|
status: number
|
||||||
|
payment_time: string
|
||||||
|
create_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const editOrderForm = ref<EditOrderForm | null>(null)
|
||||||
|
const canEditOrderTime = computed(() => hasPermission(['order.order/editTime']))
|
||||||
|
const isEditPaymentTimeEditable = computed(() => [2, 4].includes(editOrderForm.value?.status ?? 0))
|
||||||
const editOrderRules = {
|
const editOrderRules = {
|
||||||
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }]
|
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
|
||||||
|
payment_time: [
|
||||||
|
{
|
||||||
|
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (isEditPaymentTimeEditable.value && !value) {
|
||||||
|
callback(new Error('请选择支付时间'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索患者
|
// 搜索患者
|
||||||
@@ -1289,11 +1340,37 @@ const getCreateTypeText = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 编辑订单
|
// 编辑订单
|
||||||
|
const normalizeOrderDateTime = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined || value === '' || value === '-') return ''
|
||||||
|
|
||||||
|
const raw = String(value).trim()
|
||||||
|
const canonicalDateTime = raw.match(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/)?.[0]
|
||||||
|
if (canonicalDateTime) return canonicalDateTime.replace('T', ' ')
|
||||||
|
|
||||||
|
const numericTimestamp = /^\d{10,13}$/.test(raw) ? Number(raw) : 0
|
||||||
|
const parsed = new Date(
|
||||||
|
numericTimestamp
|
||||||
|
? numericTimestamp < 1_000_000_000_000
|
||||||
|
? numericTimestamp * 1000
|
||||||
|
: numericTimestamp
|
||||||
|
: raw
|
||||||
|
)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return ''
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(
|
||||||
|
parsed.getHours()
|
||||||
|
)}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
const handleEditOrder = (row: any) => {
|
const handleEditOrder = (row: any) => {
|
||||||
editOrderForm.value = {
|
editOrderForm.value = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
patient_id: row.patient_id || null,
|
patient_id: row.patient_id || null,
|
||||||
order_type: row.order_type
|
order_type: row.order_type,
|
||||||
|
status: Number(row.status),
|
||||||
|
payment_time: [2, 4].includes(Number(row.status)) ? normalizeOrderDateTime(row.payment_time) : '',
|
||||||
|
create_time: normalizeOrderDateTime(row.create_time)
|
||||||
}
|
}
|
||||||
editPatientList.value = row.patient ? [row.patient] : []
|
editPatientList.value = row.patient ? [row.patient] : []
|
||||||
editOrderDialogVisible.value = true
|
editOrderDialogVisible.value = true
|
||||||
@@ -1320,11 +1397,18 @@ const submitEditOrder = async () => {
|
|||||||
try {
|
try {
|
||||||
await editOrderFormRef.value?.validate()
|
await editOrderFormRef.value?.validate()
|
||||||
editOrderLoading.value = true
|
editOrderLoading.value = true
|
||||||
await orderEdit({
|
const payload: Record<string, unknown> = {
|
||||||
id: editOrderForm.value.id,
|
id: editOrderForm.value.id,
|
||||||
patient_id: editOrderForm.value.patient_id ?? 0,
|
patient_id: editOrderForm.value.patient_id ?? 0,
|
||||||
order_type: editOrderForm.value.order_type
|
order_type: editOrderForm.value.order_type
|
||||||
})
|
}
|
||||||
|
if (canEditOrderTime.value) {
|
||||||
|
payload.create_time = editOrderForm.value.create_time
|
||||||
|
if (isEditPaymentTimeEditable.value) {
|
||||||
|
payload.payment_time = editOrderForm.value.payment_time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await orderEdit(payload)
|
||||||
feedback.msgSuccess('保存成功')
|
feedback.msgSuccess('保存成功')
|
||||||
editOrderDialogVisible.value = false
|
editOrderDialogVisible.value = false
|
||||||
getLists()
|
getLists()
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<code>一键打包</code>
|
<code>一键打包</code>
|
||||||
产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用
|
产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用
|
||||||
Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。
|
Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。
|
||||||
安装包通常超过 200MB,优先传到对象存储 / CDN 后粘贴地址。
|
安装包通常超过 200MB,本页上传按钮会直传到已配置的腾讯云 COS;也可以
|
||||||
|
自行上传到其他对象存储 / CDN 后粘贴地址。
|
||||||
</div>
|
</div>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<div class="text-xl font-medium mb-[20px]">升级策略</div>
|
<div class="text-xl font-medium mb-[20px]">升级策略</div>
|
||||||
@@ -125,7 +126,9 @@
|
|||||||
<el-form-item label="上传安装包">
|
<el-form-item label="上传安装包">
|
||||||
<div>
|
<div>
|
||||||
<upload
|
<upload
|
||||||
type="file"
|
v-perms="['setting.desktop_workstation/setConfig']"
|
||||||
|
type="desktop_package"
|
||||||
|
direct
|
||||||
:limit="1"
|
:limit="1"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:show-progress="true"
|
:show-progress="true"
|
||||||
@@ -136,8 +139,8 @@
|
|||||||
<el-button type="primary" plain>选择安装包并上传</el-button>
|
<el-button type="primary" plain>选择安装包并上传</el-button>
|
||||||
</upload>
|
</upload>
|
||||||
<div class="form-tips">
|
<div class="form-tips">
|
||||||
仅建议上传较小的包。大文件请先传到对象存储,再把地址和 SHA-256
|
安装包将分片直传腾讯云 COS,不经过业务服务器(支持 EXE / ZIP,最大
|
||||||
填到上方。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
|
2GB)。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -1775,9 +1775,31 @@ const appointmentCellClasses = (row: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅已预约(1)可进视频/小程序码 */
|
/**
|
||||||
const isAppointmentActiveForVideo = (row: any) =>
|
* 找到当前可用的已预约挂号。
|
||||||
row.has_appointment && Number(row.appointment_status) === 1
|
* 同一诊单当天可能先完成一条挂号、随后又新增一条预约,此时行级 appointment_* 仍可能指向旧记录。
|
||||||
|
*/
|
||||||
|
const activeAppointment = (row: any) =>
|
||||||
|
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||||
|
|
||||||
|
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
|
||||||
|
const activeAppointmentRow = (row: any) => {
|
||||||
|
const apt = activeAppointment(row)
|
||||||
|
if (!apt) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
has_appointment: 1,
|
||||||
|
appointment_id: apt.id,
|
||||||
|
appointment_status: apt.status,
|
||||||
|
appointment_doctor_id: apt.doctor_id,
|
||||||
|
appointment_doctor_name: apt.doctor_name,
|
||||||
|
appointment_time_text: apt.time_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任一挂号记录处于已预约(1)即可进视频/小程序码。 */
|
||||||
|
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||||
|
|
||||||
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
|
||||||
const canCancelAppointmentRow = (row: any) => {
|
const canCancelAppointmentRow = (row: any) => {
|
||||||
@@ -1962,17 +1984,18 @@ const submitFillIdCard = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
|
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
|
||||||
const handleVideoQRCode = async (row: any) => {
|
const handleVideoQRCode = async (row: any) => {
|
||||||
if (!isAppointmentActiveForVideo(row)) {
|
const qrcodeRow = activeAppointmentRow(row)
|
||||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
if (!qrcodeRow) {
|
||||||
return
|
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
|
||||||
}
|
return
|
||||||
if (!row.patient_id) {
|
}
|
||||||
feedback.msgWarning('患者信息不完整')
|
if (!qrcodeRow.patient_id) {
|
||||||
return
|
feedback.msgWarning('患者信息不完整')
|
||||||
}
|
return
|
||||||
lastQRCodeType.value = 'video'
|
}
|
||||||
currentQRCodePatient.value = row
|
lastQRCodeType.value = 'video'
|
||||||
|
currentQRCodePatient.value = qrcodeRow
|
||||||
qrcodeDialogVisible.value = true
|
qrcodeDialogVisible.value = true
|
||||||
qrcodeLoading.value = true
|
qrcodeLoading.value = true
|
||||||
qrcodeUrl.value = ''
|
qrcodeUrl.value = ''
|
||||||
@@ -1983,11 +2006,11 @@ const handleVideoQRCode = async (row: any) => {
|
|||||||
feedback.msgError('小程序未配置,请先配置小程序信息')
|
feedback.msgError('小程序未配置,请先配置小程序信息')
|
||||||
qrcodeDialogVisible.value = false
|
qrcodeDialogVisible.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await generateMiniProgramQrcode({
|
const result = await generateMiniProgramQrcode({
|
||||||
doctor_id: row.appointment_doctor_id,
|
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||||
diagnosis_id: row.appointment_doctor_id,
|
diagnosis_id: qrcodeRow.appointment_doctor_id,
|
||||||
patient_id: row.patient_id,
|
patient_id: qrcodeRow.patient_id,
|
||||||
share_user_id: userStore.userInfo?.id || '',
|
share_user_id: userStore.userInfo?.id || '',
|
||||||
mini_program_path: 'pages/login/login'
|
mini_program_path: 'pages/login/login'
|
||||||
})
|
})
|
||||||
@@ -2004,17 +2027,18 @@ const handleVideoQRCode = async (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成确认诊单二维码
|
// 生成确认诊单二维码
|
||||||
const handleMiniProgramQRCode = async (row: any) => {
|
const handleMiniProgramQRCode = async (row: any) => {
|
||||||
if (!isAppointmentActiveForVideo(row)) {
|
const qrcodeRow = activeAppointmentRow(row)
|
||||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
|
if (!qrcodeRow) {
|
||||||
return
|
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
|
||||||
}
|
return
|
||||||
if (!row.patient_id) {
|
}
|
||||||
feedback.msgWarning('患者信息不完整')
|
if (!qrcodeRow.patient_id) {
|
||||||
return
|
feedback.msgWarning('患者信息不完整')
|
||||||
}
|
return
|
||||||
lastQRCodeType.value = 'confirm'
|
}
|
||||||
currentQRCodePatient.value = row
|
lastQRCodeType.value = 'confirm'
|
||||||
|
currentQRCodePatient.value = qrcodeRow
|
||||||
qrcodeDialogVisible.value = true
|
qrcodeDialogVisible.value = true
|
||||||
qrcodeLoading.value = true
|
qrcodeLoading.value = true
|
||||||
qrcodeUrl.value = ''
|
qrcodeUrl.value = ''
|
||||||
@@ -2033,11 +2057,11 @@ const handleMiniProgramQRCode = async (row: any) => {
|
|||||||
// 获取当前登录用户信息
|
// 获取当前登录用户信息
|
||||||
const currentUser = userStore.userInfo
|
const currentUser = userStore.userInfo
|
||||||
|
|
||||||
// 调用生成二维码接口
|
// 调用生成二维码接口
|
||||||
const result = await generateMiniProgramQrcode({
|
const result = await generateMiniProgramQrcode({
|
||||||
diagnosis_id: row.id,
|
diagnosis_id: qrcodeRow.id,
|
||||||
doctor_id: row.appointment_doctor_id,
|
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||||
patient_id: row.patient_id,
|
patient_id: qrcodeRow.patient_id,
|
||||||
share_user_id: currentUser?.id || ''
|
share_user_id: currentUser?.id || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -946,8 +946,28 @@ const appointmentRowClass = (row: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAppointmentActiveForVideo = (row: any) =>
|
/**
|
||||||
row.has_appointment && Number(row.appointment_status) === 1
|
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号,二维码应使用明细里的有效预约。
|
||||||
|
*/
|
||||||
|
const activeAppointment = (row: any) =>
|
||||||
|
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null
|
||||||
|
|
||||||
|
const activeAppointmentRow = (row: any) => {
|
||||||
|
const apt = activeAppointment(row)
|
||||||
|
if (!apt) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
has_appointment: 1,
|
||||||
|
appointment_id: apt.id,
|
||||||
|
appointment_status: apt.status,
|
||||||
|
appointment_doctor_id: apt.doctor_id,
|
||||||
|
appointment_doctor_name: apt.doctor_name,
|
||||||
|
appointment_time_text: apt.time_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row)
|
||||||
|
|
||||||
const canCancelAppointmentRow = (row: any) => {
|
const canCancelAppointmentRow = (row: any) => {
|
||||||
const s = Number(row.appointment_status)
|
const s = Number(row.appointment_status)
|
||||||
@@ -1241,13 +1261,14 @@ const qrcodeAppointmentTimeText = computed(() => {
|
|||||||
return p.appointment_time_text || '—'
|
return p.appointment_time_text || '—'
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleVideoQRCode = async (row: any) => {
|
const handleVideoQRCode = async (row: any) => {
|
||||||
if (!isAppointmentActiveForVideo(row)) {
|
const qrcodeRow = activeAppointmentRow(row)
|
||||||
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
|
if (!qrcodeRow) {
|
||||||
}
|
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
|
||||||
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
}
|
||||||
lastQRCodeType.value = 'video'
|
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||||
currentQRCodePatient.value = row
|
lastQRCodeType.value = 'video'
|
||||||
|
currentQRCodePatient.value = qrcodeRow
|
||||||
qrcodeDialogVisible.value = true
|
qrcodeDialogVisible.value = true
|
||||||
qrcodeLoading.value = true
|
qrcodeLoading.value = true
|
||||||
qrcodeUrl.value = ''
|
qrcodeUrl.value = ''
|
||||||
@@ -1258,11 +1279,11 @@ const handleVideoQRCode = async (row: any) => {
|
|||||||
feedback.msgError('小程序未配置')
|
feedback.msgError('小程序未配置')
|
||||||
qrcodeDialogVisible.value = false
|
qrcodeDialogVisible.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await generateMiniProgramQrcode({
|
const result = await generateMiniProgramQrcode({
|
||||||
doctor_id: row.appointment_doctor_id,
|
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||||
diagnosis_id: row.appointment_doctor_id,
|
diagnosis_id: qrcodeRow.appointment_doctor_id,
|
||||||
patient_id: row.patient_id,
|
patient_id: qrcodeRow.patient_id,
|
||||||
share_user_id: userStore.userInfo?.id || '',
|
share_user_id: userStore.userInfo?.id || '',
|
||||||
mini_program_path: 'pages/login/login'
|
mini_program_path: 'pages/login/login'
|
||||||
})
|
})
|
||||||
@@ -1275,13 +1296,14 @@ const handleVideoQRCode = async (row: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMiniProgramQRCode = async (row: any) => {
|
const handleMiniProgramQRCode = async (row: any) => {
|
||||||
if (!isAppointmentActiveForVideo(row)) {
|
const qrcodeRow = activeAppointmentRow(row)
|
||||||
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
|
if (!qrcodeRow) {
|
||||||
}
|
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
|
||||||
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
}
|
||||||
lastQRCodeType.value = 'confirm'
|
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
|
||||||
currentQRCodePatient.value = row
|
lastQRCodeType.value = 'confirm'
|
||||||
|
currentQRCodePatient.value = qrcodeRow
|
||||||
qrcodeDialogVisible.value = true
|
qrcodeDialogVisible.value = true
|
||||||
qrcodeLoading.value = true
|
qrcodeLoading.value = true
|
||||||
qrcodeUrl.value = ''
|
qrcodeUrl.value = ''
|
||||||
@@ -1292,11 +1314,11 @@ const handleMiniProgramQRCode = async (row: any) => {
|
|||||||
feedback.msgError('小程序未配置')
|
feedback.msgError('小程序未配置')
|
||||||
qrcodeDialogVisible.value = false
|
qrcodeDialogVisible.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await generateMiniProgramQrcode({
|
const result = await generateMiniProgramQrcode({
|
||||||
diagnosis_id: row.id,
|
diagnosis_id: qrcodeRow.id,
|
||||||
doctor_id: row.appointment_doctor_id,
|
doctor_id: qrcodeRow.appointment_doctor_id,
|
||||||
patient_id: row.patient_id,
|
patient_id: qrcodeRow.patient_id,
|
||||||
share_user_id: userStore.userInfo?.id || ''
|
share_user_id: userStore.userInfo?.id || ''
|
||||||
})
|
})
|
||||||
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url
|
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -45,9 +45,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
: 'http://127.0.0.1:8080'
|
: 'http://127.0.0.1:8080'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
base: '/admin/',
|
base: '/admin/',
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
port: 5555,
|
||||||
|
host: '0.0.0.0',
|
||||||
hmr: true,
|
hmr: true,
|
||||||
open: true,
|
open: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -11,6 +11,7 @@ their platform media backends.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -23,9 +24,46 @@ RESOURCES = PROJECT_ROOT / "resources"
|
|||||||
WINDOWS_ICON = RESOURCES / "branding" / "app-icon.ico"
|
WINDOWS_ICON = RESOURCES / "branding" / "app-icon.ico"
|
||||||
MACOS_ICON = RESOURCES / "branding" / "app-icon.icns"
|
MACOS_ICON = RESOURCES / "branding" / "app-icon.icns"
|
||||||
ENTITLEMENTS = PROJECT_ROOT / "packaging" / "macos" / "entitlements.plist"
|
ENTITLEMENTS = PROJECT_ROOT / "packaging" / "macos" / "entitlements.plist"
|
||||||
VERSION_FILE = PROJECT_ROOT / "packaging" / "windows" / "version_info.txt"
|
VERSION_SOURCE = SOURCE_ROOT / "doctor_workstation" / "__init__.py"
|
||||||
|
VERSION_TEMPLATE = PROJECT_ROOT / "packaging" / "windows" / "version_info.template.txt"
|
||||||
MEDIA_SMOKE_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py"
|
MEDIA_SMOKE_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py"
|
||||||
|
|
||||||
|
|
||||||
|
def read_application_version():
|
||||||
|
source = VERSION_SOURCE.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', source, re.MULTILINE)
|
||||||
|
if not match:
|
||||||
|
raise SystemExit(f"Application version is missing from {VERSION_SOURCE}")
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def windows_version_tuple(version):
|
||||||
|
match = re.match(r"^(\d+(?:\.\d+){0,3})", version)
|
||||||
|
if not match:
|
||||||
|
raise SystemExit(f"Application version is invalid for Windows resources: {version}")
|
||||||
|
parts = [int(part) for part in match.group(1).split(".")]
|
||||||
|
return tuple((parts + [0, 0, 0, 0])[:4])
|
||||||
|
|
||||||
|
|
||||||
|
def generate_windows_version_file(version):
|
||||||
|
template = VERSION_TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
version_tuple = ", ".join(str(part) for part in windows_version_tuple(version))
|
||||||
|
rendered = template.replace("@VERSION_TUPLE@", version_tuple)
|
||||||
|
rendered = rendered.replace("@VERSION_STRING@", version)
|
||||||
|
output = PROJECT_ROOT / "build" / "generated" / "version_info.txt"
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(rendered, encoding="utf-8")
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
APP_VERSION = read_application_version()
|
||||||
|
if sys.platform == "win32":
|
||||||
|
if not VERSION_TEMPLATE.is_file():
|
||||||
|
raise SystemExit(f"Windows version template is missing: {VERSION_TEMPLATE}")
|
||||||
|
VERSION_FILE = generate_windows_version_file(APP_VERSION)
|
||||||
|
else:
|
||||||
|
VERSION_FILE = None
|
||||||
|
|
||||||
if not ENTRY_POINT.is_file():
|
if not ENTRY_POINT.is_file():
|
||||||
raise SystemExit(f"Application entry point is missing: {ENTRY_POINT}")
|
raise SystemExit(f"Application entry point is missing: {ENTRY_POINT}")
|
||||||
if not (VIDEO_DIST / "index.html").is_file():
|
if not (VIDEO_DIST / "index.html").is_file():
|
||||||
@@ -107,7 +145,7 @@ exe = EXE(
|
|||||||
icon=str(WINDOWS_ICON) if sys.platform == "win32" else None,
|
icon=str(WINDOWS_ICON) if sys.platform == "win32" else None,
|
||||||
codesign_identity=os.environ.get("MACOS_CODESIGN_IDENTITY") if is_macos else None,
|
codesign_identity=os.environ.get("MACOS_CODESIGN_IDENTITY") if is_macos else None,
|
||||||
entitlements_file=str(ENTITLEMENTS) if is_macos else None,
|
entitlements_file=str(ENTITLEMENTS) if is_macos else None,
|
||||||
version=str(VERSION_FILE) if sys.platform == "win32" else None,
|
version=str(VERSION_FILE) if VERSION_FILE else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
collection = COLLECT(
|
collection = COLLECT(
|
||||||
|
|||||||
+6
-5
@@ -1,9 +1,10 @@
|
|||||||
# UTF-8
|
# UTF-8
|
||||||
# Example PyInstaller version resource. Update all four version tuples together.
|
# PyInstaller version resource template. Values are generated from
|
||||||
|
# doctor_workstation.__version__ by packaging/doctor_workstation.spec.
|
||||||
VSVersionInfo(
|
VSVersionInfo(
|
||||||
ffi=FixedFileInfo(
|
ffi=FixedFileInfo(
|
||||||
filevers=(0, 1, 0, 0),
|
filevers=(@VERSION_TUPLE@),
|
||||||
prodvers=(0, 1, 0, 0),
|
prodvers=(@VERSION_TUPLE@),
|
||||||
mask=0x3f,
|
mask=0x3f,
|
||||||
flags=0x0,
|
flags=0x0,
|
||||||
OS=0x40004,
|
OS=0x40004,
|
||||||
@@ -18,11 +19,11 @@ VSVersionInfo(
|
|||||||
[
|
[
|
||||||
StringStruct('CompanyName', 'ZYT'),
|
StringStruct('CompanyName', 'ZYT'),
|
||||||
StringStruct('FileDescription', '医生工作台'),
|
StringStruct('FileDescription', '医生工作台'),
|
||||||
StringStruct('FileVersion', '0.1.0.0'),
|
StringStruct('FileVersion', '@VERSION_STRING@'),
|
||||||
StringStruct('InternalName', 'DoctorWorkstation'),
|
StringStruct('InternalName', 'DoctorWorkstation'),
|
||||||
StringStruct('OriginalFilename', 'DoctorWorkstation.exe'),
|
StringStruct('OriginalFilename', 'DoctorWorkstation.exe'),
|
||||||
StringStruct('ProductName', '医生工作台'),
|
StringStruct('ProductName', '医生工作台'),
|
||||||
StringStruct('ProductVersion', '0.1.0.0')
|
StringStruct('ProductVersion', '@VERSION_STRING@')
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]),
|
]),
|
||||||
+4
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "zhenyang-doctor-workstation"
|
name = "zhenyang-doctor-workstation"
|
||||||
version = "1.0.0"
|
dynamic = ["version"]
|
||||||
description = "Cross-platform doctor consultation workstation for Windows and macOS"
|
description = "Cross-platform doctor consultation workstation for Windows and macOS"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -31,6 +31,9 @@ build = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
doctor-workstation = "doctor_workstation.app:main"
|
doctor-workstation = "doctor_workstation.app:main"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "src/doctor_workstation/__init__.py"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/doctor_workstation"]
|
packages = ["src/doctor_workstation"]
|
||||||
|
|
||||||
@@ -46,4 +49,3 @@ target-version = "py311"
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Windows x64 客户端更新 `can_install=False` 诊断
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
“已识别最新版本,但按钮显示「暂不可安装」并提示后台尚未配置安装包”并不等价于只有一种后台配置错误。当前链路把多种拒绝原因压缩为同一个 `UpdateOffer.can_install=False`,而对话框的兜底文案统一归因为“后台未配置”。
|
||||||
|
|
||||||
|
对标准 Windows x64 客户端,最值得按以下顺序检查:
|
||||||
|
|
||||||
|
1. **服务端没有在 `packages.windows_x64` 取到同时非空的 `url` 和 `sha256`。** 最新版本是全局字段,安装包是按平台另行选择,因此完全可能 `has_update=True` 但 `can_install=False`。
|
||||||
|
2. **Windows Inno Setup 包使用了 HTTP(非 localhost)地址,或本机关闭了 HTTPS 证书校验。** 前者会被解析器拒绝;后者会在 UI session 中把一个原本可安装的 offer 二次降级为不可安装。
|
||||||
|
3. **服务端返回了非 64 位十六进制 SHA-256。** 当前 PHP evaluate 只检查 SHA 是否非空,Python 客户端则做严格格式校验,两端判定可能不一致。
|
||||||
|
4. **`package` 结构或 `package.type` 不符合客户端契约。** 客户端只接受对象形式的 `package`,类型只接受 `archive` / `inno_setup`;不过当前第一方 PHP 后端会把未知类型归一为 `archive`,当前管理端也只提供这两个选项,所以这通常只发生在旧服务、手工响应或绕过当前保存链路的配置中。
|
||||||
|
|
||||||
|
如果更新对话框确实已经出现,则单纯的版本、`enabled`、响应平台/架构不匹配通常可以排除:`AppUpdateSession._on_offer()` 在 `offer.has_update=False` 时直接返回,不会展示更新对话框(`app/src/doctor_workstation/ui/dialogs/app_update.py:360-368`)。
|
||||||
|
|
||||||
|
## 端到端链路与证据
|
||||||
|
|
||||||
|
### 1. 客户端发送的身份
|
||||||
|
|
||||||
|
- Windows 被映射为 `windows`(`app/src/doctor_workstation/services/app_update.py:78-83`)。
|
||||||
|
- `AMD64`、`x86_64`、`x64` 都被映射为 `x64`(`app/src/doctor_workstation/services/app_update.py:86-92`)。
|
||||||
|
- 检查请求固定发往 `setting.desktop_workstation/check`,携带 `current_version`、`platform`、`arch`(`app/src/doctor_workstation/services/app_update.py:218-243`)。
|
||||||
|
- `ApiClient` 会解开 `{code, data}` 信封,`code == 1` 时把 `data` 直接交给更新解析器(`app/src/doctor_workstation/services/api_client.py:515-542`)。
|
||||||
|
|
||||||
|
因此标准 64 位 Windows 的请求应为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /adminapi/setting.desktop_workstation/check
|
||||||
|
?current_version=<当前版本>&platform=windows&arch=x64
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 服务端先决定是否有对应平台安装包
|
||||||
|
|
||||||
|
- 服务端只声明三个包槽位:`windows_x64`、`macos_arm64`、`macos_x64`(`server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:28-32`)。
|
||||||
|
- `windows`/`win32`/`win64` 会归一为 `windows`,`x64`/`amd64`/`x86_64` 会归一为 `x64`,然后拼成 `windows_x64`(同文件 `:125-153`)。
|
||||||
|
- evaluate 从 `config.packages[windows_x64]` 取包;服务端 `canInstall` 只要求 `url !== '' && sha256 !== ''`(同文件 `:75-86`)。
|
||||||
|
- `hasUpdate` 独立由启用状态和版本比较决定(`:86-88`),响应中只有 `canInstall` 为真才返回 `package`,最终 `can_install = hasUpdate && canInstall`(`:90-103`)。
|
||||||
|
|
||||||
|
这直接解释了核心现象:`latest_version` 配置正确会让客户端看到新版本,但 `packages.windows_x64.url` 或 `packages.windows_x64.sha256` 任一为空,响应仍会是 `has_update: true`、`package: null`、`can_install: false`。
|
||||||
|
|
||||||
|
管理端保存的真实字段是嵌套结构 `packages.windows_x64.{url,sha256,size,filename,type}`(`admin/src/api/setting/desktop_workstation.ts:5-24`、`admin/src/views/setting/desktop_workstation/index.vue:330-344`),而不是把 Windows 包放在 macOS 槽位或任意自定义键下。管理页默认 Windows 类型为 `inno_setup`(Vue 文件 `:198-218`),上传 `.exe` 也会设置为 `inno_setup` 并在浏览器计算 SHA-256(`:292-315`)。
|
||||||
|
|
||||||
|
当前服务端校验允许整行安装包为空:空值/空行会继续通过(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:83-110`),所以“自动检测已启用、最新版本有效、Windows 包未完整配置”是被允许保存的状态。外部 URL 缺 SHA 会被拒绝,但站内相对 URL 对应文件不存在且 SHA 为空的情形仍可能保存;服务端只会在本地文件确实存在时自动补 SHA、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
|
||||||
|
|
||||||
|
### 3. Python 客户端会再做一轮更严格的判定
|
||||||
|
|
||||||
|
`parse_update_offer()` 的规则位于 `app/src/doctor_workstation/services/app_update.py:140-215`:
|
||||||
|
|
||||||
|
- `package` 必须是字典;`url` 必须非空(`:152-167`)。
|
||||||
|
- `type` 缺省为 `archive`,只接受 `archive` / `inno_setup`;`inno_setup` 只允许 Windows(`:158-167`)。
|
||||||
|
- 响应平台、架构必须与请求时的期望值完全一致;同时必须满足服务端 `has_update`、`enabled`、合法且更高的版本(`:175-184`)。
|
||||||
|
- SHA-256 必须恰好 64 个十六进制字符(`:185-189`)。
|
||||||
|
- `inno_setup` URL 必须是 HTTPS,唯一例外是 HTTP localhost/loopback(`:190-194`,具体 URL 规则在 `:371-376`)。
|
||||||
|
- 最终 `can_install` 是服务端 `can_install`、有效 package、有效 SHA、安全安装器传输、`has_update` 五者的合取(`:195-201`)。判失败后返回对象会清除 `package`,并把 `force` 一并降为 false(`:202-215`)。
|
||||||
|
|
||||||
|
因此若原始 API 返回 `can_install: true`,客户端仍可能因以下字段得到 false:
|
||||||
|
|
||||||
|
| 字段/状态 | 拒绝条件 | Windows x64 症状是否吻合 |
|
||||||
|
|---|---|---|
|
||||||
|
| `package` | `null`、数组、字符串等非对象 | 是 |
|
||||||
|
| `package.url` | 空字符串 | 是 |
|
||||||
|
| `package.sha256` | 空、长度不是 64、包含非十六进制字符 | 是 |
|
||||||
|
| `package.type` | 非 `archive` / `inno_setup` | 是,但当前第一方后端通常会归一为 `archive` |
|
||||||
|
| `package.type=inno_setup` + URL | 非 localhost 的 `http://` 或相对 URL | 是 |
|
||||||
|
| `package.filename` | 空或扩展名不匹配 | **不会在 offer 阶段令 `can_install=False`**;可能在下载/应用阶段失败 |
|
||||||
|
| `package.size` | 空、0、不可转整数 | **不会在 offer 阶段令 `can_install=False`**;解析为 0 |
|
||||||
|
| 缺少 `package.type` | 默认 `archive` | **不会单独导致 false**;EXE 被误当 archive 会在稍后解压失败 |
|
||||||
|
|
||||||
|
一个重要的不一致是:PHP evaluate 目前只检查 SHA 非空(`DesktopWorkstationLogic.php:85`),Python 检查完整格式(`app_update.py:185-189`)。管理端正常保存会校验 64 位十六进制(`DesktopWorkstationValidate.php:147-151`),但旧数据、直接写配置或绕过校验的导入仍可能造成“后端说可安装、客户端说不可安装”。
|
||||||
|
|
||||||
|
### 4. UI session 还会因本机 TLS 设置二次降级
|
||||||
|
|
||||||
|
即使 `fetch_update_offer()` 返回的 Inno Setup offer 已经 `can_install=True`,`AppUpdateSession._on_offer()` 仍会以本机 `config.verify_ssl` 调用安装器下载策略;失败时把 `force=False`、`package=None`、`can_install=False`(`app/src/doctor_workstation/ui/dialogs/app_update.py:371-392`)。
|
||||||
|
|
||||||
|
本机配置默认 `verify_ssl=True`(`app/src/doctor_workstation/config.py:88-97`、`:124-130`),但登录页勾选“信任自签名证书(仅内网调试)”会把它反转为 false(`app/src/doctor_workstation/ui/login.py:831-844`、`:937-944`、`:1041-1053`)。`validate_installer_download_policy()` 明确拒绝 `verify_ssl=False`,也拒绝非安全的 Inno Setup URL(`app/src/doctor_workstation/services/app_update.py:379-385`)。
|
||||||
|
|
||||||
|
这是最容易被误判为“后台没包”的非后台原因。诊断时应比较两个时点:
|
||||||
|
|
||||||
|
1. `fetch_update_offer()` 刚返回时是否 `can_install=True`;
|
||||||
|
2. `_on_offer()` 传给 `_present()` 时是否已经变成 false。
|
||||||
|
|
||||||
|
若只有第 2 个时点为 false,按当前代码唯一的正常降级入口就是 Inno Setup 下载策略,优先检查 `verify_ssl`。
|
||||||
|
|
||||||
|
审阅时工作树中已存在一项并非本文创建的未提交改善:`UpdateOffer` 增加 `install_unavailable_reason`,TLS 策略降级时生成具体原因,对话框优先展示该原因(`app_update.py` service `:53-67`;UI `:200-206`、`:379-391`)。兜底文案仍用于服务端/解析阶段没有原因的 `can_install=False`,所以根因判别和补测仍有必要。
|
||||||
|
|
||||||
|
### 5. 为什么平台或版本通常不是这个弹窗的根因
|
||||||
|
|
||||||
|
- 客户端要求响应 `platform`/`arch` 与请求期望值精确相等,错配会让 `has_update=False`(`app_update.py:175-184`)。
|
||||||
|
- session 对 `has_update=False` 直接显示“当前已是最新版本”或静默返回,不创建更新对话框(UI `:360-368`)。
|
||||||
|
- 当前第一方后端会把 Windows/x64 常见别名归一为响应中的 `windows`/`x64`(`DesktopWorkstationLogic.php:125-153`)。
|
||||||
|
|
||||||
|
所以对于已经出现该对话框的标准 Windows x64 客户端,优先查 `packages.windows_x64`,而不是先怀疑 `AMD64` 与 `x64` 名称差异。例外是非标准/旧后端没有按当前契约归一,或实际机器是 Windows ARM64;服务端没有 `windows_arm64` 包槽位,后者会天然没有对应包。
|
||||||
|
|
||||||
|
同理,`enabled=false`、最新版本无效、当前版本不低于最新版本都会使 `has_update=False`,与“更新弹窗出现但不可安装”不吻合。源码运行也不是该兜底文案的成因:源码模式只会取消强制属性,点击安装后才显示“当前为源码运行”(UI `:393-394`、`:401-410`)。
|
||||||
|
|
||||||
|
## 最短现场排查路径
|
||||||
|
|
||||||
|
1. 用发生问题的当前版本请求实际 API,并保留解包后的 `data`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/adminapi/setting.desktop_workstation/check?current_version=<version>&platform=windows&arch=x64
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 若响应已经是 `can_install:false` 且 `package:null`,读取管理端配置并核对 `packages.windows_x64.url` 与 `.sha256` 是否同时非空;确认包没有误填到 `macos_x64`,也没有只保存最新版本而未保存包。
|
||||||
|
3. 若响应是 `can_install:true`,核对 `package` 是否为对象、SHA 是否 64 位十六进制、`type` 是否精确为 `archive` 或 `inno_setup`。若为 Inno Setup,URL 应为 HTTPS。
|
||||||
|
4. 若解析后 offer 为 true、弹窗前变成 false,检查客户端 `preferences.json` 中的 `verify_ssl`,以及登录页“信任自签名证书”是否被勾选。
|
||||||
|
5. 对 Windows 安装程序,期望响应至少应类似:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"has_update": true,
|
||||||
|
"enabled": true,
|
||||||
|
"platform": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"can_install": true,
|
||||||
|
"package": {
|
||||||
|
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
|
||||||
|
"sha256": "<64 lowercase hex chars>",
|
||||||
|
"size": 123456789,
|
||||||
|
"filename": "DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
|
||||||
|
"type": "inno_setup"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 现有测试覆盖与缺口
|
||||||
|
|
||||||
|
已有客户端测试覆盖:
|
||||||
|
|
||||||
|
- 缺 SHA 会拒绝安装(`app/tests/test_app_update.py:43-63`)。
|
||||||
|
- 合法 HTTPS Inno Setup 会接受(`:66-89`)。
|
||||||
|
- HTTP Inno Setup 会拒绝(`:92-115`)。
|
||||||
|
- 未知类型会拒绝(`:118-139`)。
|
||||||
|
- 旧版本/错误平台响应不会成为 update(`:142-163`)。
|
||||||
|
- 检查接口会发送 `platform=windows`、`arch=x64`(`:166-208`)。
|
||||||
|
- UI 的可选/强制升级基本行为,以及“给定 policy reason 时展示该 reason”(`app/tests/test_app_update_ui.py:49-96`)。
|
||||||
|
|
||||||
|
已有 PHP 契约测试覆盖 `win32 + amd64 -> windows_x64`,以及完整 Windows Inno 包可安装(`server/tests/DesktopWorkstationUpdateContractTest.php:17-62`);缺包测试只覆盖 macOS 槽位(`:74-77`)。
|
||||||
|
|
||||||
|
建议新增以下测试:
|
||||||
|
|
||||||
|
1. **Windows x64 服务端缺字段矩阵(最高优先级)**:分别让 `packages.windows_x64.url` 为空、`sha256` 为空、整个键缺失;断言 `has_update=true`、`package=null`、`can_install=false`。这会直接固化本次症状。
|
||||||
|
2. **服务端/客户端 SHA 契约一致性**:给 evaluate 一个“非空但不是 64 位十六进制”的 SHA。期望服务端也返回不可安装,或至少用共享 fixture 明确当前由客户端拒绝;避免两端一个 true、一个 false。
|
||||||
|
3. **`AppUpdateSession` TLS 二次降级**:构造合法 HTTPS `inno_setup` offer,分别设置 `verify_ssl=True/False`,截获 `_present()`;true 时保持可安装,false 时断言 `can_install=False` 且原因明确指向证书策略而非后台缺包。
|
||||||
|
4. **UI 兜底分支**:构造 `can_install=False` 且无 reason 的 offer,断言按钮禁用并展示后台/平台包缺失文案;与已有“注入 policy reason”的测试形成两条独立路径。
|
||||||
|
5. **解析字段矩阵**:补充 `package=null`、非对象、空 URL、63 位 SHA、含非 hex SHA、缺少 type 默认 archive、Windows archive 使用 HTTP 仍可解析等边界测试。现有测试覆盖了部分,但没有把每个判定条件与原因一一锁定。
|
||||||
|
6. **跨层契约 fixture**:把 PHP `check` 的 Windows x64 JSON 响应作为 Python `parse_update_offer()` 输入,验证 canonical `windows/x64`、包类型、SHA 和 `can_install` 不发生语义漂移。
|
||||||
|
7. **管理端 payload 测试**:确认保存时始终发送 `packages.windows_x64` 嵌套对象,上传 `.exe` 后 `type=inno_setup` 且 URL、SHA、文件名、大小均落在同一槽位。
|
||||||
|
|
||||||
|
长期看,最稳妥的可观测性是让 `can_install=False` 同时带结构化原因(例如 `missing_package`、`invalid_digest`、`unsupported_type`、`insecure_installer_url`、`tls_verification_disabled`),并在客户端保留原因而不是立即清除所有包信息。这样 UI 不必用一个“后台未配置”文案覆盖所有安全门禁。
|
||||||
|
|
||||||
|
## 验证记录
|
||||||
|
|
||||||
|
- 根目录 `.trellis/` 不存在;本次按根 `AGENTS.md` 执行,只读检查生产代码,仅新增本文档。
|
||||||
|
- `app/.venv/Scripts/python.exe -m pytest tests/test_app_update.py tests/test_app_update_ui.py -q`:通过。
|
||||||
|
- 收集结果:`test_app_update.py` 21 项、`test_app_update_ui.py` 3 项,共 24 项。
|
||||||
|
- `php tests/DesktopWorkstationUpdateContractTest.php`(工作目录 `server/`):`Desktop workstation update contract: OK`。
|
||||||
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# DEBUG_MODE 与线上 API 固定策略分析
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前启动配置不是单一的“环境变量 -> `AppConfig`”链路,而是四层覆盖:
|
||||||
|
|
||||||
|
1. `python-dotenv` 先加载 `.env`,且 `override=False`,所以进程环境变量优先于 `.env`(`app/src/doctor_workstation/config.py:113-118`)。
|
||||||
|
2. `DOCTOR_API_BASE_URL` 被规范化并传入 `AppConfig`(`config.py:118-132`)。
|
||||||
|
3. 用户目录中的 `preferences.json` 再覆盖环境配置,因此当前实际上是 `进程环境/.env < preferences.json`(`config.py:133-153`)。
|
||||||
|
4. 登录页另有一套 Qt `QSettings`:`server/base_url` 会覆盖已经合并好的 `config.api_base_url`,保存或正式登录时再通过 `config_changed` 写回 `AppConfig`,控制器随后保存 `preferences.json` 并重建远端仓库(`app/src/doctor_workstation/ui/login.py:917-927, 1026-1054, 1067-1077, 1079-1103`;`app/src/doctor_workstation/app.py:438-504`)。
|
||||||
|
|
||||||
|
所以,只在 `AppConfig.load()` 里把环境变量替换成线上域名是不完整的。`DEBUG_MODE=False` 时必须同时封住:
|
||||||
|
|
||||||
|
- 环境变量 / `.env`;
|
||||||
|
- `preferences.json`;
|
||||||
|
- Qt `QSettings` 的 `server/base_url`;
|
||||||
|
- 登录页运行期 `with_updates(api_base_url=...)`。
|
||||||
|
|
||||||
|
建议目标契约为:
|
||||||
|
|
||||||
|
| 模式 | 最终 `AppConfig.api_base_url` | 本地地址设置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DEBUG_MODE=False` | 始终为 `https://admin.zhenyangtang.com.cn/adminapi` | 环境、JSON preference、Qt `QSettings`、登录页编辑均不得改变 |
|
||||||
|
| `DEBUG_MODE=True` | 保留当前规则:环境/.env 初始化,`preferences.json` 覆盖,登录页可再次编辑 | 完全保留现有可配置行为 |
|
||||||
|
|
||||||
|
仓库内线上地址最直接的证据是 `admin/.env.production:1-3`,生产管理端使用 `https://admin.zhenyangtang.com.cn/`;`admin/vite.config.ts:54-64` 的开发代理也指向同一主机。桌面端的 `normalize_api_base_url()` 会追加 `/adminapi`(`config.py:67-85`),因此建议常量保存主机根地址,最终有效地址由同一个规范化函数产生。`TongjiUniApp/main.js:3` 当前使用的是 `https://xt.zhenyangtang.com.cn/`,它属于另一客户端,不能替代管理 API 地址。
|
||||||
|
|
||||||
|
## 精确修改建议
|
||||||
|
|
||||||
|
### 1. 包级发布策略常量
|
||||||
|
|
||||||
|
在 `app/src/doctor_workstation/__init__.py:1-6` 添加两个普通源码常量,并更新 `__all__`。当前该文件已有未提交的版本升级 `1.1.0 -> 1.2.0`,实现时必须保留它,只做增量编辑。
|
||||||
|
|
||||||
|
建议名称和取值:
|
||||||
|
|
||||||
|
```python
|
||||||
|
DEBUG_MODE = False
|
||||||
|
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||||
|
|
||||||
|
__all__ = ["__version__", "DEBUG_MODE", "ONLINE_API_BASE_URL"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`DEBUG_MODE` 不应来自 `DOCTOR_DEBUG_MODE` 或其他环境变量,否则已安装程序仍可被本地环境切回调试地址,直接违反需求。它应是发布代码/构建产物内的策略开关。线上常量不要带查询参数、凭据或 fragment;是否在常量里带 `/adminapi` 均可,但建议只放域名根地址,让 `normalize_api_base_url()` 保持路径的唯一规范化入口。
|
||||||
|
|
||||||
|
`config.py` 从包根导入这两个常量不会形成循环:包 `__init__.py` 不导入 `config.py`;现有 `services/app_update.py:21` 也已经用相同方式从包根导入 `__version__`。
|
||||||
|
|
||||||
|
### 2. 在 AppConfig 的两个入口执行同一发布策略
|
||||||
|
|
||||||
|
涉及 `app/src/doctor_workstation/config.py:17-30, 67-85, 113-153, 167-176`。
|
||||||
|
|
||||||
|
建议在 `normalize_api_base_url()` 之后增加一个小型策略函数(名称可调整):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def effective_api_base_url(candidate: str) -> str:
|
||||||
|
if not DEBUG_MODE:
|
||||||
|
# 源码常量无效属于发布错误,应显式失败,不要静默退回空地址。
|
||||||
|
return normalize_api_base_url(ONLINE_API_BASE_URL)
|
||||||
|
try:
|
||||||
|
return normalize_api_base_url(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在两个入口复用:
|
||||||
|
|
||||||
|
1. `AppConfig.load()` 的 `api_url` 必须由该函数生成。这样进程环境和 `.env` 在 release 模式下即使含 `http://127.0.0.1` 也只会被读取而不会成为有效 API 地址。
|
||||||
|
2. `_merge_preferences()` 在 `DEBUG_MODE=False` 时必须忽略 JSON 中的 `api_base_url`;也可以允许读取后在 `replace()` 前强制写回 `effective_api_base_url(...)`。关键是**发布策略必须在 preference 合并之后生效**。
|
||||||
|
3. `with_updates()` 在 `DEBUG_MODE=False` 时必须把任何传入的 `api_base_url` 强制改为线上值,而不是仅做 URL 规范化。登录页运行期正是通过此入口更新配置。
|
||||||
|
|
||||||
|
实现上可选择“每个入口调用 `effective_api_base_url()`”,也可选择一个 `_apply_runtime_policy()` 在 `_merge_preferences()` 和 `with_updates()` 的 `replace()` 之后统一执行。后者更不容易遗漏,但需要保证两条返回路径都调用它。
|
||||||
|
|
||||||
|
不建议用 `AppConfig.__post_init__()` 强制改写所有直接构造的实例。仓库中大量单元/UI 测试直接构造带 `.test` 域名的 `AppConfig`(例如 `app/tests/test_ui_contract.py:408-412`);发布要求针对真实运行配置入口,没必要破坏依赖注入式测试。若希望更强的防御,可提供显式 `apply_runtime_policy()`,由 `load()`、`with_updates()` 和控制器接收外部 `AppConfig` 时调用。
|
||||||
|
|
||||||
|
### 3. 封住 Qt QSettings 的第二套本地 preference
|
||||||
|
|
||||||
|
涉及 `app/src/doctor_workstation/ui/login.py:451-468, 788-851, 917-950, 956-969, 1026-1054`。
|
||||||
|
|
||||||
|
这是满足“本地 preference 不应把它改回调试地址”的必需修改,不是纯 UI 优化:
|
||||||
|
|
||||||
|
- `_restore_settings()`:`DEBUG_MODE=False` 时,`server_url_edit` 只能显示 `config.api_base_url`,不得读取 `self.settings.value("server/base_url", ...)`;`DEBUG_MODE=True` 时保持现有读取逻辑。
|
||||||
|
- `_apply_server_settings()`:`DEBUG_MODE=False` 时使用 `config.api_base_url` 作为 `base_url`,不得信任编辑框或旧 QSettings;也不要把旧调试地址重新写入 `server/base_url`。`DEBUG_MODE=True` 时保持现状。
|
||||||
|
- 发布模式下至少将 `server_url_edit` 设为只读。也可隐藏地址编辑入口,但不要无意中一起删除超时设置;是否同时禁止“信任自签名证书”属于另一项发布安全策略。
|
||||||
|
- `_credential_scope()` 当前优先使用地址编辑框(`login.py:956-969`)。因此必须先确保发布模式下编辑框显示线上地址,否则实际请求虽已被 `with_updates()` 锁到线上,密码却可能错误地按旧调试地址做凭据 scope,造成跨环境凭据恢复混乱。
|
||||||
|
|
||||||
|
不建议启动时删除旧的 `server/base_url` QSettings。发布模式忽略它即可,这样将来显式切到 `DEBUG_MODE=True` 时仍能保留既有调试配置,也避免无必要的数据清理。
|
||||||
|
|
||||||
|
### 4. bootstrap / repository 侧无需另建域名来源
|
||||||
|
|
||||||
|
实际启动入口是 `app/src/doctor_workstation/__main__.py:5-19 -> app.py:1166-1182`。`main()` 只调用一次 `AppConfig.load()`,随后 `ApplicationController.__init__()` 立即执行 `_rebuild_remote_repository()`(`app.py:402-424`);后者把 `self.config.api_base_url` 原样传给 `build_repository()`(`app.py:513-525`),再由 `ApiClient` 规范化为带结尾斜杠的 `/adminapi/` 地址(`services/factory.py:13-45`;`services/api_client.py:133-148`)。
|
||||||
|
|
||||||
|
因此 `app.py`、`factory.py`、`api_client.py` 不应复制线上域名常量。只要 `AppConfig` 在完成所有合并后保持不变量,这一段无需修改。
|
||||||
|
|
||||||
|
可选的纵深防御:`ApplicationController._on_config_changed()` 在收到一个完整 `AppConfig` payload 时目前直接接受(`app.py:460-489`),只有 dict payload 才经过 `self.config.with_updates()`。若未来可能有第二个发信者,建议让完整 `AppConfig` 同样经过显式 runtime policy;当前唯一连接来自 `LoginWindow`(`app.py:447`),且其正常路径会先调用 `with_updates()`,所以这不是本次最小改动的阻塞项。
|
||||||
|
|
||||||
|
## 当前覆盖顺序与修改后顺序
|
||||||
|
|
||||||
|
当前:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env --(override=False)--> os.environ
|
||||||
|
|
|
||||||
|
v
|
||||||
|
AppConfig(env)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
preferences.json 覆盖 env
|
||||||
|
|
|
||||||
|
v
|
||||||
|
LoginWindow 的 QSettings/server/base_url 覆盖 config
|
||||||
|
|
|
||||||
|
v
|
||||||
|
with_updates -> save_preferences -> rebuild repository
|
||||||
|
```
|
||||||
|
|
||||||
|
建议修改后:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DEBUG_MODE=True : 保持上面的完整可配置链路
|
||||||
|
|
||||||
|
DEBUG_MODE=False:
|
||||||
|
env/.env ----------- ignored for api_base_url ---+
|
||||||
|
preferences.json --- ignored for api_base_url ---+--> ONLINE_API_BASE_URL
|
||||||
|
QSettings ---------- ignored for api_base_url ---+ |
|
||||||
|
runtime update ------ clamped for api_base_url ---+ v
|
||||||
|
build_repository
|
||||||
|
```
|
||||||
|
|
||||||
|
## 兼容风险与边界
|
||||||
|
|
||||||
|
1. **Demo 模式仍可覆盖“是否使用远端仓库”。** 当前 `demo_mode` 默认 `True`,且仍可被环境和 `preferences.json` 覆盖(`config.py:93, 126, 140-153`);登录页也会保存它(`login.py:1002-1006`)。本需求只要求固定 API 域名,所以不应顺手强制 `demo_mode=False`。如果产品语义其实是“发布版必须始终连接线上、不能进入 Demo”,需要单独明确并给 `demo_mode` 增加相同发布策略。
|
||||||
|
2. **TLS 校验仍可被本地 preference 关闭。** `verify_ssl` 当前可由环境、JSON preference 和 QSettings 改为 `False`(`config.py:129, 151-152, 174-175`;`login.py:937-944, 1038-1048`)。固定线上域名但允许关闭证书校验仍有中间人风险。建议产品确认是否在 `DEBUG_MODE=False` 时也强制 `verify_ssl=True`,但它超出“域名不可改”的最小范围。
|
||||||
|
3. **调试启动脚本不会自动打开源码 DEBUG_MODE。** `app/Debug_DoctorWorkstation.bat:16-23` 只设置独立配置目录、Demo 和日志级别,没有能力改变源码布尔常量。若常量提交为 `False`,脚本仍能跑 Demo,但不能用本地 URL。不要为方便而从环境读取 `DEBUG_MODE`;更安全的方案是开发者本地改为 `True`(不提交),或由明确区分的 debug 构建生成非发布模块。
|
||||||
|
4. **已有 preference 不需要迁移或删除。** 发布模式会忽略旧调试 URL;切回 debug 后仍按现有优先级恢复。`save_preferences()` 当前把完整 dataclass 写入 JSON(`config.py:155-165`),发布运行后可能把线上 URL写回文件,这是可接受的,但测试应覆盖“旧文件存在时首次启动仍直接得到线上 URL”。
|
||||||
|
5. **凭据按 URL scope 隔离。** `LoginWindow._credential_scope()` 和 `TokenStore` 使用 API scope。切到线上后旧调试 token/password 不应被用于线上,这是正确行为;但若只锁 `AppConfig` 而不锁登录页 QSettings,可能出现“请求发往线上、密码却按调试 URL scope 保存/恢复”的错配,因此第 3 节不能省略。
|
||||||
|
6. **构建 smoke 环境目前注入 loopback API。** Windows/macOS 构建与安装 smoke 分别在 `app/scripts/build_windows.ps1:28-47`、`build_macos.sh:105-123`、`smoke_windows_installer.ps1:101-109` 注入 `https://127.0.0.1:9`。发布策略生效后该变量会被忽略。正常 smoke 不应访问线上:更新检查被 `DOCTOR_SMOKE_TEST`/`--smoke-test` 阻断(`ui/dialogs/app_update.py:332-340`),且这些脚本设置 `DOCTOR_DEMO_MODE=true`,会阻断 session restore(`app.py:530-540`)。仍建议增加“smoke 期间没有发起线上请求”的断言,避免未来启动流程变化造成生产流量。
|
||||||
|
7. **不要把线上常量的错误静默转为空地址。** 调试环境输入无效时保持当前的空地址降级合理;源码内线上常量无效则应让测试/构建立即失败,否则发布包只会落入 `_UnconfiguredRepository`(`app.py:435-436, 513-525`),错误会拖到登录时才暴露。
|
||||||
|
8. **版本读取兼容。** PyInstaller spec 用正则只读取 `__version__` 行(`app/packaging/doctor_workstation.spec:27-37`)。只要保留当前独立的 `__version__ = "1.2.0"` 赋值,新增常量与 `__all__` 不影响版本生成。
|
||||||
|
|
||||||
|
## 建议测试
|
||||||
|
|
||||||
|
优先在 `app/tests/test_config.py` 增加以下矩阵:
|
||||||
|
|
||||||
|
1. `DEBUG_MODE=False`,环境 `DOCTOR_API_BASE_URL=http://127.0.0.1:8000`,无 preference:`AppConfig.load().api_base_url == "https://admin.zhenyangtang.com.cn/adminapi"`。
|
||||||
|
2. `DEBUG_MODE=False`,环境为线上、`preferences.json` 保存调试 URL:最终仍为线上。
|
||||||
|
3. `DEBUG_MODE=False`,先 `AppConfig.load()`,再 `with_updates(api_base_url="http://localhost:8000")`:最终仍为线上。
|
||||||
|
4. `DEBUG_MODE=True`,环境提供 A、preference 提供 B:最终仍为 B,证明现有“preference 覆盖 env”行为未回归。
|
||||||
|
5. `DEBUG_MODE=True`,无 preference,仅环境提供 URL:继续规范化并自动追加 `/adminapi`。
|
||||||
|
6. 将 `ONLINE_API_BASE_URL` 临时 monkeypatch 为非法值且 `DEBUG_MODE=False`:应显式抛错,避免发布误配置静默降级。
|
||||||
|
|
||||||
|
在 `app/tests/test_ui_contract.py` 增加:
|
||||||
|
|
||||||
|
1. 发布模式的 QSettings 预置 `server/base_url=http://127.0.0.1:8000`,创建 `LoginWindow` 后地址框显示线上 URL且不可编辑。
|
||||||
|
2. 发布模式调用 `_save_server_settings()` / 非 Demo `submit()`,`config_changed` payload 的 `api_base_url` 仍为线上,远端仓库不会以 QSettings 地址重建。
|
||||||
|
3. 上述场景下 `_credential_scope()` 返回线上 scope,防止凭据落在旧调试 scope。
|
||||||
|
4. `DEBUG_MODE=True` 重跑同类场景,确认地址框仍从 QSettings 恢复、保存后仍能切换服务器。
|
||||||
|
|
||||||
|
在 bootstrap/构建层增加或保留以下回归:
|
||||||
|
|
||||||
|
1. `ApplicationController` 用 `AppConfig.load()` 启动时,传给 `build_repository()` 的 release base URL 精确为 `https://admin.zhenyangtang.com.cn/adminapi`。
|
||||||
|
2. `--smoke-test` 和 `DOCTOR_SMOKE_TEST=1` 下,无论 release URL 是否存在,都不执行更新请求或 token restore 网络调用。
|
||||||
|
3. 冻结包 smoke 继续通过;原 smoke 脚本中的 loopback `DOCTOR_API_BASE_URL` 被忽略是预期行为,不应把断言写成“最终 URL 等于 127.0.0.1”。
|
||||||
|
|
||||||
|
建议验证命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location D:\web\zyt\app
|
||||||
|
uv run pytest tests/test_config.py tests/test_ui_contract.py -q
|
||||||
|
uv run ruff check src/doctor_workstation/__init__.py src/doctor_workstation/config.py src/doctor_workstation/ui/login.py tests/test_config.py tests/test_ui_contract.py
|
||||||
|
```
|
||||||
|
|
||||||
|
若还修改了 bootstrap 防御或 smoke 契约,再运行相关完整测试和冻结构建门禁;仅本分析任务未修改生产代码、也未执行会连接线上环境的测试。
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 登录页 DEBUG_MODE 门禁分析
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前实现不存在 `DEBUG_MODE`(项目内唯一含 `debug_mode` 的命中只是一个测试函数名)。登录页始终创建并展示“演示模式”和“服务器设置”入口;`AppConfig.demo_mode` 默认又是 `True`,且 `preferences.json` 会覆盖环境配置。因此,仅对两个控件调用 `hide()` 不能满足目标:隐藏的 checkbox 仍可能保持 checked,普通登录仍会自动读取/写回残留 `QSettings`,控制器也会接受伪造或残留的 demo 状态。
|
||||||
|
|
||||||
|
建议把 `DEBUG_MODE` 设计成**非用户偏好、不可由 `QSettings` 或 `preferences.json` 覆盖的单一运行时门禁**,并在配置加载、LoginWindow 行为和 ApplicationController 三层同时收口:
|
||||||
|
|
||||||
|
- `DEBUG_MODE=True`:显示且允许演示仓库切换和登录页服务器设置,保留现有调试行为。
|
||||||
|
- `DEBUG_MODE=False`:隐藏完整 UI 区块,强制 effective demo 为 `False`,忽略残留服务器 QSettings,登录只能使用 composition root 提供的远程仓库;直接调用槽函数、设置隐藏 checkbox、发信号或构造 demo payload 也不能绕过。
|
||||||
|
|
||||||
|
`demo_mode` 只能表示 DEBUG 模式下的默认选择/当前选择,不能再充当“是否有权使用 demo”的授权位。
|
||||||
|
|
||||||
|
## 当前实现与风险点
|
||||||
|
|
||||||
|
### 1. 配置与持久化
|
||||||
|
|
||||||
|
| 位置 | 当前行为 | DEBUG_MODE=False 的风险 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/doctor_workstation/config.py:88-99` | `AppConfig.demo_mode` 默认 `True`,没有 debug gate | 直接构造 `AppConfig()` 就默认允许 demo |
|
||||||
|
| `config.py:113-133` | `DOCTOR_DEMO_MODE` 未设置时也按 `True` 加载,然后调用 `_merge_preferences()` | 生产未显式注入环境变量时默认 demo;即使环境设为 false,后续偏好仍可覆盖 |
|
||||||
|
| `config.py:135-153` | `preferences.json` 中所有 dataclass 字段均会合并,包括 `demo_mode`、`api_base_url`、`request_timeout`、`verify_ssl` | 旧 debug profile 的 demo/server 值可覆盖本次受控配置 |
|
||||||
|
| `config.py:155-165` | `save_preferences()` 用 `asdict(self)` 保存完整配置 | demo 切换和服务器设置会持续残留在 JSON 中 |
|
||||||
|
| `config.py:167-176` | `with_updates()` 可随时把 demo/服务器字段改回调试值 | UI 隐藏后仍可从信号/直接调用修改 |
|
||||||
|
|
||||||
|
需要特别区分两套持久化:demo 当前**不写 QSettings**,它通过 `config_changed -> ApplicationController._on_config_changed -> save_preferences()` 写入 `preferences.json`;服务器地址、超时和证书校验先写 `QSettings`,随后同一信号链又写入 `preferences.json`。相关位置是 `ui/login.py:1038-1054` 和 `app.py:460-491`。
|
||||||
|
|
||||||
|
当前 `Debug_DoctorWorkstation.bat:16-24` 只是设置 `DOCTOR_DEMO_MODE=true` 和 `DOCTOR_LOG_LEVEL=DEBUG`,没有提供独立 debug capability。若新门禁来自环境,调试启动器应显式设置专用值(例如 `DOCTOR_DEBUG_MODE=true`);普通/冻结启动不得设置。若门禁是构建期常量,则无需让用户偏好或 `.env` 参与。无论采用哪种来源,都不要把它作为普通 `AppConfig` dataclass 字段写入 `preferences.json`。
|
||||||
|
|
||||||
|
### 2. LoginWindow 组件和信号
|
||||||
|
|
||||||
|
| 位置 | 组件/信号链 | 当前行为与缺口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ui/login.py:444-449` | `server_settings_changed(dict)`、`config_changed(object)`、`demo_mode_changed(bool)` | `server_settings_changed` 目前仅测试监听;另外两个信号由 controller 监听。所有发射点都无 debug gate |
|
||||||
|
| `login.py:451-468` | 构造参数、`demo_repository`、`active_repository` | 只要传入 demo repository 就保留可切换能力;controller 当前总会传入 |
|
||||||
|
| `login.py:739-755` | “记住密码”行和 `demo_check` | checkbox 始终加入布局;只有 repository 为空时 disabled,不会隐藏 |
|
||||||
|
| `login.py:770-855` | “或”分隔线、`server_toggle`、`server_panel` 及 URL/timeout/self-signed/save 子控件 | toggle 始终显示,panel 只是在初始时折叠。若只隐藏 toggle,“或”分隔线和固定 spacing 仍会残留 |
|
||||||
|
| `login.py:917-948` | `_restore_settings()` | 始终从 QSettings 恢复三项 server 值;只看 `config.demo_mode` 就勾选 demo。`setChecked(True)` 会触发已连接的 `_on_demo_toggled()` |
|
||||||
|
| `login.py:956-969` | `_credential_scope()` | 优先读取 `server_url_edit`;即便控件隐藏,残留 QSettings URL 仍可改变凭据读取/保存 scope |
|
||||||
|
| `login.py:1002-1013` | `demo_check.toggled -> _on_demo_toggled()` | 切换 `active_repository`,发射 `demo_mode_changed`,再经 `_emit_config_update` 发射 `config_changed`;没有权限判断 |
|
||||||
|
| `login.py:1015-1027` | `server_toggle.clicked`、save button | 方法可被直接调用,隐藏控件并不能阻止 panel 展开或保存 |
|
||||||
|
| `login.py:1029-1054` | `_apply_server_settings()` | 会持久化 QSettings、发射两个配置相关信号;没有权限判断 |
|
||||||
|
| `login.py:1079-1126` | `submit()` | 直接以隐藏 checkbox 的 checked 状态决定 demo;非 demo 登录会**无条件自动应用服务器控件当前值**,所以旧 QSettings 即便不展开 panel 也会生效 |
|
||||||
|
| `login.py:1140-1159` | `_set_loading()` | loading 结束会按 `demo_repository is not None` 重新 enable demo,并重新 enable server 子控件;需把 debug gate 合入 enable 条件 |
|
||||||
|
| `login.py:1177-1216` | 登录成功与凭据保存 | `payload["demo_mode"]` 决定是否保存密码;凭据 scope 又可能来自隐藏的 server edit |
|
||||||
|
| `login.py:1224-1234` | 证书错误 | 会直接勾选 toggle 并展开 panel;生产隐藏后仍可被错误路径重新显示 |
|
||||||
|
|
||||||
|
证书错误文案还在 `ui/widgets.py:376-383` 明确引导用户展开服务器设置、关闭证书校验。非 debug 模式必须改为不引用隐藏入口的运维提示,否则 UI 和文案契约矛盾。
|
||||||
|
|
||||||
|
### 3. ApplicationController 与登录可信边界
|
||||||
|
|
||||||
|
| 位置 | 当前行为 | 需要的防线 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `app.py:402-423` | 总是实例化 `DemoDoctorRepository()`;`current_demo_mode=config.demo_mode` | 非 debug 不创建/不暴露 demo repository,并强制 current demo false |
|
||||||
|
| `app.py:438-455` | 总把 demo repository 传给 LoginWindow;复用窗口时信任 `demo_check` | 传递显式 gate;非 debug 复用时重置 checkbox/active repository |
|
||||||
|
| `app.py:460-506` | 接受 `demo_mode` 及全部 server 字段,保存 preferences 并重建 repository | 非 debug 拒绝 debug-only changes,避免伪造 `config_changed` 绕过 UI |
|
||||||
|
| `app.py:508-511` | 任意 `demo_mode_changed(True)` 都会设置 current demo 并取消 session restore | 非 debug 忽略/纠正 true |
|
||||||
|
| `app.py:530-540` | `config.demo_mode=True` 会跳过生产 token restore | 必须基于经过门禁归一化的 effective demo;残留 preference 不能阻止 restore |
|
||||||
|
| `app.py:653-681` | 信任成功 payload 中的 `demo_mode` 与 repository | 非 debug 必须拒绝 demo payload/repository,或无条件把 effective demo 归零;这是 UI 之外的最后可信边界 |
|
||||||
|
| `app.py:782-792` | `current_demo_mode` 决定是否打开离线 demo 视频窗 | 前述边界不收口时,伪造状态还会扩散到登录后的功能 |
|
||||||
|
|
||||||
|
## 精确修改建议
|
||||||
|
|
||||||
|
### A. 建立单一、不可持久化的 capability
|
||||||
|
|
||||||
|
在 `src/doctor_workstation/config.py` 定义唯一 `DEBUG_MODE`(或等价只读函数),由受控构建/专用调试启动器决定。不要从 `QSettings` 读取,不要让 `preferences.json` 覆盖,也不要随 `asdict(AppConfig)` 保存。
|
||||||
|
|
||||||
|
配置加载完成后必须做一次最终归一化:`effective_demo_mode = DEBUG_MODE and requested_demo_mode`。在 `DEBUG_MODE=False` 时,`_merge_preferences()` 至少忽略 `demo_mode`;若“服务器设置不可用”意味着生产连接完全由受控环境提供,还应同时忽略偏好中的 `api_base_url`、`request_timeout`、`verify_ssl`,否则旧登录页设置虽然 UI 不可见,仍会从 JSON 生效。`with_updates()` 也应拒绝或丢弃非 debug 下对这些 debug-only 字段的修改。
|
||||||
|
|
||||||
|
推荐把 debug capability 显式传给 `ApplicationController`/`LoginWindow` 或保存为只读实例属性,便于测试 True/False 两条路径。不要在多个模块各自复制一个可 monkeypatch 的常量,否则测试或运行时可能出现 config 判 false、UI 判 true 的分裂状态。
|
||||||
|
|
||||||
|
### B. LoginWindow:可见性和行为同时门禁
|
||||||
|
|
||||||
|
在 `ui/login.py:451-468` 记录 `self.debug_mode`,并把 `self.demo_repository` 设为 `demo_repository if debug_mode else None`。建议仍构造具名控件以保持测试和代码引用稳定,但所有状态转换都使用 `self.debug_mode` 判断。
|
||||||
|
|
||||||
|
UI 结构建议:
|
||||||
|
|
||||||
|
1. `demo_check` 仅在 debug 时 visible,并且 enabled 条件为 `debug_mode and demo_repository is not None and not loading`。
|
||||||
|
2. 把 `login.py:770-855` 的“或”分隔线、server toggle、panel 和上下 spacing 包进一个 `self.debug_server_section` QWidget;整个 section 仅在 debug 时 visible。单独隐藏 `server_toggle` 会留下“或”和空白。
|
||||||
|
3. panel 初始仍折叠;debug true 时保持现有 toggle 行为。
|
||||||
|
|
||||||
|
行为防线建议:
|
||||||
|
|
||||||
|
1. `_restore_settings()`:非 debug 不读取 `server/*` QSettings,不恢复 demo,明确令 demo unchecked、active repository 为 production repository;服务器控件若仍构造,只从受控 `config` 填充。是否删除旧键是迁移策略,**忽略它们才是安全要求**。
|
||||||
|
2. `_credential_scope()`:非 debug 始终从受控 config URL 取 scope,不读取隐藏的 `server_url_edit`。
|
||||||
|
3. `_on_demo_toggled(True)`:非 debug 立即用 signal blocker 恢复 unchecked/production repository,然后 return;不得发 `demo_mode_changed` 或 `config_changed`。
|
||||||
|
4. `_toggle_server_panel()`、`_save_server_settings()`、`_apply_server_settings()`:非 debug 强制 panel 关闭且不写 QSettings、不发 `server_settings_changed/config_changed`。直接调用也必须无效。
|
||||||
|
5. `submit()`:用 `demo_mode = self.debug_mode and self.demo_check.isChecked()`,并从这个 effective 值选择 repository。非 debug 跳过 `_apply_server_settings()`,只使用 composition root 已构造的 remote repository;否则会再次应用隐藏控件中的旧值。
|
||||||
|
6. `_set_loading()`:所有 demo/server enabled 状态与 `self.debug_mode` 做 AND,防止 loading 完成后重新激活。
|
||||||
|
7. `_on_login_error()`:仅 debug 时自动展开 certificate panel;非 debug 保持 section 隐藏,并显示“请联系管理员检查受控服务器/证书配置”之类不提供绕过证书校验的文案。
|
||||||
|
|
||||||
|
### C. Controller:不要信任 UI 状态或 payload
|
||||||
|
|
||||||
|
在 `app.py:402-423` 以同一 capability 计算 effective state;非 debug 最好根本不实例化 `DemoDoctorRepository`。`_show_login()` 显式传 gate,窗口复用时不要读取隐藏 checkbox 决定 repository。
|
||||||
|
|
||||||
|
`_on_config_changed()` 必须再次过滤 demo/server debug-only 字段;`_on_demo_mode_changed()` 非 debug 不接受 true;`_begin_session_restore()` 不得因未经门禁的旧 `config.demo_mode` 跳过;`_on_login_succeeded()` 应把 demo capability 作为可信边界,非 debug 收到 `demo_mode=True` 或 demo repository 时拒绝进入 shell并清理 session,而不是静默接受 payload。这样即使未来有其他代码直接调用槽函数,也不能重新开启演示路径。
|
||||||
|
|
||||||
|
## `tests/test_ui_contract.py` 现状与调整
|
||||||
|
|
||||||
|
实际文件是 `app/tests/test_ui_contract.py`,`app/tests` 下没有 `conftest.py`;这里使用的 `tmp_path`/`monkeypatch` 是 pytest 内置 fixture,相关 helper 都定义在测试函数内。
|
||||||
|
|
||||||
|
现有相关契约:
|
||||||
|
|
||||||
|
- `test_ui_contract.py:230-273`:真实 demo 登录,证明 `config.demo_mode=True` 会勾选 checkbox、使用空账号密码登录 demo,并发出 demo payload;未覆盖 debug capability。
|
||||||
|
- `test_ui_contract.py:276-337`:同一 QSettings 跨窗口恢复账号/密码;不涉及 demo。
|
||||||
|
- `test_ui_contract.py:340-375`:服务器 panel 在最小窗口的布局。
|
||||||
|
- `test_ui_contract.py:378-399`:函数名虽含 `debug_mode`,实际仅验证 self-signed 值写入 QSettings,没有任何 `DEBUG_MODE` 判断。
|
||||||
|
- `test_ui_contract.py:402-455`:普通登录前自动应用 server 值、经 `config_changed` 换成新 repository。
|
||||||
|
- `test_ui_contract.py:458-463`:证书错误文案指向服务器设置。
|
||||||
|
- `test_ui_contract.py:495-513`:证书错误会自动展开服务器 panel;这个契约只应在 debug true 成立。
|
||||||
|
|
||||||
|
引入 gate 后,`230`、`340`、`378`、`402`、`495` 这几组依赖 demo/server 的测试都应显式运行在 `DEBUG_MODE=True`,避免它们因测试默认值偶然通过。不要新增“把 demo 写入 QSettings”的契约;当前 demo 的持久化源是 AppConfig/preferences,目标反而要求非 debug 忽略该残留值。
|
||||||
|
|
||||||
|
## 建议回归测试矩阵
|
||||||
|
|
||||||
|
### `tests/test_ui_contract.py`
|
||||||
|
|
||||||
|
1. **debug true 可见且可用**:show 窗口后断言 demo checkbox、完整 server section/toggle 可见;原 demo 登录、panel 几何、自签名保存、登录前应用服务器设置均继续通过。
|
||||||
|
2. **debug false 无视觉残件**:断言 demo checkbox、`debug_server_section`(包括“或”分隔线)、toggle、panel 都不可见;demo unchecked,`active_repository is remote_repository`。
|
||||||
|
3. **残留 QSettings 不生效**:预写 `server/base_url=旧地址`、`server/read_timeout`、`server/verify_ssl=false`,用 debug false 构造窗口;断言 credential scope/实际登录 repository 使用受控 config,QSettings 值未被 `_apply_server_settings()` 写回或发射成配置更新。
|
||||||
|
4. **直接调用不能绕过**:debug false 下程序化 `demo_check.setChecked(True)`、`_on_demo_toggled(True)`、`_toggle_server_panel(True)`、`_save_server_settings()`;断言仍 unchecked、production repository、panel hidden,`demo_mode_changed`、`server_settings_changed`、`config_changed` 均无 debug 更新。
|
||||||
|
5. **提交强制 production**:给 debug false 窗口同时传 remote 和 demo repository,并让 stale config 的 `demo_mode=True`;输入账号密码后立即执行 worker,断言只有 remote `login()` 被调用,payload `demo_mode=False`。
|
||||||
|
6. **证书错误分模式**:debug true 仍自动展开并给出 self-signed 指引;debug false 不展开/不显示 section,错误文案不再提隐藏的“服务器设置”或关闭证书校验。
|
||||||
|
7. **loading 不重启入口**:debug false 执行 `_set_loading(True)` 再 `_set_loading(False)`,断言 demo/server 控件持续 hidden + disabled。
|
||||||
|
|
||||||
|
### `tests/test_config.py`
|
||||||
|
|
||||||
|
1. 在隔离 `DOCTOR_CONFIG_DIR` 写入旧 `preferences.json`(至少 `demo_mode:true`);DEBUG false 加载后必须 `demo_mode is False`,即使 `DOCTOR_DEMO_MODE=true` 也不能越权。
|
||||||
|
2. DEBUG true 时确认 `DOCTOR_DEMO_MODE`/允许的 demo preference 仍能选择默认 demo 状态。
|
||||||
|
3. 若生产服务器配置要求环境权威,再写入旧 JSON server 字段,断言 DEBUG false 仍采用环境的 URL/timeout/verify_ssl。
|
||||||
|
4. `with_updates(demo_mode=True)` 在 DEBUG false 下不能产生 effective demo true;同理覆盖 server debug-only 更新的策略。
|
||||||
|
|
||||||
|
### `tests/test_ui_contract.py` 中的 controller 边界(或拆到 controller 专属测试)
|
||||||
|
|
||||||
|
1. DEBUG false 时 `_begin_session_restore()` 不因 stale `config.demo_mode=True` 而跳过远程恢复。
|
||||||
|
2. DEBUG false 时直接调用 `_on_demo_mode_changed(True)` 不改变 `current_demo_mode`。
|
||||||
|
3. DEBUG false 时把 `demo_mode=True`/demo repository 的伪造 payload 传给 `_on_login_succeeded()`,断言不能创建 ShellWindow。
|
||||||
|
|
||||||
|
若采用专用 `DOCTOR_DEBUG_MODE` 环境变量,还应在 `tests/test_one_click_entrypoints.py:102-108` 增加调试启动器显式开启、普通启动器/打包入口不开启的静态契约,并同步 `.env.example:8-17` 与 `README.md:56-72`,避免继续把 `DOCTOR_DEMO_MODE=true` 描述成足以启用演示能力。
|
||||||
|
|
||||||
|
## 最小验收标准
|
||||||
|
|
||||||
|
非 debug 模式应同时满足以下可观察结果:登录页看不到 demo、服务器入口、“或”分隔线或相关空白;旧 demo preference 不能阻止远程 token restore;旧 server QSettings 不能改变 URL、timeout、TLS 校验或凭据 scope;程序化调用隐藏控件/槽函数/信号也不能切换仓库或进入 demo shell。只有这四层都成立,才不是单纯的视觉隐藏。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Windows 1.2.0 正式包重建结果(DEBUG_MODE=False)
|
||||||
|
|
||||||
|
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||||
|
- 工作目录:`D:\web\zyt\app`
|
||||||
|
- 总体结果:成功
|
||||||
|
- 生产源码修改:无(本次仅重建产物并新增本记录)
|
||||||
|
|
||||||
|
## 发布配置核对
|
||||||
|
|
||||||
|
打包前解析 `src/doctor_workstation/__init__.py`,确认:
|
||||||
|
|
||||||
|
- `__version__ = "1.2.0"`
|
||||||
|
- `DEBUG_MODE = False`
|
||||||
|
- `ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"`
|
||||||
|
|
||||||
|
打包完成后,使用 PyInstaller 的归档读取器打开
|
||||||
|
`dist/DoctorWorkstation/DoctorWorkstation.exe` 内嵌的 `PYZ.pyz`,提取
|
||||||
|
`doctor_workstation` 模块并检查其顶层字节码常量,得到:
|
||||||
|
|
||||||
|
- `STORE_NAME __version__` 前的常量为 `"1.2.0"`
|
||||||
|
- `STORE_NAME DEBUG_MODE` 前的常量为 `False`
|
||||||
|
- `STORE_NAME ONLINE_API_BASE_URL` 前的常量为
|
||||||
|
`"https://admin.zhenyangtang.com.cn"`
|
||||||
|
|
||||||
|
因此本次重建的 EXE 已包含正式模式和线上 API 域名配置。
|
||||||
|
Windows 版本资源也核对为:应用 EXE 的 `FileVersion` / `ProductVersion`
|
||||||
|
均为 `1.2.0`,安装器的 `ProductVersion` 为 `1.2.0`。
|
||||||
|
|
||||||
|
## 打包
|
||||||
|
|
||||||
|
执行命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- Vue/Vite 前端构建:通过(43 个模块)
|
||||||
|
- PyInstaller 6.22.0 / Python 3.12.12:通过
|
||||||
|
- Frozen Qt multimedia file gate:通过
|
||||||
|
- Frozen Qt multimedia smoke gate:通过
|
||||||
|
- Frozen application entry smoke gate:通过
|
||||||
|
- 7-Zip ZIP 创建:通过(`Everything is Ok`)
|
||||||
|
- Inno Setup 6.7.3:通过(`Successful compile (175.078 sec)`)
|
||||||
|
|
||||||
|
构建过程出现 Vite 大 chunk 提示、一个可选 Qt QML 插件缺失提示以及
|
||||||
|
Windows 系统 DLL 解析警告;它们均未阻断构建,且上述冻结产物门禁全部通过。
|
||||||
|
|
||||||
|
## 安装器烟测
|
||||||
|
|
||||||
|
执行命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||||
|
- 隔离测试目录:
|
||||||
|
`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-4988cc3cc71346e49925445b9ae57fb9`
|
||||||
|
- 覆盖项:安装器图标、静默安装、已安装 EXE 启动、静默卸载及卸载残留检查
|
||||||
|
|
||||||
|
## 产物与校验
|
||||||
|
|
||||||
|
| 文件 | 大小(字节) | 大小(MiB) | SHA-256 |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| `dist/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,912,808 | 155.366 | `D0A9EED88F42F7FBBF31920D7B1ED82BD241481E98964F42313DF386CB746C0A` |
|
||||||
|
| `dist/DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,810,092 | 220.118 | `9FAA8596AE0D233626B3D44676C51E6DB0BA3496B12D2EE6C984ABC0E51EB01B` |
|
||||||
|
| `dist/SHA256SUMS.txt` | 220 | 0.000 | `2DA56F76458ACE040597916C68B265C737D270B335EA73F21C4A248A2FA41B78` |
|
||||||
|
|
||||||
|
`dist/SHA256SUMS.txt` 内容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
D0A9EED88F42F7FBBF31920D7B1ED82BD241481E98964F42313DF386CB746C0A DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||||
|
9FAA8596AE0D233626B3D44676C51E6DB0BA3496B12D2EE6C984ABC0E51EB01B DoctorWorkstation-Windows-x64-1.2.0.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
独立使用 `Get-FileHash -Algorithm SHA256` 重算 EXE 和 ZIP 后,两项均与
|
||||||
|
`SHA256SUMS.txt` 逐字符匹配;清单恰好包含两条记录。
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Debug mode full-suite verification
|
||||||
|
|
||||||
|
Verification date: 2026-08-28 (Asia/Shanghai)
|
||||||
|
|
||||||
|
Scope: read-only verification of the current shared worktree under `D:\web\zyt\app`. No production source was modified.
|
||||||
|
|
||||||
|
## Pytest
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
- Exit code: `1`
|
||||||
|
- Result: `2 failed, 684 passed`
|
||||||
|
- Total collected/executed: `686`
|
||||||
|
- Duration: `3195.85s` (`0:53:15`)
|
||||||
|
|
||||||
|
Failures:
|
||||||
|
|
||||||
|
1. `tests/test_diagnosis_order_video_visual.py::test_video_table_embeds_player_and_preserves_row_bound_upload`
|
||||||
|
- Assertion location: `tests/test_diagnosis_order_video_visual.py:429`
|
||||||
|
- Assertion: `table.rowHeight(0) >= playback.required_table_row_height()`
|
||||||
|
- Actual: row height `246`; required row height `250`.
|
||||||
|
|
||||||
|
2. `tests/test_reception_parity_ui.py::test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geometry`
|
||||||
|
- Assertion location: `tests/test_reception_parity_ui.py:1575`
|
||||||
|
- Assertion: `expand_button.size().width() == expand_button.size().height() == 28`
|
||||||
|
- Actual: `QSize(28, 34)`; expected `QSize(28, 28)`.
|
||||||
|
|
||||||
|
## Ruff
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\python.exe -m ruff check src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
- Exit code: `1`
|
||||||
|
- Result: `9` errors, all `F401` unused imports and all reported as fixable with `--fix`.
|
||||||
|
|
||||||
|
Findings:
|
||||||
|
|
||||||
|
1. `src/doctor_workstation/ui/diagnosis_index_widgets.py:37:5` - unused `PySide6.QtGui.QPixmap`.
|
||||||
|
2. `src/doctor_workstation/ui/dialogs/ai_consult.py:18:5` - unused `PySide6.QtGui.QFont`.
|
||||||
|
3. `src/doctor_workstation/ui/dialogs/ai_consult.py:24:5` - unused `PySide6.QtGui.QPixmap`.
|
||||||
|
4. `src/doctor_workstation/ui/dialogs/ai_consult.py:26:5` - unused `PySide6.QtGui.QTextBlockFormat`.
|
||||||
|
5. `src/doctor_workstation/ui/dialogs/ai_consult.py:27:5` - unused `PySide6.QtGui.QTextCharFormat`.
|
||||||
|
6. `src/doctor_workstation/ui/dialogs/ai_consult.py:28:5` - unused `PySide6.QtGui.QTextCursor`.
|
||||||
|
7. `src/doctor_workstation/ui/dialogs/prescription.py:19:28` - unused `datetime.datetime`.
|
||||||
|
8. `src/doctor_workstation/ui/pages/patients.py:11:75` - unused `PySide6.QtGui.QPixmap`.
|
||||||
|
9. `src/doctor_workstation/ui/pages/prescriptions.py:10:65` - unused `PySide6.QtGui.QPixmap`.
|
||||||
|
|
||||||
|
## Overall result
|
||||||
|
|
||||||
|
The full verification gate is failing: both pytest and ruff returned exit code `1`.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# 强制更新对话框“退出软件”安全实现分析
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
强制更新对话框可以提供“退出软件”,但不能把按钮直接连接到 `dialog.close()`、`reject()` 或 `QApplication.quit()`。当前更新下载由全局 `QThreadPool` 中的 `QRunnable` 执行,退出应用不会自动取消或等待该任务;安全的最小方案应是两阶段退出:
|
||||||
|
|
||||||
|
1. GUI 线程记录“退出已请求”,禁止再提交安装,并用线程安全的取消事件通知下载任务;
|
||||||
|
2. 更新任务通过既有 `finished` 信号确认已结束后,再由 `ApplicationController` 调用 `application.quit()`;
|
||||||
|
3. `aboutToQuit` 中的 `ApplicationController.shutdown()` 只做最终、幂等的资源清理,不能承担异步等待任务结束的职责。
|
||||||
|
|
||||||
|
这条顺序保证:用户选择“退出软件”后不会又启动更新助手;`.part` 文件能走现有异常清理;Qt 事件循环在工作线程仍可能发信号时不会提前消失。
|
||||||
|
|
||||||
|
## 当前实现与证据
|
||||||
|
|
||||||
|
### 1. 强制对话框目前没有退出路径
|
||||||
|
|
||||||
|
- `AppUpdateDialog` 对强制更新移除关闭按钮并设为应用级模态(`app/src/doctor_workstation/ui/dialogs/app_update.py:112-121`)。
|
||||||
|
- 按钮区只有“稍后提醒”“取消下载”“立即更新”;“稍后提醒”在强制更新时隐藏(`:179-197`)。
|
||||||
|
- `set_busy()` 只在“忙且非强制”时显示取消下载,所以强制更新下载过程中没有任何停止入口(`:208-213`)。
|
||||||
|
- 强制更新或任意下载忙状态都会忽略窗口关闭事件,强制更新还会忽略 Escape(`:265-275`)。
|
||||||
|
|
||||||
|
因此新增能力应是独立的 `exit_requested` 语义,而不是复用 `download_cancelled`。后者当前在 session 中明确拒绝强制更新(`:411-415`),且它的既有语义只是“取消后留在应用内”。
|
||||||
|
|
||||||
|
### 2. session 没有“请求取消 -> 已经停稳”的闭环
|
||||||
|
|
||||||
|
- `_TaskSignals` 已声明 `finished`,`_Task.run()` 也一定会在 `finally` 发出它(`app_update.py:67-90`),但 `AppUpdateSession` 没有连接该信号。
|
||||||
|
- session 只保留一个跨线程共享的 `_cancel: bool` 和最近一次 `_signals`,没有活动 worker/token、退出状态或完成回调(`:285-292`)。
|
||||||
|
- 检查任务和安装准备任务均直接提交到 `QThreadPool.globalInstance()`(`:316-330`、`:467-472`);局部 `worker` 没被 session 用来跟踪生命周期。
|
||||||
|
- 安装准备任务直接把进度/状态连到 dialog,把结果连到 `_finish_install()`(`:467-471`)。关闭事件循环前没有撤销或门控这些回调。
|
||||||
|
- 新 offer 到达时,session 会对旧 dialog 调用 `close()` 后立刻 `deleteLater()`(`:397-405`)。如果旧 dialog 正在强制更新/下载,它的 `closeEvent()` 会拒绝关闭,但 `deleteLater()` 仍会排队;与此同时旧 worker 仍持有连接和捕获该 dialog 的 lambda。这也是需要用“活动操作 token”阻止重入/替换的理由。
|
||||||
|
|
||||||
|
### 3. 当前取消只能在收到下载分块以后生效
|
||||||
|
|
||||||
|
- `download_package()` 把 HTTP read timeout 设为 `None`(`app/src/doctor_workstation/services/app_update.py:311`)。服务器建立连接后若不再发送数据,worker 可以无限阻塞在读取中,GUI 写入 `_cancel=True` 也不能唤醒 socket。
|
||||||
|
- 取消回调只在 `iter_bytes()` 产出一个 chunk 后检查(`:334-337`)。取消被观察到时会抛出 `AppUpdateError`,现有异常分支会删除 `.part` 文件(`:349-351`),这一清理机制可以继续复用。
|
||||||
|
- 下载返回以后没有再次检查取消状态;job 会继续校验安装器,或调用不可取消的 `safe_extract_zip()`(UI `app_update.py:439-465`;service `app_update.py:247-260`)。
|
||||||
|
- 下载全部完成后,文件在 `os.replace()` 前也没有最后一次取消检查(service `app_update.py:358-367`)。即使退出请求恰好到达末尾,job 仍可能返回 `_PreparedUpdate`。
|
||||||
|
- 工作目录在下一次同版本尝试开始时会整体删除重建(service `app_update.py:722-727`),所以取消发生在下载完成或解压阶段时,保留完整 zip/部分解压目录不会污染下一次尝试;关键仍是不能继续提交安装。
|
||||||
|
|
||||||
|
### 4. 直接 `quit()` 存在安装竞态
|
||||||
|
|
||||||
|
当前 `_finish_install()` 收到任何合法 `_PreparedUpdate` 就先启动外部更新助手,再用 300 ms 定时器调用 `application.quit()`(UI `app_update.py:483-503`)。更新助手按设计等待当前 PID 消失后才覆盖/安装并重启:archive 路径见 service `app_update.py:517-525`,Inno Setup 路径见 `:594-625`。
|
||||||
|
|
||||||
|
若下载中“退出软件”直接调用 `quit()`,存在以下时序:
|
||||||
|
|
||||||
|
1. worker 已完成最后一个 chunk,并已把 `result` 排进 GUI 事件队列;
|
||||||
|
2. 用户的退出点击与该 queued result 先后到达 GUI 线程;
|
||||||
|
3. 若 result 仍被处理,当前 `_finish_install()` 没有“退出已请求”门禁,会启动更新助手;
|
||||||
|
4. 应用随后退出,于是用户选择的“只退出”实际变成“退出并安装”。
|
||||||
|
|
||||||
|
反向时序也不安全:如果 `quit()` 先结束事件循环,worker 仍可能在独立 `httpx.Client` 中写 `.part`、解压或发射 Qt 信号。`QApplication.quit()` 是退出事件循环的请求,不是 `QRunnable` 的 cancel/join。进程最终可能等待 Qt 线程池析构、遗留中间文件,或丢弃已经排队的结果;不能把这些析构时机当成生命周期协议。
|
||||||
|
|
||||||
|
### 5. `ApplicationController.shutdown()` 目前不管理 updater
|
||||||
|
|
||||||
|
- `aboutToQuit` 在控制器构造时连接到 `shutdown()`(`app/src/doctor_workstation/app.py:402-427`)。
|
||||||
|
- `shutdown()` 只置 `_shutting_down`、失效 session restore、关闭视频和远端 API client;没有调用 `self.app_updater.shutdown()`(`:1081-1097`)。
|
||||||
|
- Qt 配置了 `setQuitOnLastWindowClosed(True)`(`:1147-1162`),因此单纯关闭/拒绝 dialog 也不是统一的退出协议:父 login/shell 仍存在时未必退出,最后窗口意外关闭时又会绕过 updater 的准备阶段。
|
||||||
|
- `ApiClient.close()` 会无超时地等待所有活跃短请求归还连接(`app/src/doctor_workstation/services/api_client.py:414-450`,尤其 `:427-432`)。更新检查使用的正是共享 remote client(UI `app_update.py:310-330`),所以若退出恰逢检查请求,`aboutToQuit -> shutdown -> client.close()` 可能在 GUI 线程等待请求超时/重试结束。强制对话框的原始检查通常已经返回,但 session 仍应在最终 shutdown 时先递增 generation,使迟到的检查结果绝不能再创建窗口。
|
||||||
|
|
||||||
|
`aboutToQuit` 已经处于事件循环退出阶段,不适合再启动“取消后等 finished signal”的异步流程;finished queued signal 可能已没有下一轮事件可处理。因此必须在点击“退出软件”时先完成 quiesce,再真正调用 `quit()`。
|
||||||
|
|
||||||
|
## 建议的最小实现
|
||||||
|
|
||||||
|
### A. 对话框只发意图,不自行退出
|
||||||
|
|
||||||
|
在 `AppUpdateDialog` 增加独立信号 `exit_requested = Signal()` 和按钮:
|
||||||
|
|
||||||
|
- 文案为“退出软件”,仅 `offer.force` 时显示;非强制更新继续使用“稍后提醒/取消下载”。
|
||||||
|
- 强制更新即使 `_busy=True` 也保持该按钮可用,因为这正是下载中唯一的离开路径。
|
||||||
|
- 点击后只 emit;session 接管状态转换。对话框增加 `set_exiting()`,禁用所有按钮、显示“正在停止更新并退出…”,防止双击。
|
||||||
|
- `closeEvent()` 和 Escape 的现有强制拦截继续保留。不要让窗口标题栏关闭绕开协调器。
|
||||||
|
- 一旦外部安装助手已经成功启动,进入不可逆的 `APPLY_COMMITTED` 状态,禁用“退出软件”;此后退出必然表示“退出并安装”。
|
||||||
|
|
||||||
|
### B. 用 `threading.Event` 和活动操作身份建立闭环
|
||||||
|
|
||||||
|
`AppUpdateSession` 最少需要以下 GUI 线程状态:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._cancel_event = Event()
|
||||||
|
self._active_install_signals: _TaskSignals | None = None
|
||||||
|
self._exit_requested = False
|
||||||
|
self._quit_when_idle: Callable[[], None] | None = None
|
||||||
|
self._apply_committed = False
|
||||||
|
```
|
||||||
|
|
||||||
|
开始安装准备时 `clear()` event,保存本次 `signals`,并把 `signals.finished` 连到带 `signals` 身份参数的 `_on_install_finished()`。进度、状态、result、error 也不要再直接连接 dialog 方法;统一经过 session handler,并同时验证:
|
||||||
|
|
||||||
|
- `signals is self._active_install_signals`;
|
||||||
|
- dialog 仍是 `self.dialog`;
|
||||||
|
- 未处于 `_exit_requested`(finished handler 除外)。
|
||||||
|
|
||||||
|
这会同时解决迟到回调、旧 dialog 被替换、以及上一次任务影响下一次 `_cancel` 状态的问题。活动安装存在时,`check()`/`_present()` 应拒绝再替换 dialog,避免两个 job 同时删除和使用同一版本 workspace。
|
||||||
|
|
||||||
|
退出请求的最小状态机是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IDLE/PREPARING --点击退出--> EXIT_PENDING
|
||||||
|
EXIT_PENDING --cancel_event.set()--> 等待当前 install signals.finished
|
||||||
|
无活动任务或 finished 到达 --> ApplicationController.request_quit()
|
||||||
|
aboutToQuit --> ApplicationController.shutdown() 最终幂等清理
|
||||||
|
```
|
||||||
|
|
||||||
|
`_finish_install()` 的第一条业务门禁必须是“如果退出已请求、event 已 set、或 signals 已不是当前操作,则直接返回,不调用 `apply_downloaded_update()`”。这是防止“退出反而安装”的关键断言。
|
||||||
|
|
||||||
|
### C. 让 job 在阶段边界观察取消,并给网络读取有限上界
|
||||||
|
|
||||||
|
现有 `download_package(cancelled=...)` 接口无需改变,改传 `self._cancel_event.is_set`。job 至少在以下边界调用统一的 `_raise_if_cancelled()`:
|
||||||
|
|
||||||
|
1. 创建 workspace 前;
|
||||||
|
2. `download_package()` 返回后;
|
||||||
|
3. 安装器校验/zip 解压前;
|
||||||
|
4. 校验/解压后、构造 `_PreparedUpdate` 前。
|
||||||
|
|
||||||
|
同时把 `download_package()` 的 `read=None` 改成有限的“单次读空闲超时”,建议沿用配置的 `request_timeout` 或默认 30 秒。这个 timeout 不是总下载时长:只要持续收到 chunk,大文件仍可继续;服务器停止发数据后,退出等待则有确定上界。
|
||||||
|
|
||||||
|
如果希望解压中点击退出也能很快响应,可把 `safe_extract_zip()` 从一次性 `extractall()` 改为逐 member 提取并在每个 member 前检查同一个 cancel callback。若坚持最小改动,也可以让退出等待当前 `extractall()` 完成,但必须保持 event loop 和 dialog 存活,并在解压后门禁掉安装,不能先 `quit()`。
|
||||||
|
|
||||||
|
### D. 由控制器统一发起真正退出
|
||||||
|
|
||||||
|
在 `ApplicationController` 增加与 `_shutting_down` 分离的 `_quit_requested`,以及幂等 `request_quit()`:
|
||||||
|
|
||||||
|
1. 首次调用时设置 `_quit_requested`;
|
||||||
|
2. 调用 `app_updater.prepare_to_quit(self.application.quit)`;
|
||||||
|
3. updater 无活动安装时立即以 `QTimer.singleShot(0, callback)` 完成;有任务时保存 callback,待该任务 `finished` 后完成;
|
||||||
|
4. 重复调用不做任何事。
|
||||||
|
|
||||||
|
不要提前设置 `_shutting_down`,否则真正触发 `aboutToQuit` 时现有 `shutdown()` 会在 `:1084-1086` 直接返回,跳过资源释放。
|
||||||
|
|
||||||
|
`ApplicationController.shutdown()` 中应在 `_cancel_session_restore()` 之后、关闭视频和 remote client 之前调用幂等的 `self.app_updater.shutdown()`。该方法应:递增 `_generation`、设置 cancel event、清除退出 callback、使所有迟到 callback 失效;它是兜底,不再等待 worker。正常的强制对话框退出路径到这里时,安装准备 worker 已经 finished。
|
||||||
|
|
||||||
|
成功更新也应复用同一完成门:`_finish_install()` 成功启动 helper 后只记录 `_apply_committed=True` 和“任务结束后退出”;由本次 `signals.finished` 再调用控制器的 `request_quit()`。这样可以删除当前依赖经验值的 300 ms 定时退出(UI `app_update.py:501-503`),并明确保证 worker 已离开 `run()`。
|
||||||
|
|
||||||
|
### E. 不建议的实现
|
||||||
|
|
||||||
|
- 不要在退出按钮中调用 `os._exit()`、`terminate()` 或强杀线程;这会绕过 controller 的视频/API 清理,并可能截断 `.part`/日志写入。
|
||||||
|
- 不要在 `aboutToQuit` 中调用 `QThreadPool.globalInstance().waitForDone()`;它会等待整个应用的全局线程池,而不只是更新任务,当前无限 read timeout 还可能让 GUI 永久卡住。
|
||||||
|
- 不要用循环 `processEvents()` 等待 worker;这会允许更新按钮、窗口关闭和 queued result 重入。
|
||||||
|
- 不要只设置现有 `_cancel=True` 后立即 `quit()`;设置取消只是请求,`finished` 才是可退出的确认。
|
||||||
|
|
||||||
|
## 建议补测
|
||||||
|
|
||||||
|
在 `app/tests/test_app_update_ui.py` 现有强制对话框测试(`:68-83`)基础上补:
|
||||||
|
|
||||||
|
1. 强制更新显示“退出软件”,不显示“稍后提醒”,关闭按钮/Escape 仍不能绕过;非强制更新不显示该退出按钮。
|
||||||
|
2. 空闲时点击退出只调用一次 controller `request_quit()`。
|
||||||
|
3. 下载中点击退出会 set event、保持应用运行且不立即调用 `application.quit()`;手工 emit 当前 signals 的 `finished` 后才调用一次。
|
||||||
|
4. 退出请求后再投递 `progress/status/result/error` 均不更新旧 dialog;特别断言 `_finish_install()` 不调用 `apply_downloaded_update()`。
|
||||||
|
5. 模拟“result 已排队但退出点击先处理”的边界,断言不会启动 helper;模拟 result 已先完成 helper 提交,则退出按钮已禁用且最终走“安装后退出”。
|
||||||
|
6. 新 offer 在活动安装期间不会 `deleteLater()` 当前 dialog,也不会启动第二个 workspace job。
|
||||||
|
7. `ApplicationController.shutdown()` 调用 updater shutdown 早于 `remote_repository.client.close()`,并保持二次调用幂等。
|
||||||
|
|
||||||
|
在 `app/tests/test_app_update.py` 的下载测试(现有 `:235-293`)基础上补:
|
||||||
|
|
||||||
|
8. 流式响应在若干 chunk 后设置 `Event`,断言抛取消错误、目标文件和 `.part` 都不存在。
|
||||||
|
9. 下载最后一个 chunk 后、`os.replace()`/job 返回前取消,断言 session 的阶段门禁不会产出可安装结果。
|
||||||
|
10. 读空闲超时为有限值,并被转换为 `AppUpdateError`;避免退出永久等待。
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
最小、低风险的提交顺序是:先加入 session 的 operation token、`Event`、finished 门和 controller `request_quit()`;再加对话框按钮;最后把 read timeout 改为有限值并补阶段取消检查。只有当“退出后 result 绝不会进入 `apply_downloaded_update()`”和“quit 只发生在 finished 以后”两条测试通过,才应开放强制更新下载中的退出按钮。
|
||||||
|
|
||||||
|
## 审阅说明
|
||||||
|
|
||||||
|
- 本次依据工作树当前内容只读分析;生产代码与现有测试均未修改。
|
||||||
|
- 根目录 `AGENTS.md` 已读取;仓库当前不存在其中提到的 `.trellis/` 目录,因此没有额外的 workflow/spec 文件可读。
|
||||||
|
- 工作树原本已有多项未提交修改;本次只新增本研究文档,没有覆盖或回退任何现有改动。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 强制更新“退出软件”Windows 正式包重建与验证结果
|
||||||
|
|
||||||
|
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||||
|
- 工作目录:`D:\web\zyt\app`
|
||||||
|
- 总体结果:成功
|
||||||
|
- 生产源码/测试修改:无(本次仅重建发布产物并新增本报告)
|
||||||
|
- 工作树说明:执行前 `src/doctor_workstation/__init__.py` 及其他生产/测试文件已有用户修改;本次全部保留,未覆盖或回退
|
||||||
|
- Trellis:仓库根目录不存在 `.trellis/`,因此无 Trellis 工作流文件可继续读取
|
||||||
|
|
||||||
|
## 发布配置核对
|
||||||
|
|
||||||
|
重建前后均读取 `src/doctor_workstation/__init__.py`,确认:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n "(__version__|DEBUG_MODE)" src/doctor_workstation/__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 输出:`6:__version__ = "1.2.0"`、`10:DEBUG_MODE = False`
|
||||||
|
|
||||||
|
- `__version__ = "1.2.0"`
|
||||||
|
- `DEBUG_MODE = False`
|
||||||
|
|
||||||
|
最终 frozen 主程序的 Windows 版本资源也复核为:
|
||||||
|
|
||||||
|
- `FileVersion = 1.2.0`
|
||||||
|
- `ProductVersion = 1.2.0`
|
||||||
|
|
||||||
|
最终安装器的 `ProductVersion = 1.2.0`。
|
||||||
|
|
||||||
|
## 正式包重建
|
||||||
|
|
||||||
|
执行命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:`Windows package complete.`
|
||||||
|
- Python 构建环境:PyInstaller 6.22.0 / Python 3.12.12
|
||||||
|
- 视频伴侣构建:通过,Vite 转换 43 个模块
|
||||||
|
- frozen Qt 多媒体文件门禁:通过
|
||||||
|
- frozen Qt 多媒体 smoke(`--media-smoke-test`,隔离 offscreen 环境):通过
|
||||||
|
- frozen 应用入口 smoke(`--smoke-test`,隔离 offscreen 环境):通过
|
||||||
|
- ZIP:7-Zip 报告 `Everything is Ok`
|
||||||
|
- 安装器:Inno Setup 6.7.3 编译成功,`Successful compile (140.609 sec)`
|
||||||
|
|
||||||
|
构建期间有非阻断警告:Vite 报告单个压缩后 chunk 超过 500 kB;PyInstaller 报告一个 Qt QML 插件二进制缺失及若干 Windows 系统 DLL 解析警告。脚本内置的 frozen 文件门禁与两个 smoke gate 均通过,构建最终退出码为 `0`。
|
||||||
|
|
||||||
|
## 最终安装器冒烟验证
|
||||||
|
|
||||||
|
执行命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer 'D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||||
|
- 隔离验证目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-55b6feebaa134d21ba0637549d55355b`
|
||||||
|
- 覆盖范围:安装器品牌图标、静默安装、已安装 EXE 存在性与启动 smoke、静默卸载、卸载后主程序残留检查
|
||||||
|
|
||||||
|
## 产物与独立校验
|
||||||
|
|
||||||
|
使用以下命令模式逐项独立复核,退出码为 `0`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Item -LiteralPath <产物绝对路径>
|
||||||
|
Get-FileHash -LiteralPath <产物绝对路径> -Algorithm SHA256
|
||||||
|
```
|
||||||
|
|
||||||
|
| 产物 | 绝对路径 | 字节数 | SHA-256 |
|
||||||
|
| --- | --- | ---: | --- |
|
||||||
|
| 正式安装器 EXE | `D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,907,874 | `71B4ADF7B431A3BBC53818AA2D68089977D777D18F0969C930A0A6B1777F2BA9` |
|
||||||
|
| 发布 ZIP | `D:\web\zyt\app\dist\DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,817,779 | `D41C0794568A59FB57A083425750F4A2CDBED9962B13D9EBBD42FC2EEF374604` |
|
||||||
|
| frozen 主程序 EXE | `D:\web\zyt\app\dist\DoctorWorkstation\DoctorWorkstation.exe` | 6,300,842 | `550BF4434C6B2BEEB9DDA5A78107FB392C4FF442A6D62D01803A51197E59A893` |
|
||||||
|
| 校验清单 | `D:\web\zyt\app\dist\SHA256SUMS.txt` | 220 | `9BD4CB7CD0A1FA7FBBED0E6D38BE2A4F98898224A6B97E0E38F35701CB8B8A20` |
|
||||||
|
|
||||||
|
`dist\SHA256SUMS.txt` 内容:
|
||||||
|
|
||||||
|
```text
|
||||||
|
71B4ADF7B431A3BBC53818AA2D68089977D777D18F0969C930A0A6B1777F2BA9 DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||||
|
D41C0794568A59FB57A083425750F4A2CDBED9962B13D9EBBD42FC2EEF374604 DoctorWorkstation-Windows-x64-1.2.0.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
独立复算的安装器与 ZIP 哈希均与脚本末尾输出及 `SHA256SUMS.txt` 逐字符一致。
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# 强制更新“退出软件”回归测试设计
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
建议在 `app/tests/test_app_update_ui.py` 把“退出软件”作为强制更新对话框的独立显式动作测试,不把它等同于关闭窗口或 `reject()`:
|
||||||
|
|
||||||
|
- 强制更新在初始状态和下载中状态都显示且启用“退出软件”。
|
||||||
|
- 点击只发出一次专用信号(下文假定为 `exit_requested`);对话框自身不静默 `reject()`。
|
||||||
|
- 普通更新仍显示“稍后提醒”,不显示“退出软件”,原有 `update_deferred` + `reject()` 行为不变。
|
||||||
|
- 强制更新无论初始还是下载中,标题栏关闭和 Escape 都继续被拦截;用户只能通过明确的“退出软件”动作退出。
|
||||||
|
|
||||||
|
生产实现若采用独立控件,建议公开 `exit_button`;这比把强制退出语义塞进现有 `later_button` 更容易测试,也避免 `_defer()` 同时承担“稍后”和“退出”两种相反行为。若实现选择复用 `later_button`,下述断言可把 `exit_button` 替换为该控件,但至少应保留独立的 `exit_requested` 信号。
|
||||||
|
|
||||||
|
## 当前覆盖缺口
|
||||||
|
|
||||||
|
当前 `test_app_update_ui.py` 有以下相关覆盖:
|
||||||
|
|
||||||
|
- `test_optional_update_dialog_allows_later` 只断言普通更新的稍后按钮可见和更新文案,未点击按钮,也未验证 `update_deferred`。
|
||||||
|
- `test_forced_update_dialog_hides_defer_and_blocks_escape` 断言稍后按钮隐藏,并用 `dialog.close()` 验证强更无法关闭;尽管测试名写有 `blocks_escape`,测试体没有发送 Escape。
|
||||||
|
- 没有覆盖 `set_busy(True)`。当前 `set_busy()` 会禁用 `later_button`,因此若复用该按钮显示“退出软件”,下载中会直接回归为不可退出。
|
||||||
|
- 没有覆盖退出信号的次数,也没有证明显式退出动作不会被当成普通 `reject()`。
|
||||||
|
|
||||||
|
现有 service 测试 `app/tests/test_app_update.py` 主要覆盖 offer 解析、下载、校验与更新应用,不适合承载 Qt 按钮和键盘行为;这些回归应继续留在 `test_app_update_ui.py`。
|
||||||
|
|
||||||
|
## 建议测试矩阵
|
||||||
|
|
||||||
|
| offer | 对话框状态 | 稍后按钮 | 退出按钮 | 更新按钮 | 取消下载 | 关闭 / Escape |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 强制 | 初始 | 隐藏 | 显示、启用 | 启用 | 隐藏 | 均拦截 |
|
||||||
|
| 强制 | 下载中 | 隐藏 | 显示、启用 | 禁用 | 隐藏 | 均拦截 |
|
||||||
|
| 普通 | 初始 | 显示、启用,文案“稍后提醒” | 隐藏 | 启用 | 隐藏 | 允许 |
|
||||||
|
| 普通 | 下载中 | 保持现有禁用语义 | 隐藏 | 禁用 | 显示 | `closeEvent` 目前拦截;本次不要顺带定义 Escape 新语义 |
|
||||||
|
|
||||||
|
最后一格存在现有 Qt 行为不对称:普通更新下载中时 `closeEvent()` 会拦截标题栏关闭,但 `keyPressEvent()` 仅专门拦截强制更新的 Escape。除非产品需求明确要求调整普通更新下载中的 Escape,否则本次回归不要无意固化或改变该行为。
|
||||||
|
|
||||||
|
## 推荐测试拆分
|
||||||
|
|
||||||
|
测试文件增加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. 强制更新在初始和下载中均可显式退出
|
||||||
|
|
||||||
|
用参数化覆盖两个状态,避免只测初始渲染:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"])
|
||||||
|
def test_forced_update_exit_action_stays_available(
|
||||||
|
busy: bool,
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
dialog = AppUpdateDialog(_offer(force=True))
|
||||||
|
dialog.show()
|
||||||
|
if busy:
|
||||||
|
dialog.set_busy(True)
|
||||||
|
dialog.show_download_progress(256, 1024)
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
assert not dialog.later_button.isVisible()
|
||||||
|
assert dialog.exit_button.isVisible()
|
||||||
|
assert dialog.exit_button.isEnabled()
|
||||||
|
assert dialog.exit_button.text() == "退出软件"
|
||||||
|
assert dialog.update_button.isEnabled() is (not busy)
|
||||||
|
assert not dialog.cancel_button.isVisible()
|
||||||
|
|
||||||
|
dialog.hide()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
```
|
||||||
|
|
||||||
|
这里必须在 `set_busy(True)` 后断言,才能捕获“统一禁用底部按钮”导致强制更新无法退出的回归。调用 `show_download_progress()` 同时让测试更贴近真实 `_start_install()` 顺序:先 `set_busy(True)`,再进入下载进度态。
|
||||||
|
|
||||||
|
### 2. 点击退出按钮只发一次专用信号
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_forced_update_exit_button_emits_request(
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
dialog = AppUpdateDialog(_offer(force=True))
|
||||||
|
requested: list[bool] = []
|
||||||
|
dialog.exit_requested.connect(lambda: requested.append(True))
|
||||||
|
dialog.show()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
dialog.exit_button.click()
|
||||||
|
|
||||||
|
assert requested == [True]
|
||||||
|
assert dialog.isVisible()
|
||||||
|
dialog.hide()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
```
|
||||||
|
|
||||||
|
`assert dialog.isVisible()` 有意证明按钮在 dialog 层只表达“请求退出软件”,而不是绕开应用级清理流程直接 `reject()`。真正退出应由 `AppUpdateSession`/应用层的 slot 完成。若最终设计明确由 dialog 自身关闭,则删除这一条,但仍要保留信号次数断言。
|
||||||
|
|
||||||
|
还可把该测试参数化为初始/下载中并在两种状态点击;若测试数量需要控制,则第一个参数化测试负责可用性,第二个测试负责一次信号已足够定位大部分回归。
|
||||||
|
|
||||||
|
### 3. 普通更新仍是“稍后提醒”
|
||||||
|
|
||||||
|
建议增强现有 optional 测试,而不是只检查可见性:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_optional_update_dialog_keeps_defer_action(
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
dialog = AppUpdateDialog(_offer(force=False))
|
||||||
|
deferred: list[bool] = []
|
||||||
|
dialog.update_deferred.connect(lambda: deferred.append(True))
|
||||||
|
dialog.show()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
assert dialog.later_button.isVisible()
|
||||||
|
assert dialog.later_button.isEnabled()
|
||||||
|
assert dialog.later_button.text() == "稍后提醒"
|
||||||
|
assert not dialog.exit_button.isVisible()
|
||||||
|
|
||||||
|
dialog.later_button.click()
|
||||||
|
|
||||||
|
assert deferred == [True]
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
```
|
||||||
|
|
||||||
|
这条会防止实现“退出软件”时误把普通更新的次按钮文案、信号或关闭行为一起改掉。
|
||||||
|
|
||||||
|
### 4. 强制更新明确拦截关闭与 Escape
|
||||||
|
|
||||||
|
把当前名不副实的测试改成真实事件测试,并参数化初始/下载中:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"])
|
||||||
|
def test_forced_update_only_allows_explicit_exit(
|
||||||
|
busy: bool,
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
dialog = AppUpdateDialog(_offer(force=True))
|
||||||
|
dialog.show()
|
||||||
|
if busy:
|
||||||
|
dialog.set_busy(True)
|
||||||
|
dialog.show_download_progress(256, 1024)
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
dialog.close()
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.isVisible()
|
||||||
|
|
||||||
|
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.isVisible()
|
||||||
|
|
||||||
|
dialog.hide()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
```
|
||||||
|
|
||||||
|
行为断言比检查 window flags 更稳定:不同平台可能规范化窗口标志,但 `closeEvent()`/`keyPressEvent()` 是否真正保留对话框才是用户可观察契约。
|
||||||
|
|
||||||
|
## 会话层边界
|
||||||
|
|
||||||
|
dialog 信号测试只能证明点击请求已发出,不能证明应用最终退出。生产接线还应满足:
|
||||||
|
|
||||||
|
- `AppUpdateSession._present()` 连接 `exit_requested` 到一个应用级退出入口。
|
||||||
|
- 下载中退出时先设置取消标志,使 `download_package(..., cancelled=...)` 尽快结束并清理 `.part` 文件,再请求 `QApplication.quit()`;否则全局线程池任务可能拖延进程退出。
|
||||||
|
- 应用级退出必须走既有 `QApplication.aboutToQuit -> ApplicationController.shutdown`,不要从 dialog 直接调用 `sys.exit()` 或跳过资源清理。
|
||||||
|
|
||||||
|
若实现为可替换的 session 方法(例如 `_request_exit()`),可另补一个 session 单测,mock/monkeypatch 该方法后验证 dialog 信号接线;不要在 pytest 共享的真实 `QApplication` 上直接调用 `quit()`,以免污染同进程后续 UI 测试。本次题目明确要求的四项回归,以上 dialog 测试已经可以独立、稳定覆盖。
|
||||||
|
|
||||||
|
## 验证记录
|
||||||
|
|
||||||
|
- 已读取根 `AGENTS.md`;工作树中不存在 `.trellis/workflow.md` 和 `.trellis/spec/`,因此无法应用额外 Trellis 分层规范。
|
||||||
|
- 只读运行现有基线:`app/.venv/Scripts/python.exe -m pytest tests/test_app_update_ui.py -q`,结果 `4 passed`。
|
||||||
|
- 本文之外未修改生产代码或测试代码;工作树中原有的 `app_update.py` 与 `test_app_update_ui.py` 未提交改动均已保留。
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Windows 1.2.0 正式打包结果
|
||||||
|
|
||||||
|
- 打包日期:2026-08-28(Asia/Shanghai)
|
||||||
|
- 工作目录:`D:\web\zyt\app`
|
||||||
|
- 版本源:`src/doctor_workstation/__init__.py`
|
||||||
|
- 确认版本:`1.2.0`
|
||||||
|
- 执行命令:`powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1`
|
||||||
|
- 脚本退出码:`0`
|
||||||
|
- 总体结果:成功
|
||||||
|
|
||||||
|
## 发布产物核对
|
||||||
|
|
||||||
|
| 文件 | 大小(字节) | 大小(MiB) | SHA-256 |
|
||||||
|
| --- | ---: | ---: | --- |
|
||||||
|
| `dist/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,914,042 | 155.367 | `1D74966B73005B30ECB4EB7E2101BECB57BB8C91D0981392ACD00F6800D37E7D` |
|
||||||
|
| `dist/DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,810,707 | 220.118 | `A56057D95EFCE70E58FA54264CAC17A16E2F5BDB842FE515A5A153B073A4AFE9` |
|
||||||
|
| `dist/SHA256SUMS.txt` | 220 | 0.000 | `08BD69A21BACEC86B8269ED665C50CDAF4D3ED6D252CEC4DD7BEE30E73573175` |
|
||||||
|
|
||||||
|
以上三个文件均存在。独立使用 `Get-FileHash -Algorithm SHA256` 重新计算 EXE 和 ZIP 哈希,结果与打包脚本末尾输出及 `SHA256SUMS.txt` 中的两条记录逐项一致。
|
||||||
|
|
||||||
|
## 脚本验证结果
|
||||||
|
|
||||||
|
- 锁定的 Python 构建依赖检查通过。
|
||||||
|
- 视频伴侣依赖安装成功,`vue-tsc --noEmit && vite build` 成功;Vite 共转换 43 个模块。
|
||||||
|
- PyInstaller 6.22.0 / Python 3.12.12 构建成功,输出 `dist/DoctorWorkstation`。
|
||||||
|
- `Frozen Qt multimedia file gate passed.`
|
||||||
|
- `Frozen Qt multimedia smoke gate passed (--media-smoke-test, isolated offscreen mode).`
|
||||||
|
- `Frozen application entry smoke gate passed (--smoke-test, isolated offscreen mode).`
|
||||||
|
- 7-Zip 创建 ZIP 成功,输出 `Everything is Ok`;归档包含 180 个目录、3,011 个文件。
|
||||||
|
- Inno Setup 6.7.3 编译成功,输出 `Successful compile (165.062 sec)`。
|
||||||
|
- 正式安装包、ZIP 和校验清单均生成,脚本最终退出码为 0。
|
||||||
|
|
||||||
|
## 非阻断警告
|
||||||
|
|
||||||
|
- Vite 报告单个压缩后 chunk 超过 500 kB,仅为体积优化提示。
|
||||||
|
- PyInstaller 报告一个 Qt QML 插件二进制缺失,以及若干 Windows 系统 DLL 解析警告;这些警告未阻断构建,且脚本内置的多媒体文件门禁、多媒体离屏 smoke test 和应用入口 smoke test 全部通过。
|
||||||
|
|
||||||
|
## 真实安装器冒烟测试
|
||||||
|
|
||||||
|
- 执行命令:`powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer 'D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe'`
|
||||||
|
- 脚本退出码:`0`
|
||||||
|
- 脚本最终结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||||
|
- 隔离测试目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-ac84581babe94e28a0d0ffab63dd0b5f`
|
||||||
|
|
||||||
|
### 分阶段核对
|
||||||
|
|
||||||
|
- 静默安装:通过。安装器以 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER` 运行到隔离目录;`setup.log` 记录 `Installation process succeeded.` 和 `Need to restart Windows? No`。
|
||||||
|
- 安装内容:通过。脚本在启动前确认 `DoctorWorkstation.exe` 与 `unins000.exe` 均存在。
|
||||||
|
- 图标:通过。脚本分别提取安装器、已安装主程序及卸载器的 32×32 关联图标并计算 SHA-256;主程序与卸载器图标均和安装器品牌图标一致,否则脚本会失败。
|
||||||
|
- 启动 smoke:通过。已安装主程序在隔离配置、日志目录与 `QT_QPA_PLATFORM=offscreen` 环境下执行 `--smoke-test`,退出码为 0;应用日志记录 `doctor workstation starting`,未记录异常堆栈。
|
||||||
|
- 静默卸载:通过。卸载器以 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART` 运行,退出码为 0;`uninstall.log` 记录 `Uninstallation process succeeded.`、`Removed all? Yes` 和 `Need to restart Windows? No`。
|
||||||
|
- 残留检查:通过。卸载完成后独立确认隔离安装目录、`DoctorWorkstation.exe`、`unins000.exe`、当前用户开始菜单快捷方式及本次当前用户卸载注册表键均不存在。隔离测试根目录按设计保留,仅包含安装/卸载日志和隔离应用日志,便于审计。
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 医生工作站版本发布配置与更新 API 诊断
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
当前仓库中,admin、server、app 三端的**现行契约是一致的**,但字段名不是扁平的
|
||||||
|
`package_type` / `download_url`。正式 wire contract 是:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1,
|
||||||
|
"data": {
|
||||||
|
"has_update": true,
|
||||||
|
"force": false,
|
||||||
|
"enabled": true,
|
||||||
|
"current_version": "1.1.0",
|
||||||
|
"latest_version": "1.2.0",
|
||||||
|
"min_version": "",
|
||||||
|
"title": "...",
|
||||||
|
"notes": "...",
|
||||||
|
"platform": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"package": {
|
||||||
|
"url": "https://.../DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||||
|
"sha256": "64 位十六进制值",
|
||||||
|
"size": 123,
|
||||||
|
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||||
|
"type": "inno_setup"
|
||||||
|
},
|
||||||
|
"can_install": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
- `latest_version` 是 `data` 下的顶层字段。
|
||||||
|
- `platform`、`arch` 是检测请求参数,同时在 `data` 下回显规范化后的值;它们不保存在发布配置中。
|
||||||
|
- 安装包对象叫 `package`。
|
||||||
|
- 安装包类型叫 `package.type`,不是顶层或同级的 `package_type`。
|
||||||
|
- 下载地址叫 `package.url`,不是 `download_url`。
|
||||||
|
- 哈希叫 `package.sha256`。
|
||||||
|
- 如果截图或线上响应实际出现的是扁平 `package_type`、`download_url`,当前 app 不会读取这些别名。这不是当前仓库 server 的输出,优先怀疑线上后端/代理为另一版本或只部署了部分提交。
|
||||||
|
|
||||||
|
当前工作区还存在一个已确认的发布物版本风险:运行时版本源已经是 `1.2.0`(`app/src/doctor_workstation/__init__.py:5-6`),但 `app/dist/SHA256SUMS.txt:1-2` 只登记了 `1.1.0` 的 EXE/ZIP,且本地 `DoctorWorkstation.exe` 文件版本也是 `1.1.0`。如果管理端把 `latest_version` 设为 `1.2.0`,却填入当前 `1.1.0` 安装包,客户端安装后仍会报告 `1.1.0`,下次启动会再次发现 `1.2.0`,形成重复升级提示。打包脚本明确从同一个 `__version__` 读取版本(`app/scripts/package_windows.ps1:68-76`),并用它生成 EXE/ZIP 名称(`:169-174`)及两者哈希(`:234-244`);发布前必须重新生成 `1.2.0` 产物。
|
||||||
|
|
||||||
|
> 本轮只读诊断没有修改生产代码。根目录 `AGENTS.md` 已读取;仓库中没有 `.trellis/` 目录。
|
||||||
|
|
||||||
|
## 1. Windows 64 位安装包字段如何进入保存请求
|
||||||
|
|
||||||
|
管理端类型定义把 Windows 包放在 `packages.windows_x64`,每个包固定包含
|
||||||
|
`url`、`sha256`、`size`、`filename`、`type`;其中 `type` 只允许
|
||||||
|
`archive | inno_setup`(`admin/src/api/setting/desktop_workstation.ts:3-24`)。保存接口是
|
||||||
|
`POST /setting.desktop_workstation/setConfig`(同文件 `:27-34`)。
|
||||||
|
|
||||||
|
页面的 Windows 区块来自平台键 `windows_x64`(`admin/src/views/setting/desktop_workstation/index.vue:196-224`),默认类型是 `inno_setup`(`:198-218`)。UI 字段与请求体的对应关系如下:
|
||||||
|
|
||||||
|
| 截图/UI 字段 | 保存请求字段 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows 64 位安装包 | `packages.windows_x64` | `index.vue:221-224, 339-343` |
|
||||||
|
| 安装包类型 | `packages.windows_x64.type`,EXE 为 `inno_setup` | `index.vue:96-104, 305-309` |
|
||||||
|
| 安装包地址 | `packages.windows_x64.url` | `index.vue:113-123, 318-324` |
|
||||||
|
| SHA-256 | `packages.windows_x64.sha256` | `index.vue:144-150, 292-315` |
|
||||||
|
| 文件名 | `packages.windows_x64.filename` | `index.vue:152-162, 300-304` |
|
||||||
|
| 文件大小(字节) | `packages.windows_x64.size` | `index.vue:164-174, 300-304` |
|
||||||
|
| 最新版本号 | 顶层 `latest_version` | `index.vue:34-42, 330-344` |
|
||||||
|
|
||||||
|
选择文件后,页面在浏览器本地读取原始文件名和字节数,`.exe` 自动切换为
|
||||||
|
`inno_setup`,并用 Web Crypto 计算 SHA-256(`index.vue:292-315`)。上传成功后只把上传接口返回的 `data.uri`(次选 `data.url`)写入包的 `url`(`:318-328`)。最终点击保存时,页面显式组装三个平台的完整 `packages` 对象,而不是上传后自动发布(`:330-345`)。
|
||||||
|
|
||||||
|
虽然 API 封装调用写成 `request.post({ params })`,拦截器会在 POST 且没有 `data` 时把
|
||||||
|
`params` 移入 JSON body(`admin/src/utils/request/index.ts:20-38`),所以 PHP 收到的是上述嵌套 JSON,而不是查询字符串。
|
||||||
|
|
||||||
|
## 2. 后端如何校验和持久化
|
||||||
|
|
||||||
|
控制器用 POST 校验器接收请求,再交给逻辑层保存(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:38-46`)。
|
||||||
|
|
||||||
|
正常管理端保存时的关键约束:
|
||||||
|
|
||||||
|
- 版本号需是纯数字分段格式(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:48-57`)。
|
||||||
|
- 包类型只允许 `archive` / `inno_setup`,且 `inno_setup` 只允许 Windows x64(`:118-137`)。
|
||||||
|
- Inno Setup 若填写文件名,必须以 `.exe` 结尾(`:138-140`)。
|
||||||
|
- 显式 `http://` 的 Inno Setup 地址会被拒绝(`:141-143`)。
|
||||||
|
- 外部 http(s) 地址必须带 SHA-256,SHA-256 若非空必须是 64 位十六进制(`:144-152`)。
|
||||||
|
- 文件名最长 180 字节,size 必须是非负数(`:153-158`)。
|
||||||
|
|
||||||
|
逻辑层将标量分别保存为配置项,把所有平台包作为一个 `packages` 配置项保存:
|
||||||
|
|
||||||
|
- `enabled`
|
||||||
|
- `latest_version`
|
||||||
|
- `min_version`
|
||||||
|
- `force_update`
|
||||||
|
- `title`
|
||||||
|
- `notes`
|
||||||
|
- `packages`
|
||||||
|
|
||||||
|
证据为 `server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:45-55`。其中
|
||||||
|
`latest_version` / `min_version` 会被正规化为三段版本,包则逐平台正规化
|
||||||
|
`url/sha256/size/filename/type`(`:180-205, 226-254`)。
|
||||||
|
|
||||||
|
`ConfigService::set()` 对数组执行 `json_encode(..., JSON_UNESCAPED_UNICODE)` 后写入 Config 模型的 `value` 字段;标量直接写入(`server/app/common/service/ConfigService.php:32-50`)。因此数据库中的逻辑形态是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type = desktop_workstation, name = latest_version, value = "1.2.0"
|
||||||
|
type = desktop_workstation, name = packages, value =
|
||||||
|
{"windows_x64":{"url":"...","sha256":"...","size":...,"filename":"...","type":"inno_setup"},...}
|
||||||
|
```
|
||||||
|
|
||||||
|
读取时,`ConfigService::get()` 会对合法 JSON 自动 `json_decode(..., true)`(同文件
|
||||||
|
`:65-85`),所以 `packages` 回到 PHP 数组。保存 URL 时会去掉当前站点/当前存储域名,读取给 API 时再补回绝对域名(`DesktopWorkstationLogic.php:231-252, 261-280`;`server/app/common/service/FileService.php:42-59, 69-78`)。本地 `uploads/...` 文件还会在缺失/无效时由 server 计算哈希、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
|
||||||
|
|
||||||
|
## 3. 检测 API 如何选择包和序列化响应
|
||||||
|
|
||||||
|
app 请求的端点是 `setting.desktop_workstation/check`(`app/src/doctor_workstation/services/app_update.py:29-35`)。控制器把 `check` 放进免登录列表(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:26-29`),并把 GET 参数直接交给逻辑层(`:48-55`)。
|
||||||
|
|
||||||
|
客户端发送:
|
||||||
|
|
||||||
|
```text
|
||||||
|
current_version=<当前运行时版本>&platform=windows&arch=x64
|
||||||
|
```
|
||||||
|
|
||||||
|
证据为 `app_update.py:217-243`。server 将 `windows/win/win32/win64` 统一成
|
||||||
|
`windows`,把 `amd64/x86_64/x64` 统一成 `x64`,拼成配置键
|
||||||
|
`windows_x64`(`DesktopWorkstationLogic.php:125-153`)。也就是说,`platform` / `arch`
|
||||||
|
不是管理端发布字段,而是由客户端运行环境发给检测接口、用于选择
|
||||||
|
`packages.windows_x64` 的请求维度。
|
||||||
|
|
||||||
|
server 的检测响应由 `evaluate()` 直接组成(`DesktopWorkstationLogic.php:75-103`):
|
||||||
|
|
||||||
|
- `latest_version` 来自已保存的配置并正规化。
|
||||||
|
- `platform`、`arch` 是请求值正规化后的回显。
|
||||||
|
- `package` 是匹配平台的单个包,只有 `url` 和 `sha256` 都非空才返回,否则为 `null`。
|
||||||
|
- 包对象的键为 `url/sha256/size/filename/type`(`:272-280`)。
|
||||||
|
- `can_install = has_update && url 非空 && sha256 非空`。
|
||||||
|
- `force` 只有存在更新、命中强制策略并且有可安装包时才为 true。
|
||||||
|
|
||||||
|
控制器的 `data()` 最终封装为 `{code, show, msg, data}`(`server/app/common/controller/BaseLikeAdminController.php:50-60`;`server/app/common/service/JsonService.php:71-91`)。app 的 `ApiClient` 对 `code == 1` 返回 envelope 中的 `data`(`app/src/doctor_workstation/services/api_client.py:504-542`),因此 `parse_update_offer()` 收到的就是上面列出的 `data` 对象,而不是整个 envelope。
|
||||||
|
|
||||||
|
## 4. 与 app 客户端契约逐字段对照
|
||||||
|
|
||||||
|
| 语义 | server 实际输出 | app 实际读取 | 是否一致 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 最新版本 | `latest_version` | `data.get("latest_version")` | 一致(`DesktopWorkstationLogic.php:95`; `app_update.py:174-181`) |
|
||||||
|
| 平台 | `platform` | `data.get("platform")` | 一致(server `:99`; app `:147-150, 176-183`) |
|
||||||
|
| 架构 | `arch` | `data.get("arch")` | 一致(server `:100`; app `:147-150, 176-183`) |
|
||||||
|
| 安装包 | `package` object/null | `data.get("package")` | 一致(server `:101`; app `:151-153`) |
|
||||||
|
| 下载地址 | `package.url` | `package_payload.get("url")` | 一致(server `:275`; app `:154, 166-173`) |
|
||||||
|
| SHA-256 | `package.sha256` | `package_payload.get("sha256")` | 一致(server `:276`; app `:155, 184-188`) |
|
||||||
|
| 包类型 | `package.type` | `package_payload.get("type")` | 一致(server `:279`; app `:157-173`) |
|
||||||
|
| 文件大小 | `package.size` | `package_payload.get("size")` | 一致(server `:277`; app `:158-173`) |
|
||||||
|
| 文件名 | `package.filename` | `package_payload.get("filename")` | 一致(server `:278`; app `:156-173`) |
|
||||||
|
| 可安装 | `can_install` | `data.get("can_install")` + 客户端二次校验 | 一致但客户端更严格(server `:85-102`; app `:184-200`) |
|
||||||
|
|
||||||
|
客户端只认可 `archive` / `inno_setup`,且 Inno 只允许 Windows;它还要求 SHA-256
|
||||||
|
严格为 64 位小写十六进制、响应平台/架构必须与请求一致(`app_update.py:162-200`)。对于
|
||||||
|
`inno_setup`,下载 URL 还必须是 HTTPS(localhost 调试例外),随后下载内容要通过 SHA-256、size、`.exe` 后缀和 PE `MZ` 头校验(`:287-300, 360-397`)。UI 根据 `package.type` 分流:`inno_setup` 直接走 Windows 安装器,`archive` 则按 ZIP 解压(`app/src/doctor_workstation/ui/dialogs/app_update.py:423-449`)。
|
||||||
|
|
||||||
|
现有自动化也明确锁定了这个嵌套契约:server contract test 要求 Windows 包返回
|
||||||
|
`package.type == inno_setup`(`server/tests/DesktopWorkstationUpdateContractTest.php:34-62`);app test 用
|
||||||
|
`package.{url,sha256,size,filename,type}` 构造响应并验证接收(`app/tests/test_app_update.py:66-90`)。本轮实跑:
|
||||||
|
|
||||||
|
```text
|
||||||
|
php server/tests/DesktopWorkstationUpdateContractTest.php PASS
|
||||||
|
uv run pytest app/tests/test_app_update.py -q PASS (21 tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 根因候选(按优先级)
|
||||||
|
|
||||||
|
### A. `latest_version` 与实际安装包版本不一致(当前工作区已有直接证据)
|
||||||
|
|
||||||
|
当前版本源是 `1.2.0`,但现有 EXE/ZIP、SHA256SUMS 和冻结 exe 都是 `1.1.0`。如果截图中的管理端配置已经把最新版本发布为 `1.2.0`,当前 `1.1.0` 包不能作为它的安装包。表现为下载、安装可能成功,但应用重启后仍是旧版本并再次提示更新。
|
||||||
|
|
||||||
|
### B. 线上响应使用 `package_type` / `download_url` 扁平字段
|
||||||
|
|
||||||
|
当前 app 没有这两个 wire key 的兼容读取,仓库内也没有生成它们的 server 代码。如果截图中的实际网络响应是例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"latest_version":"1.2.0","package_type":"inno_setup","download_url":"...","sha256":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
app 会因为没有 `package.url` 而得到 `package=None`,最终 `can_install=false`;即使把包放在
|
||||||
|
`package` 中但只给 `package_type`,客户端也会默认当成 `archive`,对 EXE 执行 ZIP 解压并失败。该情形应视为明确的协议不一致。
|
||||||
|
|
||||||
|
### C. admin / server / app 部署版本分叉,或 PHP OPcache 未刷新
|
||||||
|
|
||||||
|
Git 历史显示提交 `43e5411b6a8d2e625140c5dca8ddeb8492ba7daa` 才同步把
|
||||||
|
`type=inno_setup` 加入 admin、server 和 app。它之前的 server 会在保存/读取包时丢掉 `type`。
|
||||||
|
因此“管理页面已有 Inno Setup 下拉框,但 check 响应没有 `package.type`”最符合部分部署或旧 PHP 代码仍在运行,而不是当前源码的逻辑错误。
|
||||||
|
|
||||||
|
### D. SHA-256 非空但无效,server 与 app 的可安装判定强度不同
|
||||||
|
|
||||||
|
`evaluate()` 只检查 URL/哈希非空;app 要求恰好 64 位十六进制。通过正常管理端保存不会发生,因为 validator 会拦截;但旧数据、手工改库、另一服务写入配置时,可能出现 server 返回 `can_install=true`、app 最终降级为不可安装。
|
||||||
|
|
||||||
|
### E. 相对上传路径在 server 输出时被扩成 HTTP
|
||||||
|
|
||||||
|
validator 只对输入字符串显式以 `http://` 开头的 Inno URL 拒绝;`uploads/...` 相对路径可通过。响应时 `FileService::getFileUrl()` 按 `request()->domain()` 补域名。如果生产位于 HTTPS 反向代理后但 PHP 未正确识别代理协议,响应可能变成 `http://...`。app 会安全地拒绝自动执行这个 EXE。若截图中的 `package.url` 为 HTTP,应核对反向代理的 forwarded proto / trusted proxy 配置,而不是放宽客户端安全校验。
|
||||||
|
|
||||||
|
## 6. 建议修复与验证顺序
|
||||||
|
|
||||||
|
1. **先重新打 1.2.0 正式包再发布。** 保持 `app/src/doctor_workstation/__init__.py`、EXE 的 FileVersion/ProductVersion、安装包文件名、管理端 `latest_version` 四者全部为 `1.2.0`;从新生成的 `SHA256SUMS.txt` 复制 EXE 对应哈希,不要复用 1.1.0 的值。
|
||||||
|
2. **直接抓线上 check 响应。** 用与 app 一样的参数请求:
|
||||||
|
`GET /adminapi/setting.desktop_workstation/check?current_version=1.1.0&platform=windows&arch=x64`。确认有效数据位于 `data`,并且字段精确为 `data.package.url/type/sha256`。
|
||||||
|
3. **如果看到 `download_url/package_type`,统一契约。** 首选修 server 采用当前仓库的嵌套结构并整体部署;若必须兼容历史服务,可在 app 解析层短期接受别名,但 canonical 输出仍应只有 `package.{url,type,...}`,并补契约测试。
|
||||||
|
4. **如果 `package.type` 缺失,做完整部署并清 OPcache。** 同时部署 admin 静态资源、PHP controller/logic/validator 和新 app;不要只替换管理页面。
|
||||||
|
5. **核验 URL 与哈希。** `package.url` 必须是客户端可达的 HTTPS 绝对地址;下载文件 SHA-256 必须与 `package.sha256` 完全一致,size 若填写也必须一致。
|
||||||
|
6. **补一条跨端端到端 fixture。** 固化一个 Windows `inno_setup` 响应,既让 PHP `evaluate()` 产出 JSON,也让 Python `parse_update_offer()` 消费同一 fixture;另外增加扁平别名必须被拒绝(或在决定兼容后明确接受)的测试,避免字段名再次漂移。
|
||||||
|
|
||||||
|
## 最小正确发布样例
|
||||||
|
|
||||||
|
管理端保存体中的 Windows 部分:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": 1,
|
||||||
|
"latest_version": "1.2.0",
|
||||||
|
"min_version": "",
|
||||||
|
"force_update": 0,
|
||||||
|
"title": "医生工作站 1.2.0",
|
||||||
|
"notes": "...",
|
||||||
|
"packages": {
|
||||||
|
"windows_x64": {
|
||||||
|
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||||
|
"sha256": "<新 1.2.0 EXE 的 64 位 SHA-256>",
|
||||||
|
"size": 0,
|
||||||
|
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||||
|
"type": "inno_setup"
|
||||||
|
},
|
||||||
|
"macos_arm64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"},
|
||||||
|
"macos_x64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把同一内容改名为顶层 `download_url` / `package_type`;当前 app 不消费该形态。
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""Read-only-ish timing probe for the desktop update commit sequence.
|
||||||
|
|
||||||
|
The probe imports production code and replaces only its external download/apply
|
||||||
|
edges in memory. It does not modify production sources or existing tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gc
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import weakref
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtCore import QCoreApplication, QEvent, QObject, QThreadPool, QTimer
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.services import app_update as update_service
|
||||||
|
from doctor_workstation.services.app_update import (
|
||||||
|
PACKAGE_TYPE_INNO_SETUP,
|
||||||
|
UpdateOffer,
|
||||||
|
UpdatePackage,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.dialogs import app_update as update_ui
|
||||||
|
|
||||||
|
|
||||||
|
def _record(events: list[dict[str, Any]], name: str, started: float) -> None:
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
"event": name,
|
||||||
|
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||||
|
"thread_id": threading.get_ident(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exercise_session(iterations: int = 25) -> dict[str, Any]:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
main_thread = threading.get_ident()
|
||||||
|
original_edges = {
|
||||||
|
"is_frozen_install": update_ui.is_frozen_install,
|
||||||
|
"frozen_install_root": update_ui.frozen_install_root,
|
||||||
|
"download_package": update_ui.download_package,
|
||||||
|
"apply_downloaded_update": update_ui.apply_downloaded_update,
|
||||||
|
}
|
||||||
|
failures: list[dict[str, Any]] = []
|
||||||
|
samples: list[list[dict[str, Any]]] = []
|
||||||
|
collected_signals = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="zyt-update-commit-") as raw_tmp:
|
||||||
|
temp_root = Path(raw_tmp)
|
||||||
|
install_root = temp_root / "installed"
|
||||||
|
install_root.mkdir()
|
||||||
|
(install_root / "DoctorWorkstation.exe").write_bytes(b"MZ")
|
||||||
|
update_ui.is_frozen_install = lambda: True
|
||||||
|
update_ui.frozen_install_root = lambda: install_root
|
||||||
|
|
||||||
|
for index in range(iterations):
|
||||||
|
run_root = temp_root / f"run-{index}"
|
||||||
|
run_root.mkdir()
|
||||||
|
events: list[dict[str, Any]] = []
|
||||||
|
started = time.perf_counter()
|
||||||
|
|
||||||
|
def fake_download(
|
||||||
|
_url: str,
|
||||||
|
destination: Path,
|
||||||
|
*,
|
||||||
|
_events: list[dict[str, Any]] = events,
|
||||||
|
_started: float = started,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Path:
|
||||||
|
_record(_events, "download_enter", _started)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination.write_bytes(b"MZ" + b"probe")
|
||||||
|
progress = kwargs.get("progress")
|
||||||
|
if callable(progress):
|
||||||
|
progress(7, 7)
|
||||||
|
_record(_events, "download_return", _started)
|
||||||
|
return destination
|
||||||
|
|
||||||
|
def fake_apply(
|
||||||
|
_payload: Path,
|
||||||
|
*,
|
||||||
|
package_type: str,
|
||||||
|
_events: list[dict[str, Any]] = events,
|
||||||
|
_started: float = started,
|
||||||
|
) -> None:
|
||||||
|
assert package_type == PACKAGE_TYPE_INNO_SETUP
|
||||||
|
_record(_events, "apply_enter", _started)
|
||||||
|
_record(_events, "apply_return", _started)
|
||||||
|
|
||||||
|
update_ui.download_package = fake_download
|
||||||
|
update_ui.apply_downloaded_update = fake_apply
|
||||||
|
|
||||||
|
host = QObject()
|
||||||
|
host.config = SimpleNamespace( # type: ignore[attr-defined]
|
||||||
|
config_dir=run_root,
|
||||||
|
verify_ssl=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def request_quit(
|
||||||
|
_events: list[dict[str, Any]] = events,
|
||||||
|
_started: float = started,
|
||||||
|
) -> None:
|
||||||
|
_record(_events, "request_quit", _started)
|
||||||
|
|
||||||
|
host.request_quit = request_quit # type: ignore[attr-defined]
|
||||||
|
session = update_ui.AppUpdateSession(host)
|
||||||
|
offer = UpdateOffer(
|
||||||
|
has_update=True,
|
||||||
|
force=True,
|
||||||
|
enabled=True,
|
||||||
|
current_version="1.0.0",
|
||||||
|
latest_version=f"1.0.{index + 1}",
|
||||||
|
min_version="",
|
||||||
|
title="probe",
|
||||||
|
notes="probe",
|
||||||
|
platform="windows",
|
||||||
|
arch="x64",
|
||||||
|
package=UpdatePackage(
|
||||||
|
url="https://example.invalid/DoctorWorkstation-Setup.exe",
|
||||||
|
sha256="a" * 64,
|
||||||
|
size=7,
|
||||||
|
filename="DoctorWorkstation-Setup.exe",
|
||||||
|
type=PACKAGE_TYPE_INNO_SETUP,
|
||||||
|
),
|
||||||
|
can_install=True,
|
||||||
|
)
|
||||||
|
dialog = update_ui.AppUpdateDialog(offer)
|
||||||
|
session.dialog = dialog
|
||||||
|
|
||||||
|
original_finish = session._finish_install
|
||||||
|
original_finished = session._on_install_finished
|
||||||
|
|
||||||
|
def finish_probe(
|
||||||
|
*args: Any,
|
||||||
|
_events: list[dict[str, Any]] = events,
|
||||||
|
_started: float = started,
|
||||||
|
_original: Any = original_finish,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
_record(_events, "result_slot_enter", _started)
|
||||||
|
_original(*args, **kwargs)
|
||||||
|
_record(_events, "result_slot_return", _started)
|
||||||
|
|
||||||
|
def finished_probe(
|
||||||
|
*args: Any,
|
||||||
|
_events: list[dict[str, Any]] = events,
|
||||||
|
_started: float = started,
|
||||||
|
_original: Any = original_finished,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
_record(_events, "finished_slot_enter", _started)
|
||||||
|
_original(*args, **kwargs)
|
||||||
|
_record(_events, "finished_slot_return", _started)
|
||||||
|
|
||||||
|
session._finish_install = finish_probe # type: ignore[method-assign]
|
||||||
|
session._on_install_finished = finished_probe # type: ignore[method-assign]
|
||||||
|
session._start_install(dialog, offer)
|
||||||
|
signal_ref = weakref.ref(session._active_install_signals)
|
||||||
|
|
||||||
|
deadline = time.perf_counter() + 3.0
|
||||||
|
while time.perf_counter() < deadline:
|
||||||
|
app.processEvents()
|
||||||
|
if any(item["event"] == "request_quit" for item in events):
|
||||||
|
break
|
||||||
|
time.sleep(0.001)
|
||||||
|
QThreadPool.globalInstance().waitForDone(3000)
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
names = [item["event"] for item in events]
|
||||||
|
expected = [
|
||||||
|
"download_enter",
|
||||||
|
"download_return",
|
||||||
|
"result_slot_enter",
|
||||||
|
"apply_enter",
|
||||||
|
"apply_return",
|
||||||
|
"result_slot_return",
|
||||||
|
"finished_slot_enter",
|
||||||
|
"request_quit",
|
||||||
|
"finished_slot_return",
|
||||||
|
]
|
||||||
|
slot_threads = {
|
||||||
|
item["thread_id"]
|
||||||
|
for item in events
|
||||||
|
if item["event"] in {"result_slot_enter", "finished_slot_enter", "request_quit"}
|
||||||
|
}
|
||||||
|
if names != expected or slot_threads != {main_thread}:
|
||||||
|
failures.append(
|
||||||
|
{
|
||||||
|
"iteration": index,
|
||||||
|
"events": events,
|
||||||
|
"main_thread_id": main_thread,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if index < 3:
|
||||||
|
samples.append(events)
|
||||||
|
|
||||||
|
session.deleteLater()
|
||||||
|
dialog.deleteLater()
|
||||||
|
del session, dialog, host
|
||||||
|
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
app.processEvents()
|
||||||
|
gc.collect()
|
||||||
|
app.processEvents()
|
||||||
|
if signal_ref() is None:
|
||||||
|
collected_signals += 1
|
||||||
|
finally:
|
||||||
|
update_ui.is_frozen_install = original_edges["is_frozen_install"]
|
||||||
|
update_ui.frozen_install_root = original_edges["frozen_install_root"]
|
||||||
|
update_ui.download_package = original_edges["download_package"]
|
||||||
|
update_ui.apply_downloaded_update = original_edges["apply_downloaded_update"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"iterations": iterations,
|
||||||
|
"failures": failures,
|
||||||
|
"signals_collected_after_iteration": collected_signals,
|
||||||
|
"main_thread_id": main_thread,
|
||||||
|
"samples": samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def exercise_real_popen() -> dict[str, Any]:
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return {"skipped": f"requires win32, got {sys.platform}"}
|
||||||
|
with tempfile.TemporaryDirectory(prefix="zyt-update-helper-") as raw_tmp:
|
||||||
|
temp_root = Path(raw_tmp)
|
||||||
|
script = temp_root / "probe_helper.ps1"
|
||||||
|
script.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"param(",
|
||||||
|
" [int]$TargetPid, [string]$Installer, [string]$RestartExe,",
|
||||||
|
" [string]$HelperLogFile, [string]$InstallerLogFile",
|
||||||
|
")",
|
||||||
|
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||||
|
"Start-Sleep -Milliseconds 1200",
|
||||||
|
"Set-Content -LiteralPath $HelperLogFile -Value 'child-complete'",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
installer = temp_root / "Setup.exe"
|
||||||
|
installer.write_bytes(b"MZ")
|
||||||
|
restart = temp_root / "DoctorWorkstation.exe"
|
||||||
|
restart.write_bytes(b"MZ")
|
||||||
|
helper_log = temp_root / "helper.log"
|
||||||
|
fixed_log = temp_root / "fixed.log"
|
||||||
|
installer_log = temp_root / "installer.log"
|
||||||
|
original_script = script.read_text(encoding="utf-8-sig")
|
||||||
|
fixed_log_literal = str(fixed_log).replace("'", "''")
|
||||||
|
script.write_text(
|
||||||
|
original_script.replace(
|
||||||
|
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||||
|
"Set-Content -LiteralPath '"
|
||||||
|
+ fixed_log_literal
|
||||||
|
+ "' -Value (\"helper=<{0}> args=<{1}>\" -f $HelperLogFile, ($args -join '|'))\n"
|
||||||
|
+ "Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||||
|
),
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
captured: list[subprocess.Popen[Any]] = []
|
||||||
|
captured_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||||
|
real_popen = update_service.subprocess.Popen
|
||||||
|
|
||||||
|
def capture_popen(*args: Any, **kwargs: Any) -> subprocess.Popen[Any]:
|
||||||
|
captured_calls.append((args, kwargs.copy()))
|
||||||
|
process = real_popen(*args, **kwargs)
|
||||||
|
captured.append(process)
|
||||||
|
return process
|
||||||
|
|
||||||
|
update_service.subprocess.Popen = capture_popen
|
||||||
|
try:
|
||||||
|
started = time.perf_counter()
|
||||||
|
update_service._spawn_inno_setup_applier(
|
||||||
|
script,
|
||||||
|
installer=installer,
|
||||||
|
restart_exe=restart,
|
||||||
|
helper_log_file=helper_log,
|
||||||
|
installer_log_file=installer_log,
|
||||||
|
)
|
||||||
|
returned_ms = round((time.perf_counter() - started) * 1000, 3)
|
||||||
|
finally:
|
||||||
|
update_service.subprocess.Popen = real_popen
|
||||||
|
deadline = time.perf_counter() + 2.5
|
||||||
|
child_log = ""
|
||||||
|
while time.perf_counter() < deadline:
|
||||||
|
if helper_log.exists():
|
||||||
|
child_log = helper_log.read_text(encoding="utf-8").strip()
|
||||||
|
if child_log == "child-complete":
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
return_code = captured[0].poll() if captured else None
|
||||||
|
if captured and return_code is None:
|
||||||
|
return_code = captured[0].wait(timeout=2.0)
|
||||||
|
matrix: dict[str, Any] = {}
|
||||||
|
if captured_calls:
|
||||||
|
command = list(captured_calls[0][0][0])
|
||||||
|
flag_cases = {
|
||||||
|
"zero": 0,
|
||||||
|
"detached": getattr(subprocess, "DETACHED_PROCESS", 0),
|
||||||
|
"new_process_group": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||||
|
"no_window": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
"detached_new_group": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||||
|
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||||
|
"detached_no_window": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||||
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
"new_group_no_window": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
"production_all": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||||
|
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
}
|
||||||
|
running: dict[str, tuple[subprocess.Popen[Any], Path]] = {}
|
||||||
|
helper_parameter = command.index("-HelperLogFile") + 1
|
||||||
|
for name, flags in flag_cases.items():
|
||||||
|
case_command = command.copy()
|
||||||
|
case_log = temp_root / f"matrix-{name}.log"
|
||||||
|
case_command[helper_parameter] = str(case_log)
|
||||||
|
process = real_popen(
|
||||||
|
case_command,
|
||||||
|
close_fds=True,
|
||||||
|
creationflags=flags,
|
||||||
|
cwd=str(temp_root),
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
running[name] = (process, case_log)
|
||||||
|
production_flags = flag_cases["production_all"]
|
||||||
|
detached_flags = flag_cases["detached"]
|
||||||
|
extra_cases = {
|
||||||
|
"production_close_false": (production_flags, False, False),
|
||||||
|
"production_all_devnull": (production_flags, True, True),
|
||||||
|
"detached_close_false": (detached_flags, False, False),
|
||||||
|
"detached_all_devnull": (detached_flags, True, True),
|
||||||
|
}
|
||||||
|
for name, (flags, close_fds, all_devnull) in extra_cases.items():
|
||||||
|
case_command = command.copy()
|
||||||
|
case_log = temp_root / f"matrix-{name}.log"
|
||||||
|
case_command[helper_parameter] = str(case_log)
|
||||||
|
stream_kwargs = (
|
||||||
|
{
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"stdout": subprocess.DEVNULL,
|
||||||
|
"stderr": subprocess.DEVNULL,
|
||||||
|
}
|
||||||
|
if all_devnull
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
process = real_popen(
|
||||||
|
case_command,
|
||||||
|
close_fds=close_fds,
|
||||||
|
creationflags=flags,
|
||||||
|
cwd=str(temp_root),
|
||||||
|
**stream_kwargs,
|
||||||
|
)
|
||||||
|
flag_cases[name] = flags
|
||||||
|
running[name] = (process, case_log)
|
||||||
|
matrix_deadline = time.perf_counter() + 3.0
|
||||||
|
while time.perf_counter() < matrix_deadline:
|
||||||
|
if all(case_log.exists() for _, case_log in running.values()):
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
for name, (process, case_log) in running.items():
|
||||||
|
matrix[name] = {
|
||||||
|
"flags": flag_cases[name],
|
||||||
|
"log_created": case_log.exists(),
|
||||||
|
"return_code": process.poll(),
|
||||||
|
}
|
||||||
|
control_return_code = None
|
||||||
|
control_stdout = ""
|
||||||
|
control_stderr = ""
|
||||||
|
if not fixed_log.exists() and captured_calls:
|
||||||
|
call_args, call_kwargs = captured_calls[0]
|
||||||
|
call_kwargs.update(
|
||||||
|
creationflags=0,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
control = real_popen(*call_args, **call_kwargs)
|
||||||
|
control_stdout, control_stderr = control.communicate(timeout=5.0)
|
||||||
|
control_return_code = control.returncode
|
||||||
|
return {
|
||||||
|
"command": captured_calls[0][0][0] if captured_calls else [],
|
||||||
|
"spawn_returned_ms": returned_ms,
|
||||||
|
"child_completed": child_log == "child-complete",
|
||||||
|
"child_log": child_log,
|
||||||
|
"fixed_log": fixed_log.read_text(encoding="utf-8").strip()
|
||||||
|
if fixed_log.exists()
|
||||||
|
else "",
|
||||||
|
"child_return_code": return_code,
|
||||||
|
"flag_matrix": matrix,
|
||||||
|
"control_return_code": control_return_code,
|
||||||
|
"control_stdout": control_stdout,
|
||||||
|
"control_stderr": control_stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def exercise_controller_quit() -> dict[str, Any]:
|
||||||
|
"""Run the production request_quit method against a real Qt event loop."""
|
||||||
|
|
||||||
|
from doctor_workstation.app import ApplicationController
|
||||||
|
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
events: list[str] = []
|
||||||
|
holder = SimpleNamespace(application=app, _shutting_down=False)
|
||||||
|
app.aboutToQuit.connect(lambda: events.append("aboutToQuit"))
|
||||||
|
QTimer.singleShot(
|
||||||
|
0,
|
||||||
|
lambda: (
|
||||||
|
events.append("request_quit_enter"),
|
||||||
|
ApplicationController.request_quit(holder),
|
||||||
|
events.append("request_quit_return"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
watchdog = QTimer()
|
||||||
|
watchdog.setSingleShot(True)
|
||||||
|
watchdog.timeout.connect(lambda: (events.append("watchdog"), app.quit()))
|
||||||
|
watchdog.start(1000)
|
||||||
|
started = time.perf_counter()
|
||||||
|
return_code = app.exec()
|
||||||
|
return {
|
||||||
|
"events": events,
|
||||||
|
"return_code": return_code,
|
||||||
|
"returned_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
iteration_count = int(sys.argv[1]) if len(sys.argv) > 1 else 25
|
||||||
|
output = {
|
||||||
|
"session": exercise_session(iteration_count),
|
||||||
|
"real_popen": exercise_real_popen(),
|
||||||
|
"controller_quit": exercise_controller_quit(),
|
||||||
|
}
|
||||||
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# AppUpdateSession 更新提交时序复现报告
|
||||||
|
|
||||||
|
日期:2026-08-28
|
||||||
|
环境:Windows、Python 3.12.13、PySide6 6.11.1、uv 0.11.8
|
||||||
|
范围:只读检查生产源码和现有测试;新增的唯一测试资产是
|
||||||
|
`app/research/update_commit_repro.py`,未修改生产源码和既有测试。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
1. **当前工作树中的 `result -> finished -> request_quit` 时序可以稳定复现为正确。**
|
||||||
|
100 次真实 `QThreadPool` 跨线程循环没有一次乱序或丢失:下载/prepare 在 worker
|
||||||
|
线程,`_finish_install()`、`_on_install_finished()` 和 `request_quit()` 都在 GUI 主线程。
|
||||||
|
2. **`_TaskSignals` 不会因局部变量释放而提前消失。** 安装期间它同时被 `_Task`、
|
||||||
|
`AppUpdateSession._signals` 和 `_active_install_signals` 强引用;`finished` 到达后才清空
|
||||||
|
session 引用。探针在主动 `gc.collect()` 后仍观察到 wrapper 存活,风险方向是残留/泄漏,
|
||||||
|
不是过早 GC 导致信号丢失。
|
||||||
|
3. **`ApplicationController.request_quit()` 本身有效。** 在真实 Qt 事件循环里,调用顺序是
|
||||||
|
`request_quit_enter -> request_quit_return -> aboutToQuit`,没有触发 1 秒 watchdog;本次
|
||||||
|
测量从进入事件循环到退出约 0.1 ms。
|
||||||
|
4. **`apply_downloaded_update()` 的 `Popen` 调用不阻塞 GUI。** 生产参数下
|
||||||
|
`_spawn_inno_setup_applier()` 约 2.7--4.7 ms 返回。
|
||||||
|
5. **真正可复现的安装失败位于 Windows helper 启动。** 当前代码组合
|
||||||
|
`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` 启动 Windows
|
||||||
|
PowerShell。调用会快速返回,PowerShell 进程退出码甚至是 0,但脚本没有执行第一条写日志
|
||||||
|
命令。标志矩阵表明:本机上任何包含 `DETACHED_PROCESS` 的组合都失败;去掉它后,
|
||||||
|
`CREATE_NEW_PROCESS_GROUP`、`CREATE_NO_WINDOW` 以及两者组合均能执行脚本。
|
||||||
|
6. **本机真实 18:08 更新尝试与复现完全一致。** 安装包和 `install_update.ps1` 都在
|
||||||
|
18:08:37 生成,证明 Qt `result` 已投递且 `_finish_install()` 已进入
|
||||||
|
`apply_downloaded_update()`;但同目录没有 `update_helper.log` 和 `inno_setup.log`,说明
|
||||||
|
helper 没有运行到脚本第 20 行的第一条 `Write-Log`。
|
||||||
|
|
||||||
|
因此,“下载完成后没有进入安装”的首要根因不是 `finished` 信号丢失,也不是
|
||||||
|
`request_quit()` 失效,而是 **`DETACHED_PROCESS` 令 PowerShell helper 静默不执行**。
|
||||||
|
当前源码仍会在 `finished` 后请求退出,所以如果现场描述为“窗口也一直不关闭”,这部分在当前
|
||||||
|
工作树中未能复现;现有日志更符合“应用已走到提交/退出路径,但安装器从未启动,因此没有安装
|
||||||
|
和重启”的用户观感。成功路径没有阶段日志,无法仅凭旧日志证明窗口具体关闭时刻。
|
||||||
|
|
||||||
|
## 源码时序
|
||||||
|
|
||||||
|
相关位置:
|
||||||
|
|
||||||
|
- `app/src/doctor_workstation/ui/dialogs/app_update.py:68-91`:`_Task.run()` 在同一个
|
||||||
|
`try/else/finally` 中先 `result.emit(result)`,再 `finished.emit()`。
|
||||||
|
- `app/src/doctor_workstation/ui/dialogs/app_update.py:503-555`:安装任务创建
|
||||||
|
`_TaskSignals`,保存到 `_signals` 和 `_active_install_signals`,然后连接
|
||||||
|
`progress/status/result/error/finished`。
|
||||||
|
- `app/src/doctor_workstation/ui/dialogs/app_update.py:601-641`:`result` 槽先设置
|
||||||
|
`_apply_committed=True` 并同步调用 `apply_downloaded_update()`;`finished` 槽随后清理活动
|
||||||
|
signals,并在 `_apply_committed` 或 `_exit_requested` 时调用 `_complete_quit()`。
|
||||||
|
- `app/src/doctor_workstation/app.py:1081-1086`:controller 通过
|
||||||
|
`QTimer.singleShot(0, application.quit)` 请求正常退出。
|
||||||
|
- `app/src/doctor_workstation/app.py:427,1088-1105`:`aboutToQuit` 同步进入幂等
|
||||||
|
`shutdown()`,更新 session 先被 invalidated,再清理视频和 remote client。
|
||||||
|
- `app/src/doctor_workstation/services/app_update.py:637-672`:Inno helper 的 Windows
|
||||||
|
`Popen` 和三个 creation flags。
|
||||||
|
|
||||||
|
必须注意一个 Qt 细节:worker 发出 `result` 后不会等待 GUI 槽执行,紧接着就发出
|
||||||
|
`finished`;两者作为同一 sender 的跨线程事件按连接顺序排入 GUI 队列。本次 100 次实测均为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
worker: download/prepare return
|
||||||
|
-> GUI: result slot enter
|
||||||
|
-> GUI: apply_downloaded_update enter/return
|
||||||
|
-> GUI: result slot return
|
||||||
|
-> GUI: finished slot enter
|
||||||
|
-> GUI: request_quit
|
||||||
|
```
|
||||||
|
|
||||||
|
这也意味着:如果 `apply_downloaded_update()` 真正阻塞,排在它后面的 `finished` 和 quit 会一起
|
||||||
|
延迟。但本机真实 `Popen` 返回只需数毫秒,未观察到阻塞。
|
||||||
|
|
||||||
|
## 复现结果
|
||||||
|
|
||||||
|
### 1. 现有测试
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:QT_QPA_PLATFORM='offscreen'
|
||||||
|
uv run --project app pytest app/tests/test_app_update_ui.py app/tests/test_app_update.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:`28 passed`。
|
||||||
|
|
||||||
|
现有 `test_session_waits_for_update_worker_before_quitting` 是直接调用私有槽的同步单元测试,能够
|
||||||
|
检查状态门禁,但没有经过 `QThreadPool`/Qt queued delivery;这正是独立探针需要补足的部分。
|
||||||
|
|
||||||
|
### 2. 独立跨线程与 helper 探针
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:QT_QPA_PLATFORM='offscreen'
|
||||||
|
uv run --project app python app/research/update_commit_repro.py 100
|
||||||
|
```
|
||||||
|
|
||||||
|
关键结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
session.iterations = 100
|
||||||
|
session.failures = []
|
||||||
|
|
||||||
|
worker thread != GUI thread
|
||||||
|
result_slot_enter.thread_id == finished_slot_enter.thread_id
|
||||||
|
request_quit.thread_id == GUI main thread
|
||||||
|
|
||||||
|
_spawn_inno_setup_applier return = 约 3 ms
|
||||||
|
production flags script log created = false
|
||||||
|
production PowerShell return code = 0
|
||||||
|
|
||||||
|
controller quit events =
|
||||||
|
request_quit_enter, request_quit_return, aboutToQuit
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 创建标志矩阵:
|
||||||
|
|
||||||
|
| creation flags | 脚本是否执行 |
|
||||||
|
|---|---:|
|
||||||
|
| `0` | 是 |
|
||||||
|
| `CREATE_NEW_PROCESS_GROUP` | 是 |
|
||||||
|
| `CREATE_NO_WINDOW` | 是 |
|
||||||
|
| `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` | 是 |
|
||||||
|
| `DETACHED_PROCESS` | 否 |
|
||||||
|
| `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` | 否 |
|
||||||
|
| `DETACHED_PROCESS | CREATE_NO_WINDOW` | 否 |
|
||||||
|
| 当前生产三标志组合 | 否 |
|
||||||
|
|
||||||
|
将 `close_fds` 改为 false,或给 stdin/stdout/stderr 全部接 `DEVNULL`,均不能挽救包含
|
||||||
|
`DETACHED_PROCESS` 的组合。
|
||||||
|
|
||||||
|
### 3. 真实运行残留
|
||||||
|
|
||||||
|
本机目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\Users\pc\AppData\Local\ZhenYangTang\ZhenyangDoctor\updates\1_3_0\
|
||||||
|
```
|
||||||
|
|
||||||
|
18:08:37 已有:
|
||||||
|
|
||||||
|
- `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe`
|
||||||
|
- `install_update.ps1`
|
||||||
|
|
||||||
|
不存在:
|
||||||
|
|
||||||
|
- `update_helper.log`
|
||||||
|
- `inno_setup.log`
|
||||||
|
|
||||||
|
`install_update.ps1` 只会在 `_finish_install() -> apply_downloaded_update() ->
|
||||||
|
apply_inno_setup_update()` 中生成,所以这组残留直接排除了“result 未投递”和
|
||||||
|
“`_TaskSignals` 被提前回收”。脚本第一项业务动作就是写 `waiting for pid ...`;没有 helper log
|
||||||
|
则失败发生在脚本业务逻辑之前。
|
||||||
|
|
||||||
|
另有一个独立的发布数据风险:workspace 名为 `1_3_0`,下载文件名却是 `1.1.0`,而请求中的
|
||||||
|
当前版本是 `1.2.0`。即使 helper 启动成功,也可能尝试降级安装。服务端 offer 的
|
||||||
|
`latest_version`、package filename、安装器 FileVersion/产品版本需要在发布端和客户端都做一致性
|
||||||
|
校验。这不是本次 helper 不启动的直接原因,但上线前必须处理。
|
||||||
|
|
||||||
|
## 建议修复方向
|
||||||
|
|
||||||
|
1. Windows helper 不使用 `DETACHED_PROCESS`;先验证保留
|
||||||
|
`CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` 时,父进程结束后 helper 仍能存活并运行。
|
||||||
|
2. 不把 `Popen()` 成功等同于 helper 已启动。让 helper 在等待目标 PID 前先原子写一个
|
||||||
|
`ready`/`waiting` 标记,应用收到握手后才设置最终 commit 并退出;握手超时则留在应用内显示
|
||||||
|
明确错误。
|
||||||
|
3. 给成功路径补结构化日志:`prepare_result_received`、`helper_spawn_requested`、
|
||||||
|
`helper_ready`、`worker_finished`、`request_quit`、`about_to_quit`。当前只有异常日志,现场无法
|
||||||
|
区分“100% 后仍在 fsync/校验”“helper 启动失败”和“quit 清理较慢”。
|
||||||
|
4. 对 package 声明版本和安装器版本做一致性校验,拒绝低于当前版本或不同于
|
||||||
|
`latest_version` 的安装器。
|
||||||
|
|
||||||
|
## 建议回归测试
|
||||||
|
|
||||||
|
### Qt/session 测试
|
||||||
|
|
||||||
|
1. **真实 queued delivery 成功路径**:用 `QThreadPool` 启动 `_Task`,以 event loop 等待,断言
|
||||||
|
`result slot -> apply return -> finished slot -> request_quit` 严格顺序,并断言所有 UI/controller
|
||||||
|
槽都在 GUI 线程。
|
||||||
|
2. **signals 生命周期**:启动任务后删除局部 worker/signals 引用并强制 GC,仍应收到 result 和
|
||||||
|
finished;finished 后断开连接并 `deleteLater()`,最终 weakref 应释放,避免长期检查导致残留。
|
||||||
|
3. **apply 门控**:用 `Event` 暂停 fake apply,断言暂停期间不会调用 quit;释放后 finished 只触发
|
||||||
|
一次 quit。
|
||||||
|
4. **apply 失败**:`apply_downloaded_update()` 抛 `AppUpdateError` 时不 quit、
|
||||||
|
`_apply_committed` 恢复 false;另外补非 `AppUpdateError` 异常,避免意外异常留下 commit=true 后
|
||||||
|
仍被 finished 退出。
|
||||||
|
5. **controller 集成**:在真实 `QApplication.exec()` 中调用 controller `request_quit()`,spy
|
||||||
|
`aboutToQuit`、`app_updater.shutdown()` 和远程 client close,断言各一次且总时长有上界。
|
||||||
|
|
||||||
|
### Windows helper 测试
|
||||||
|
|
||||||
|
1. **sentinel 启动测试(当前代码应失败)**:生成只写 sentinel 的 PowerShell 文件,使用生产
|
||||||
|
`_spawn_inno_setup_applier()` 启动,2 秒内必须看到 sentinel;不能只断言 `Popen` 被调用。
|
||||||
|
2. **父进程退出测试**:子 Python 进程启动 helper 后立即退出;helper 应先记录 waiting/ready,
|
||||||
|
再观察父 PID 消失并写第二个 sentinel,证明去掉 `DETACHED_PROCESS` 后不会被父退出连带杀死。
|
||||||
|
3. **完整握手测试**:应用只有在 helper ready 后才调用 quit;helper 未 ready、提前退出或无法写
|
||||||
|
日志时,应用保留并展示可重试错误。
|
||||||
|
4. **打包 smoke**:从实际 PyInstaller onedir/installer 环境执行上述测试,不能只在源码虚拟环境
|
||||||
|
mock `subprocess.Popen`。
|
||||||
|
|
||||||
|
## 仓库说明
|
||||||
|
|
||||||
|
根 `AGENTS.md` 声明项目由 Trellis 管理,但当前工作区没有 `.trellis/` 目录,因而无法读取
|
||||||
|
`.trellis/workflow.md` 或 layer spec;本次按根指令执行并在此记录。工作树原本已有大量未提交
|
||||||
|
修改,本次没有改动其中任何生产源码或既有测试。
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Doctor Workstation 1.3.0 Windows 正式包构建与安全验证结果
|
||||||
|
|
||||||
|
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||||
|
- 工作目录:`D:\web\zyt\app`
|
||||||
|
- 结论:通过。1.3.0 Windows 安装器与 ZIP 已重新构建;冻结媒体/入口门禁、隔离安装/启动/卸载冒烟和真实 Windows helper bootstrap 专项测试全部通过。
|
||||||
|
- 安全边界:未修改生产源码或既有测试,未执行 reset/checkout,未访问现场服务端,也未用 `dist` 中旧的 1.1.0 安装器覆盖真实安装。安装器冒烟显式指定 1.3.0 文件并仅安装到随机临时目录。
|
||||||
|
|
||||||
|
## 最终产物
|
||||||
|
|
||||||
|
| 产物 | 字节数 | SHA-256 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe` | 162917310 | `E1734C7B5E1619951AF81082FC4578D50BD881B5CD8C7AC7F4E658E8E28B74A7` |
|
||||||
|
| `D:\web\zyt\app\dist\DoctorWorkstation-Windows-x64-1.3.0.zip` | 230820285 | `FAF2D4A68065C1ED528A1D887B5210B44FACBEBBC1CB89729FA3B690D7995CEF` |
|
||||||
|
|
||||||
|
`dist\SHA256SUMS.txt` 与重新计算的两个 SHA-256 完全一致。
|
||||||
|
|
||||||
|
## 命令、退出码与结果
|
||||||
|
|
||||||
|
以下命令均从 PowerShell 执行;未特别注明时工作目录为 `D:\web\zyt\app`。
|
||||||
|
|
||||||
|
### 1. 项目约束与 Trellis 检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Content -LiteralPath 'D:\web\zyt\AGENTS.md' -Raw
|
||||||
|
if (Test-Path -LiteralPath 'D:\web\zyt\.trellis') { Get-ChildItem ... } else { 'NO_TRELLIS' }
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:根 `AGENTS.md` 已完整读取;`D:\web\zyt\.trellis` 不存在,因此没有可继续读取的 `.trellis/workflow.md` 或分层 spec。
|
||||||
|
|
||||||
|
### 2. 初始共享工作树与构建输入只读检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status --short
|
||||||
|
Get-Content src\doctor_workstation\__init__.py
|
||||||
|
Get-Content scripts\package_windows.ps1
|
||||||
|
Get-Content scripts\smoke_windows_installer.ps1
|
||||||
|
rg -n "bootstrap|ready|helper|windows" tests\test_app_update.py
|
||||||
|
Get-ChildItem dist -File
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:开始时共享工作树已有大量未提交源码/测试变更和研究文件,均视为他人工作并保留;确认构建、安装器冒烟与 helper 测试入口存在。`dist` 内旧 1.1.0/1.2.0 包仅被列出,没有被安装或复制到真实安装位置。
|
||||||
|
|
||||||
|
### 3. 版本源逐行及实际导入检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
[System.IO.File]::ReadAllLines((Resolve-Path 'src\doctor_workstation\__init__.py'))
|
||||||
|
.\.venv\Scripts\python.exe -c "import doctor_workstation; print(repr(doctor_workstation.__version__)); print(repr(doctor_workstation.DEBUG_MODE)); print(repr(doctor_workstation.ONLINE_API_BASE_URL))"
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:`__version__ == '1.3.0'`;`DEBUG_MODE is False`;线上 API 基址为 `https://admin.zhenyangtang.com.cn`。
|
||||||
|
|
||||||
|
### 4. 打包脚本内置冻结门禁确认
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n "runtime_media_smoke|smoke|entry|frozen|media|DoctorWorkstation.exe|--smoke" scripts\package_windows.ps1 scripts\build_windows.ps1 packaging\runtime_media_smoke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:确认 `build_windows.ps1` 在构建后依次执行冻结 Qt 多媒体文件门禁、`--media-smoke-test` 冻结进程门禁和 `--smoke-test` 应用入口门禁。
|
||||||
|
|
||||||
|
### 5. 1.3.0 Windows 正式包重建
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& .\scripts\package_windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 关键结果:
|
||||||
|
- `uv sync --frozen --extra build` 成功;构建环境使用 Python 3.12.12、PyInstaller 6.22.0。
|
||||||
|
- `npm ci` 成功,视频伴侣生产构建成功(Vite 6.1.1,43 modules transformed)。
|
||||||
|
- PyInstaller onedir 冻结成功,输出 `dist\DoctorWorkstation\DoctorWorkstation.exe`。
|
||||||
|
- `Frozen Qt multimedia file gate passed.`
|
||||||
|
- `Frozen Qt multimedia smoke gate passed (--media-smoke-test, isolated offscreen mode).`
|
||||||
|
- `Frozen application entry smoke gate passed (--smoke-test, isolated offscreen mode).`
|
||||||
|
- 7-Zip 创建 1.3.0 ZIP 成功;Inno Setup 6.7.3 编译 1.3.0 安装器成功。
|
||||||
|
- 脚本打印的最终哈希与本报告“最终产物”一致。
|
||||||
|
- 非阻塞警告:Vite 报告单个 JS chunk 大于 500 kB;PyInstaller 报告一个可选 QML asset downloader 插件二进制不存在及若干 Windows 系统 DLL 静态解析警告。它们未阻断构建,且后续冻结媒体实际进程、应用入口及安装后启动门禁全部通过。
|
||||||
|
|
||||||
|
### 6. 新安装器隔离安装/启动/卸载冒烟
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& .\scripts\smoke_windows_installer.ps1 -Installer (Resolve-Path -LiteralPath '.\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe').Path
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`(PowerShell 进程退出码)
|
||||||
|
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||||
|
- 隔离目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-14b72f4137814eea8ede6bc48920f5f5`
|
||||||
|
- 覆盖项:安装器/主程序/卸载器图标一致;安装目录内主程序和卸载器存在;安装后主程序 `--smoke-test` 返回 0;卸载返回 0;卸载后主程序不再存在。
|
||||||
|
- 安全说明:脚本使用随机 `%LOCALAPPDATA%\Temp\doctor-workstation-installer-smoke-*\install`,环境变量、配置与日志均隔离;命令显式锁定 1.3.0 安装器,没有调用 1.1.0 包或现场服务端。
|
||||||
|
|
||||||
|
### 7. 真实 Windows helper bootstrap 专项测试
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv\Scripts\python.exe -m pytest 'tests\test_app_update.py::test_inno_helper_executes_bootstrap_with_production_flags' -q
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:`1 passed`(单点输出为 `.`)。该测试未 mock `subprocess.Popen`,会真实启动 PowerShell helper,并由测试生成的 helper 脚本在收到生产参数集后写入 ready 文件;最后断言 ready 文件内容为 `ready`,因此确认生产 flags 下 ready 握手实际产生。
|
||||||
|
|
||||||
|
### 8. 最终版本/生产 flag 复核及独立哈希核验
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv\Scripts\python.exe -c "import doctor_workstation; assert doctor_workstation.__version__ == '1.3.0'; assert doctor_workstation.DEBUG_MODE is False"
|
||||||
|
Get-Item .\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe
|
||||||
|
Get-FileHash .\dist\DoctorWorkstation-Setup-Windows-x64-1.3.0.exe -Algorithm SHA256
|
||||||
|
Get-Item .\dist\DoctorWorkstation-Windows-x64-1.3.0.zip
|
||||||
|
Get-FileHash .\dist\DoctorWorkstation-Windows-x64-1.3.0.zip -Algorithm SHA256
|
||||||
|
Compare-Object <重新计算的两行> (Get-Content .\dist\SHA256SUMS.txt)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:版本和 `DEBUG_MODE` 断言通过;EXE/ZIP 字节数和哈希如“最终产物”所列;`Compare-Object` 无差异。
|
||||||
|
|
||||||
|
### 9. 完成前共享工作树/产物复查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status --short
|
||||||
|
Get-ChildItem .\dist -File | Where-Object { $_.Name -match '1\.3\.0|SHA256SUMS' } | Select-Object Name,Length,LastWriteTime
|
||||||
|
```
|
||||||
|
|
||||||
|
- 退出码:`0`
|
||||||
|
- 结果:1.3.0 EXE、ZIP 和新 `SHA256SUMS.txt` 均存在;本任务未编辑生产源码或测试。完成复查时仍可见共享工作树中的既有 `src/doctor_workstation/services/app_update.py` 修改,未回退或覆盖。
|
||||||
|
|
||||||
|
## 验收结论
|
||||||
|
|
||||||
|
1. 版本/发布开关正确:`1.3.0`、`DEBUG_MODE=False`。
|
||||||
|
2. 正式 EXE/ZIP 重建成功,内置三项冻结门禁全部通过。
|
||||||
|
3. 1.3.0 安装器隔离安装、启动、卸载和残留检查通过。
|
||||||
|
4. 真实 Windows helper bootstrap 在生产 flags 下成功生成 ready 文件。
|
||||||
|
5. 最终大小与 SHA-256 已独立复核,且与 `SHA256SUMS.txt` 一致。
|
||||||
|
6. 未触碰真实安装或现场服务端 1.1.0 包。
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Windows 更新安装交接故障诊断
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
“下载完成后显示即将关闭,但程序不退出/不安装”不是下载失败。当前现场同时存在三个问题,其中第 1 项能够确定性复现用户看到的主症状,第 2 项是本次现场已经发生但被代码吞掉的 helper 启动失败,第 3 项是必须立即纠正的发布配置错误。
|
||||||
|
|
||||||
|
1. **确定性根因:强制更新对话框拒绝了 `QApplication.quit()` 触发的关闭事件。** 下载 job 完成后,session 启动 helper、设置 `_apply_committed=True`,随后在 `finished` 回调中调用应用级 `request_quit()`;但对话框此时仍是 `offer.force=True` 且 `_busy=True`,其 `closeEvent()` 无条件 `ignore()`。Qt 明确允许窗口通过 Close event 阻止 `quit()`,所以事件循环不退出,`aboutToQuit`/`ApplicationController.shutdown()` 也不会发生。helper 又先等待当前 PID 消失,于是交接形成闭环等待。
|
||||||
|
2. **独立的已确认问题:PowerShell helper 进程被创建后,在执行脚本首条日志之前就退出了,而父进程把“CreateProcess 成功”误判成“helper 已接管”。** 现场有精确的 PowerShell 启动事件,但没有 `update_helper.log`、没有 `inno_setup.log`、没有存活 helper/installer 进程。代码丢弃 `Popen` 句柄、不检查早退、也没有 ready handshake,因此 UI 仍停在“即将关闭”。现有证据不足以还原该次子进程的退出码;这正是当前可观测性缺口。
|
||||||
|
3. **发布配置错误:服务端宣称最新/最低版本为 `1.3.0`,实际下发的安装器却是 `1.1.0`。** 本机当前运行 `1.2.0`,所以即使退出与 helper 均修复,也会尝试降级安装,而不是升级到 `1.3.0`。修复客户端前应先停止这条强制更新配置。
|
||||||
|
|
||||||
|
## 代码交接链路与根因证据
|
||||||
|
|
||||||
|
### 1. UI 已经请求退出,但强制对话框否决退出
|
||||||
|
|
||||||
|
当前交接顺序是:
|
||||||
|
|
||||||
|
1. `_start_install()` 设置 busy、启动 QThreadPool 下载 job(`app/src/doctor_workstation/ui/dialogs/app_update.py:481-555`)。
|
||||||
|
2. job 下载并校验 Inno Setup EXE,返回 `_PreparedUpdate`(`:511-545`)。
|
||||||
|
3. `_finish_install()` 先显示“即将关闭程序并自动安装”,再调用 `dialog.set_apply_committed()`;该方法把 `_busy` 保持为 true(`:616-628`、`:275-283`)。
|
||||||
|
4. `apply_downloaded_update()` 进入 `apply_inno_setup_update()`,写脚本并 `Popen` PowerShell helper(`app/src/doctor_workstation/services/app_update.py:429-472`)。
|
||||||
|
5. QRunnable 随后发送 `finished`,`_on_install_finished()` 清理 active signals 并调用 `_complete_quit()`(UI `:634-641`)。
|
||||||
|
6. `_complete_quit()` 调用 controller `request_quit()`;controller 用 `QTimer.singleShot(0, self.application.quit)` 请求正常退出(UI `:472-479`;`app/src/doctor_workstation/app.py:1081-1086`)。
|
||||||
|
7. 但是更新对话框的 `closeEvent()` 在 `offer.force` **或** `_busy` 为真时执行 `event.ignore()`(UI `:296-300`)。此时两个条件都为真。
|
||||||
|
|
||||||
|
使用当前 PySide6 做了不改文件的最小事件循环复现:显示一个 modal dialog,其 `closeEvent()` 执行 `ignore()`,50 ms 后调用 `QApplication.quit()`,并设置 2 秒进程级 watchdog。输出为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
calling quit
|
||||||
|
closeEvent ignored
|
||||||
|
CODE=9
|
||||||
|
```
|
||||||
|
|
||||||
|
也就是 `quit()` 确实到达了窗口,但被 Close event 否决,事件循环直到 watchdog 才终止。Qt 官方文档同样说明 `QCoreApplication.quit()` 可能被仍未关闭的窗口或被忽略的 Quit/Close event 阻止:<https://doc.qt.io/qt-6/qcoreapplication.html#quit>。
|
||||||
|
|
||||||
|
这也解释了为何 controller 的 `aboutToQuit -> shutdown()` 接线本身没有帮助:`aboutToQuit` 只有在退出请求未被阻止时才会发出(`app.py:425-427`、`:1088-1105`)。
|
||||||
|
|
||||||
|
### 2. Inno helper 的等待设计放大了 UI 退出缺陷
|
||||||
|
|
||||||
|
生成的 `install_update.ps1` 首先写 `waiting for pid ...`,然后无限轮询 `Get-Process -Id $TargetPid`;只有目标 PID 消失后才启动安装器(service `:566-627`)。父进程传入的是 `os.getpid()`(`:637-672`)。因此,只要 Qt 主进程被对话框留下,安装器就不可能启动。
|
||||||
|
|
||||||
|
安装器参数本身与 Inno 静默更新意图一致:`/VERYSILENT`、`/SUPPRESSMSGBOXES`、`/NORESTART`、`/CLOSEAPPLICATIONS`、`/NOFORCECLOSEAPPLICATIONS`、`/NORESTARTAPPLICATIONS`、`/LOG=...`;返回 `0`/`3010` 后才重启已安装的 `DoctorWorkstation.exe`,其他返回码重启旧程序(`:599-627`)。`powershell.exe -File <script> <script args>` 的排列也符合 Windows PowerShell 5.1 的 `-File` 契约:<https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe?view=powershell-5.1>。
|
||||||
|
|
||||||
|
不过当前等待还有两个健壮性问题:
|
||||||
|
|
||||||
|
- 循环无超时、只按整数 PID 查询;极端情况下 PID 被复用会继续等待无关进程。更安全的是先取得特定 `Process` 对象/句柄,再等待该对象退出,而不是每 400 ms 重新按 ID 查找。
|
||||||
|
- helper 的成功标准只是 `subprocess.Popen(...)` 没有同步抛出 `OSError`。`Popen` 返回后句柄立即丢失,没有 child PID 日志、早退检测、stderr 捕获或 ready handshake(`:637-672`)。
|
||||||
|
|
||||||
|
当前还同时设置 `DETACHED_PROCESS`、`CREATE_NEW_PROCESS_GROUP`、`CREATE_NO_WINDOW`(`:645-670`)。Microsoft 文档指出 `CREATE_NO_WINDOW` 与 `DETACHED_PROCESS` 同用时会被忽略,因此这组 flag 至少是冗余且不能证明 helper 已进入脚本:<https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags>。本次现场只能确认 PowerShell 在脚本体前早退,不能仅凭事件日志断言具体是 flag、stdio、执行策略还是主机初始化中的哪一个原因;修复应以“可确认接管”为契约,而不是猜一个退出原因。
|
||||||
|
|
||||||
|
### 3. `apply_downloaded_update()` 的路由本身正确
|
||||||
|
|
||||||
|
`package_type=inno_setup` 会进入 `apply_inno_setup_update()`;后者要求 Windows、可解析的 frozen install root、存在的已安装 EXE,以及扩展名为 `.exe` 且 DOS header 为 `MZ` 的安装器(service `:390-401`、`:429-483`)。现场已经生成 `install_update.ps1`,证明路由、install root、EXE/PE 基本校验均已通过;故障发生在 helper 启动及应用退出交接之后。
|
||||||
|
|
||||||
|
## 本机现场证据(2026-08-28,Asia/Shanghai)
|
||||||
|
|
||||||
|
- 运行进程:PID `36304`,`C:\Program Files\ZYT\DoctorWorkstation\DoctorWorkstation.exe`,启动于 `18:08:13`;检查时仍 `Responding=True`、主窗口可见,文件 `ProductVersion=1.2.0`。
|
||||||
|
- 应用日志:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\Logs\doctor-workstation.log`。
|
||||||
|
- `18:08:13`:应用启动。
|
||||||
|
- `18:08:15`:以 `current_version=1.2.0&platform=windows&arch=x64` 检查更新成功。
|
||||||
|
- `18:08:32`:下载 `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe` 返回 HTTP 200。
|
||||||
|
- 此后没有安装/helper/退出阶段日志,也没有 Python 异常。
|
||||||
|
- 工作区:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\updates\1_3_0`。
|
||||||
|
- 安装器大小 `162,873,123` 字节,SHA-256 `0CDD38DB6EEF5E7B7380FFAE3FBC407B602EB916C68AE4C8F29496B621F50789`,`ProductVersion=1.1.0`,未签名。
|
||||||
|
- `install_update.ps1` 在 `18:08:37` 生成,PowerShell AST 解析无语法错误。
|
||||||
|
- `update_helper.log` 不存在;`inno_setup.log` 不存在。
|
||||||
|
- Windows PowerShell Operational 日志:`18:08:37.828` 有 Event `40961`“Powershell 控制台正在启动”,没有配对的 ready `40962`;检查时也没有命令行指向 `install_update.ps1` 的 PowerShell 或 Inno Setup 进程。这证明子进程被创建过,但没有进入脚本首条 `Write-Log`。
|
||||||
|
- crash log 只有各次进程启动标记,本次没有崩溃堆栈。
|
||||||
|
- 实时只读请求更新接口得到:`latest_version=1.3.0`、`min_version=1.3.0`、`force=true`,但 URL、filename、size、sha256 全都对应上述 `1.1.0` 安装器。本机实测文件 hash 与接口 SHA 一致,说明下载正确,错误在发布元数据/产物绑定。
|
||||||
|
|
||||||
|
## 最小安全修复
|
||||||
|
|
||||||
|
### P0:先修发布配置
|
||||||
|
|
||||||
|
在正确的 `1.3.0` 安装器上传并核对 `ProductVersion`、filename、size、SHA-256 前,立即关闭这条强制更新或将其设为不可安装。不要让 `latest_version=1.3.0` 继续绑定 `1.1.0` 安装器。客户端后续应增加“offer 版本与包版本”的发布门禁;仅校验 HTTPS、SHA 和 `MZ` 不能防止签名正确的旧包被错误发布。
|
||||||
|
|
||||||
|
### P0:让应用级退出能够越过“用户不可关闭”的对话框门禁
|
||||||
|
|
||||||
|
不要把“禁止用户关闭强制更新弹窗”和“禁止应用已提交后的受控退出”共用同一个 `closeEvent` 条件。建议增加明确的 `_allow_application_exit` 状态:
|
||||||
|
|
||||||
|
- 用户点击标题栏关闭/Escape 时仍然拒绝;
|
||||||
|
- helper 已确认接管,或用户点击专用“退出软件”且更新 worker 已安全结束时,session 先设置 allow 状态并关闭/隐藏该 dialog,再调用 controller `request_quit()`;
|
||||||
|
- `closeEvent()` 仅在 `not _allow_application_exit and (offer.force or _busy)` 时 ignore。
|
||||||
|
|
||||||
|
直接在主线程调用 `QCoreApplication.exit(0)`也能绕过 Close event,但会绕过其他窗口的正常 close 协议;相比之下,显式放行本更新对话框后继续走既有 `QApplication.quit -> aboutToQuit -> shutdown` 更小、更安全。
|
||||||
|
|
||||||
|
### P0:把 helper“已接管”变成可验证状态
|
||||||
|
|
||||||
|
`_spawn_inno_setup_applier()` 应返回并保留 `Popen`/child PID,且 helper 在做任何 PID 等待前原子写入 ready 标记(或首条结构化 bootstrap log)。父进程应异步等待一个很短且有界的 ready 窗口:
|
||||||
|
|
||||||
|
- ready 到达且 child 仍存活后,才设置 `_apply_committed=True`、放行 dialog close、请求主程序退出;
|
||||||
|
- child 在 ready 前退出时,读取退出码/bootstrap stderr,留在当前 UI 显示错误,不退出主程序;
|
||||||
|
- 使用 `-NonInteractive`,明确重定向 stdin/stdout/stderr 到日志或 `DEVNULL`;规范化 creation flags,不同时依赖会被忽略的 `DETACHED_PROCESS + CREATE_NO_WINDOW`;
|
||||||
|
- helper 顶层捕获脚本初始化、首条日志、PID wait、安装器启动等所有异常,并写明阶段和退出码。
|
||||||
|
|
||||||
|
这样即使本次 PowerShell 早退的底层原因在另一台机器上不同,也不会再出现“UI 已锁死并宣称即将关闭,但其实无人接管”的假成功。
|
||||||
|
|
||||||
|
## 建议测试
|
||||||
|
|
||||||
|
现有 `app/tests/test_app_update.py` 与 `app/tests/test_app_update_ui.py` 共 `28 passed`,但没有覆盖真实交接:service 测试只截获 `_spawn_inno_setup_applier` 并检查脚本文本;UI 测试只断言 mock `host.request_quit` 被调用,没有运行 `QApplication` 事件循环,也没有验证强制 dialog 是否会否决 quit。现有 installer smoke 直接执行安装器,也绕过了应用退出与 helper。
|
||||||
|
|
||||||
|
建议至少增加:
|
||||||
|
|
||||||
|
1. **Qt 子进程回归(必须)**:显示 `force=True` 且 busy/apply committed 的真实 `AppUpdateDialog`,触发 session 完成退出,以 watchdog 保底;断言事件循环正常返回 `0`、`aboutToQuit` 发生,而不是被 `closeEvent` 卡住。
|
||||||
|
2. **显式退出路径**:强制更新下载中点击“退出软件”,worker `finished` 后同样能退出;result 已排队但退出先处理时不得启动 helper。
|
||||||
|
3. **helper ready 成功**:使用临时 noop PowerShell fixture 和真实生产 creation flags,断言 child PID、ready、等待目标进程退出、后续阶段按顺序发生,并覆盖路径含空格。
|
||||||
|
4. **helper 早退**:脚本缺失/解析失败/首条日志失败时,断言父进程取得非零退出码、不设置 committed、不退出、UI 可重试且有明确日志路径。
|
||||||
|
5. **PID 身份**:目标进程退出后即使整数 PID 被模拟复用,也不会等待或误伤新进程;等待有诊断超时但绝不在无法确认旧进程退出时启动安装。
|
||||||
|
6. **隔离端到端 Inno 更新**:从一个测试 frozen app 发起 handoff,确认旧 PID 消失、helper log 产生、安装器 log 产生、目标版本真正安装、只重启一次。不要只测试安装器单独运行。
|
||||||
|
7. **发布契约**:服务端 `latest_version`、package filename/manifest `ProductVersion`、SHA/size 必须属于同一版本;构造 `latest=1.3.0 + package=1.1.0` 时发布或客户端安装必须失败。
|
||||||
|
|
||||||
|
## 审阅边界
|
||||||
|
|
||||||
|
- 已读取根 `AGENTS.md`;仓库不存在 `.trellis/`,因此没有额外 workflow/spec 可读取。
|
||||||
|
- 工作树原本已有大量未提交改动,包括本报告涉及的生产文件和测试;本次未修改、覆盖或回退它们。
|
||||||
|
- 除新增本文档外,没有修改生产代码或测试。
|
||||||
@@ -131,9 +131,9 @@ PYINSTALLER_PYTHON="$python_bin" SKIP_FRONTEND_INSTALL=1 \
|
|||||||
/bin/bash "$script_dir/build_macos.sh"
|
/bin/bash "$script_dir/build_macos.sh"
|
||||||
|
|
||||||
artifact="$project_root/dist/DoctorWorkstation.app"
|
artifact="$project_root/dist/DoctorWorkstation.app"
|
||||||
project_version="$(/usr/bin/awk -F '"' '/^version = "/ { print $2; exit }' \
|
project_version="$(/usr/bin/awk -F '"' '/^__version__ = "/ { print $2; exit }' \
|
||||||
"$project_root/pyproject.toml")"
|
"$project_root/src/doctor_workstation/__init__.py")"
|
||||||
[[ -n "$project_version" ]] || macos_die "无法从 pyproject.toml 读取版本号。"
|
[[ -n "$project_version" ]] || macos_die "无法从 doctor_workstation.__version__ 读取版本号。"
|
||||||
case "$(uname -m)" in
|
case "$(uname -m)" in
|
||||||
arm64) release_arch="arm64" ;;
|
arm64) release_arch="arm64" ;;
|
||||||
x86_64) release_arch="x64" ;;
|
x86_64) release_arch="x64" ;;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ $ReleaseLauncher = Join-Path $DistributionRoot "Start_DoctorWorkstation.bat"
|
|||||||
$InstallerDefinition = Join-Path $ProjectRoot "packaging\windows\doctor_workstation.iss"
|
$InstallerDefinition = Join-Path $ProjectRoot "packaging\windows\doctor_workstation.iss"
|
||||||
$EnsureInstallerCompiler = Join-Path $PSScriptRoot "ensure_inno_setup.ps1"
|
$EnsureInstallerCompiler = Join-Path $PSScriptRoot "ensure_inno_setup.ps1"
|
||||||
$InstallerMessagesFile = Join-Path $ProjectRoot ".build-tools\inno-languages\ChineseSimplified.isl"
|
$InstallerMessagesFile = Join-Path $ProjectRoot ".build-tools\inno-languages\ChineseSimplified.isl"
|
||||||
$ProjectMetadata = Join-Path $ProjectRoot "pyproject.toml"
|
$VersionSource = Join-Path $ProjectRoot "src\doctor_workstation\__init__.py"
|
||||||
$MediaSmokeHook = Join-Path $ProjectRoot "packaging\runtime_media_smoke.py"
|
$MediaSmokeHook = Join-Path $ProjectRoot "packaging\runtime_media_smoke.py"
|
||||||
$WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico"
|
$WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico"
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ try {
|
|||||||
$BuildScript,
|
$BuildScript,
|
||||||
$PackageLock,
|
$PackageLock,
|
||||||
(Join-Path $ProjectRoot "uv.lock"),
|
(Join-Path $ProjectRoot "uv.lock"),
|
||||||
$ProjectMetadata,
|
$VersionSource,
|
||||||
$MediaSmokeHook,
|
$MediaSmokeHook,
|
||||||
$WindowsIcon,
|
$WindowsIcon,
|
||||||
$ReleaseLauncherTemplate,
|
$ReleaseLauncherTemplate,
|
||||||
@@ -65,6 +65,16 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$VersionText = [System.IO.File]::ReadAllText($VersionSource)
|
||||||
|
$VersionMatch = [regex]::Match(
|
||||||
|
$VersionText,
|
||||||
|
'(?m)^\s*__version__\s*=\s*"([^"]+)"'
|
||||||
|
)
|
||||||
|
if (-not $VersionMatch.Success) {
|
||||||
|
throw "Unable to read the application version from $VersionSource"
|
||||||
|
}
|
||||||
|
$ProjectVersion = $VersionMatch.Groups[1].Value
|
||||||
|
|
||||||
$Npm = Find-Application -Name "npm.cmd"
|
$Npm = Find-Application -Name "npm.cmd"
|
||||||
$Node = Find-Application -Name "node.exe"
|
$Node = Find-Application -Name "node.exe"
|
||||||
if (-not $Npm -or -not $Node) {
|
if (-not $Npm -or -not $Node) {
|
||||||
@@ -156,15 +166,6 @@ try {
|
|||||||
throw "Build completed without the expected executable: $Executable"
|
throw "Build completed without the expected executable: $Executable"
|
||||||
}
|
}
|
||||||
|
|
||||||
$ProjectText = [System.IO.File]::ReadAllText($ProjectMetadata)
|
|
||||||
$VersionMatch = [regex]::Match(
|
|
||||||
$ProjectText,
|
|
||||||
'(?m)^\s*version\s*=\s*"([^"]+)"'
|
|
||||||
)
|
|
||||||
if (-not $VersionMatch.Success) {
|
|
||||||
throw "Unable to read the project version from $ProjectMetadata"
|
|
||||||
}
|
|
||||||
$ProjectVersion = $VersionMatch.Groups[1].Value
|
|
||||||
$ReleaseZip = Join-Path $DistributionRoot (
|
$ReleaseZip = Join-Path $DistributionRoot (
|
||||||
"DoctorWorkstation-Windows-x64-$ProjectVersion.zip"
|
"DoctorWorkstation-Windows-x64-$ProjectVersion.zip"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
"""Zhenyang doctor workstation."""
|
"""Zhenyang doctor workstation."""
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
# Single source of truth for runtime, package, installer, and executable versions.
|
||||||
|
__version__ = "1.4.1"
|
||||||
|
|
||||||
|
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||||
|
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||||
|
DEBUG_MODE = False
|
||||||
|
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from contextlib import suppress
|
from collections.abc import Mapping, Sequence
|
||||||
|
from contextlib import suppress
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
|
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
|
||||||
@@ -34,17 +35,179 @@ from doctor_workstation.services import (
|
|||||||
build_repository,
|
build_repository,
|
||||||
)
|
)
|
||||||
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
|
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
|
||||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
||||||
from doctor_workstation.ui.widgets import (
|
from doctor_workstation.ui.widgets import (
|
||||||
friendly_error,
|
first_value,
|
||||||
run_async,
|
friendly_error,
|
||||||
|
gender_text,
|
||||||
|
get_value,
|
||||||
|
run_async,
|
||||||
set_authentication_expired_handler,
|
set_authentication_expired_handler,
|
||||||
show_toast,
|
show_toast,
|
||||||
)
|
)
|
||||||
from doctor_workstation.video import BackendMode, launch_video_call
|
from doctor_workstation.video import BackendMode, launch_video_call
|
||||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
||||||
|
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
||||||
|
|
||||||
|
if value in (None, "", [], {}):
|
||||||
|
return ""
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
parts = [
|
||||||
|
f"{key}:{_video_case_text(item, limit=limit)}"
|
||||||
|
for key, item in value.items()
|
||||||
|
if item not in (None, "", [], {})
|
||||||
|
]
|
||||||
|
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
||||||
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||||
|
return "、".join(
|
||||||
|
part
|
||||||
|
for item in value
|
||||||
|
if (part := _video_case_text(item, limit=limit))
|
||||||
|
)[:limit]
|
||||||
|
return str(value).strip()[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _video_identity(value: Any) -> str:
|
||||||
|
if value in (None, "") or isinstance(value, bool):
|
||||||
|
return ""
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
with suppress(ValueError, TypeError):
|
||||||
|
return str(int(text))
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
||||||
|
normalized_actual = _video_identity(actual)
|
||||||
|
return not normalized_actual or normalized_actual == _video_identity(expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_video_patient_case(
|
||||||
|
detail: Any,
|
||||||
|
fallback_record: Any,
|
||||||
|
*,
|
||||||
|
diagnosis_id: Any,
|
||||||
|
patient_id: Any,
|
||||||
|
patient_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
||||||
|
|
||||||
|
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
||||||
|
nested = get_value(detail, "data", None)
|
||||||
|
if isinstance(nested, Mapping):
|
||||||
|
detail = nested
|
||||||
|
diagnosis = get_value(detail, "diagnosis", None)
|
||||||
|
if not diagnosis and isinstance(detail, Mapping):
|
||||||
|
diagnosis = detail
|
||||||
|
diagnosis = diagnosis or {}
|
||||||
|
patient = get_value(detail, "patient", None) or {}
|
||||||
|
appointment = get_value(detail, "appointment", None) or {}
|
||||||
|
|
||||||
|
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
||||||
|
detail_patient_id = first_value(
|
||||||
|
diagnosis,
|
||||||
|
"source_patient_id",
|
||||||
|
default=first_value(
|
||||||
|
patient,
|
||||||
|
"source_patient_id",
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not _video_identity_matches(
|
||||||
|
diagnosis_id,
|
||||||
|
detail_diagnosis_id,
|
||||||
|
) or not _video_identity_matches(patient_id, detail_patient_id):
|
||||||
|
detail = diagnosis = patient = appointment = {}
|
||||||
|
|
||||||
|
fallback_diagnosis_id = first_value(
|
||||||
|
fallback_record,
|
||||||
|
"diagnosis_id",
|
||||||
|
"id",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
fallback_patient_id = first_value(
|
||||||
|
fallback_record,
|
||||||
|
"source_patient_id",
|
||||||
|
"patient_id",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
if not _video_identity_matches(
|
||||||
|
diagnosis_id,
|
||||||
|
fallback_diagnosis_id,
|
||||||
|
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
||||||
|
fallback_record = {}
|
||||||
|
sources = (diagnosis, patient, appointment, fallback_record)
|
||||||
|
|
||||||
|
def pick(*keys: str, limit: int = 2000) -> str:
|
||||||
|
for source in sources:
|
||||||
|
value = first_value(source, *keys, default=None)
|
||||||
|
text = _video_case_text(value, limit=limit)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
raw_gender = next(
|
||||||
|
(
|
||||||
|
first_value(source, "gender_desc", "gender", default=None)
|
||||||
|
for source in sources
|
||||||
|
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
||||||
|
"name": pick("patient_name", "name", limit=120)
|
||||||
|
or _video_case_text(patient_name, limit=120)
|
||||||
|
or "患者",
|
||||||
|
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
||||||
|
"age": pick("age", limit=20),
|
||||||
|
"height": pick("height", limit=20),
|
||||||
|
"weight": pick("weight", limit=20),
|
||||||
|
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
||||||
|
"appointmentDate": pick(
|
||||||
|
"appointment_date",
|
||||||
|
"latest_appointment_date",
|
||||||
|
limit=80,
|
||||||
|
),
|
||||||
|
"clinicalDiagnosis": pick(
|
||||||
|
"clinical_diagnosis",
|
||||||
|
"diagnosis_name",
|
||||||
|
"disease_name",
|
||||||
|
),
|
||||||
|
"chiefComplaint": pick("chief_complaint", "complaint"),
|
||||||
|
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
||||||
|
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
||||||
|
"allergyHistory": pick(
|
||||||
|
"allergy_history_text",
|
||||||
|
"allergy_history_desc",
|
||||||
|
"allergy_history",
|
||||||
|
),
|
||||||
|
"personalHistory": pick(
|
||||||
|
"personal_history_text",
|
||||||
|
"personal_history_desc",
|
||||||
|
"personal_history",
|
||||||
|
),
|
||||||
|
"familyHistory": pick(
|
||||||
|
"family_history_text",
|
||||||
|
"family_history_desc",
|
||||||
|
"family_history",
|
||||||
|
),
|
||||||
|
"currentMedication": pick(
|
||||||
|
"current_medications",
|
||||||
|
"current_medicine",
|
||||||
|
"current_medication",
|
||||||
|
),
|
||||||
|
"tongue": pick("tongue", "tongue_coating"),
|
||||||
|
"pulse": pick("pulse", "pulse_condition"),
|
||||||
|
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
||||||
|
"remark": pick("remark"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class _ChineseQtTranslator(QTranslator):
|
class _ChineseQtTranslator(QTranslator):
|
||||||
@@ -237,19 +400,22 @@ class ApplicationController(QObject):
|
|||||||
"""Own windows, repositories and the authenticated application lifecycle."""
|
"""Own windows, repositories and the authenticated application lifecycle."""
|
||||||
|
|
||||||
def __init__(self, application: QApplication, config: AppConfig) -> None:
|
def __init__(self, application: QApplication, config: AppConfig) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.application = application
|
self.application = application
|
||||||
self.config = config
|
self.config = config
|
||||||
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
||||||
self.demo_repository = DemoDoctorRepository()
|
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
||||||
|
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
|
||||||
self.remote_repository: RemoteDoctorRepository | None = None
|
self.remote_repository: RemoteDoctorRepository | None = None
|
||||||
self.login_window: LoginWindow | None = None
|
self.login_window: LoginWindow | None = None
|
||||||
self.shell_window: ShellWindow | None = None
|
self.shell_window: ShellWindow | None = None
|
||||||
self.current_repository: Any = None
|
self.current_repository: Any = None
|
||||||
self.current_demo_mode = config.demo_mode
|
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||||
self.video_calls: dict[str, Any] = {}
|
self.video_calls: dict[str, Any] = {}
|
||||||
self.video_pending: dict[str, object] = {}
|
self.video_pending: dict[str, object] = {}
|
||||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||||
|
self._video_preview_state: dict[str, Any] | None = None
|
||||||
|
self._video_preview_generation = 0
|
||||||
self._restore_generation = 0
|
self._restore_generation = 0
|
||||||
self._restore_in_progress = False
|
self._restore_in_progress = False
|
||||||
self._restore_worker: Any = None
|
self._restore_worker: Any = None
|
||||||
@@ -340,10 +506,11 @@ class ApplicationController(QObject):
|
|||||||
if self.login_window is not None:
|
if self.login_window is not None:
|
||||||
self.login_window.config = self.config
|
self.login_window.config = self.config
|
||||||
|
|
||||||
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
||||||
self.current_demo_mode = enabled
|
allowed = self.debug_mode and enabled
|
||||||
if enabled:
|
self.current_demo_mode = allowed
|
||||||
self._cancel_session_restore()
|
if allowed:
|
||||||
|
self._cancel_session_restore()
|
||||||
|
|
||||||
def _rebuild_remote_repository(self) -> None:
|
def _rebuild_remote_repository(self) -> None:
|
||||||
old = self.remote_repository
|
old = self.remote_repository
|
||||||
@@ -503,7 +670,8 @@ class ApplicationController(QObject):
|
|||||||
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
|
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
|
||||||
return
|
return
|
||||||
|
|
||||||
demo_mode = bool(payload.get("demo_mode"))
|
demo_mode = self.debug_mode and bool(payload.get("demo_mode"))
|
||||||
|
payload["demo_mode"] = demo_mode
|
||||||
if not demo_mode and not session.menu:
|
if not demo_mode and not session.menu:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
repository.logout()
|
repository.logout()
|
||||||
@@ -547,16 +715,17 @@ class ApplicationController(QObject):
|
|||||||
self._logout(message="登录状态已失效,请重新登录。")
|
self._logout(message="登录状态已失效,请重新登录。")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _logout(self, *, message: str = "") -> None:
|
def _logout(self, *, message: str = "") -> None:
|
||||||
"""Clear authenticated resources and return to the login window."""
|
"""Clear authenticated resources and return to the login window."""
|
||||||
|
|
||||||
calls = tuple(self.video_calls.values())
|
self._restore_video_preview(activate=False)
|
||||||
|
calls = tuple(self.video_calls.values())
|
||||||
for call in calls:
|
for call in calls:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
call.close()
|
call.close()
|
||||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||||
self.video_calls.clear()
|
self.video_calls.clear()
|
||||||
self.video_pending.clear()
|
self.video_pending.clear()
|
||||||
for dialog in self.demo_video_dialogs.values():
|
for dialog in self.demo_video_dialogs.values():
|
||||||
dialog.close()
|
dialog.close()
|
||||||
self.demo_video_dialogs.clear()
|
self.demo_video_dialogs.clear()
|
||||||
@@ -579,7 +748,8 @@ class ApplicationController(QObject):
|
|||||||
patient_id = payload.get("patient_id")
|
patient_id = payload.get("patient_id")
|
||||||
diagnosis_id = payload.get("diagnosis_id")
|
diagnosis_id = payload.get("diagnosis_id")
|
||||||
patient_name = str(payload.get("patient_name") or "患者")
|
patient_name = str(payload.get("patient_name") or "患者")
|
||||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||||
|
fallback_record = payload.get("record")
|
||||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||||
return
|
return
|
||||||
@@ -633,27 +803,49 @@ class ApplicationController(QObject):
|
|||||||
marker = object()
|
marker = object()
|
||||||
self.video_pending[call_key] = marker
|
self.video_pending[call_key] = marker
|
||||||
|
|
||||||
def get_ticket() -> Any:
|
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||||
return repository.get_call_ticket(
|
ticket = repository.get_call_ticket(
|
||||||
patient_id=int(patient_id),
|
patient_id=int(patient_id),
|
||||||
diagnosis_id=int(diagnosis_id),
|
diagnosis_id=int(diagnosis_id),
|
||||||
)
|
)
|
||||||
|
detail: Any = {}
|
||||||
|
detail_loader = getattr(repository, "patient_detail", None)
|
||||||
|
if callable(detail_loader):
|
||||||
|
try:
|
||||||
|
detail = detail_loader(int(diagnosis_id))
|
||||||
|
except Exception as error:
|
||||||
|
LOGGER.warning(
|
||||||
|
"patient detail could not be loaded for video call",
|
||||||
|
extra={
|
||||||
|
"diagnosis_id": diagnosis_id,
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
patient_case = _build_video_patient_case(
|
||||||
|
detail,
|
||||||
|
fallback_record,
|
||||||
|
diagnosis_id=diagnosis_id,
|
||||||
|
patient_id=patient_id,
|
||||||
|
patient_name=patient_name,
|
||||||
|
)
|
||||||
|
return ticket, patient_case
|
||||||
|
|
||||||
def request_ticket() -> None:
|
def request_ticket() -> None:
|
||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
run_async(
|
run_async(
|
||||||
get_ticket,
|
get_video_context,
|
||||||
on_success=lambda ticket: self._launch_video(
|
on_success=lambda context: self._launch_video(
|
||||||
ticket,
|
context[0],
|
||||||
diagnosis_id=diagnosis_id,
|
diagnosis_id=diagnosis_id,
|
||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
repository=repository,
|
repository=repository,
|
||||||
call_key=call_key,
|
call_key=call_key,
|
||||||
marker=marker,
|
marker=marker,
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
),
|
patient_case=context[1],
|
||||||
|
),
|
||||||
on_error=lambda error: self._video_ticket_error(
|
on_error=lambda error: self._video_ticket_error(
|
||||||
call_key,
|
call_key,
|
||||||
marker,
|
marker,
|
||||||
@@ -696,8 +888,9 @@ class ApplicationController(QObject):
|
|||||||
repository: Any,
|
repository: Any,
|
||||||
call_key: str,
|
call_key: str,
|
||||||
marker: object,
|
marker: object,
|
||||||
open_im: bool = False,
|
open_im: bool = False,
|
||||||
patient_name: str = "患者",
|
patient_name: str = "患者",
|
||||||
|
patient_case: Mapping[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
@@ -723,9 +916,13 @@ class ApplicationController(QObject):
|
|||||||
local_dist=video_dist_path(),
|
local_dist=video_dist_path(),
|
||||||
remote_url=self.config.video_web_url or None,
|
remote_url=self.config.video_web_url or None,
|
||||||
logger=logging.getLogger("doctor_workstation.video"),
|
logger=logging.getLogger("doctor_workstation.video"),
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
)
|
patient_case=patient_case,
|
||||||
|
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||||
|
self._open_video_diagnosis(current_id)
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
LOGGER.exception("video call could not be launched")
|
LOGGER.exception("video call could not be launched")
|
||||||
show_toast(
|
show_toast(
|
||||||
@@ -734,20 +931,123 @@ class ApplicationController(QObject):
|
|||||||
"danger",
|
"danger",
|
||||||
5600,
|
5600,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
self.video_calls[call_key] = call
|
self.video_calls[call_key] = call
|
||||||
qt_window = getattr(call, "qt_window", None)
|
qt_window = getattr(call, "qt_window", None)
|
||||||
if qt_window is not None:
|
if qt_window is not None:
|
||||||
qt_window.destroyed.connect(
|
qt_window.destroyed.connect(
|
||||||
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
|
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
|
||||||
key,
|
key,
|
||||||
expected,
|
expected,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||||
if self.video_calls.get(call_key) is call:
|
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||||
self.video_calls.pop(call_key, None)
|
|
||||||
|
shell = self.shell_window
|
||||||
|
if shell is None or self.current_repository is None:
|
||||||
|
return
|
||||||
|
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||||
|
if dialog is None:
|
||||||
|
return
|
||||||
|
call = self.video_calls.get(str(diagnosis_id))
|
||||||
|
video_window = getattr(call, "qt_window", None)
|
||||||
|
if video_window is not None:
|
||||||
|
self._show_video_preview(video_window, dialog)
|
||||||
|
|
||||||
|
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
||||||
|
"""Pin a compact call window above the modeless diagnosis drawer."""
|
||||||
|
|
||||||
|
current = self._video_preview_state
|
||||||
|
if current is not None and current.get("window") is not video_window:
|
||||||
|
self._restore_video_preview(activate=False)
|
||||||
|
|
||||||
|
self._video_preview_generation += 1
|
||||||
|
generation = self._video_preview_generation
|
||||||
|
if current is None or current.get("window") is not video_window:
|
||||||
|
try:
|
||||||
|
state = {
|
||||||
|
"window": video_window,
|
||||||
|
"geometry": video_window.geometry(),
|
||||||
|
"minimum_size": video_window.minimumSize(),
|
||||||
|
"maximized": video_window.isMaximized(),
|
||||||
|
"full_screen": video_window.isFullScreen(),
|
||||||
|
"stays_on_top": bool(
|
||||||
|
video_window.windowFlags()
|
||||||
|
& Qt.WindowType.WindowStaysOnTopHint
|
||||||
|
),
|
||||||
|
}
|
||||||
|
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
||||||
|
available = screen.availableGeometry()
|
||||||
|
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
||||||
|
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||||
|
margin = 18
|
||||||
|
|
||||||
|
video_window.showNormal()
|
||||||
|
video_window.setMinimumSize(440, 300)
|
||||||
|
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||||
|
video_window.resize(preview_width, preview_height)
|
||||||
|
video_window.move(
|
||||||
|
available.x() + available.width() - preview_width - margin,
|
||||||
|
available.y() + margin,
|
||||||
|
)
|
||||||
|
video_window.show()
|
||||||
|
self._video_preview_state = state
|
||||||
|
except RuntimeError:
|
||||||
|
self._video_preview_state = None
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog.finished.connect(
|
||||||
|
lambda _result, expected=generation: self._restore_video_preview(expected)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
video_window.raise_()
|
||||||
|
dialog.raise_()
|
||||||
|
dialog.activateWindow()
|
||||||
|
except RuntimeError:
|
||||||
|
self._video_preview_state = None
|
||||||
|
|
||||||
|
def _restore_video_preview(
|
||||||
|
self,
|
||||||
|
generation: int | None = None,
|
||||||
|
*,
|
||||||
|
activate: bool = True,
|
||||||
|
) -> None:
|
||||||
|
if generation is not None and generation != self._video_preview_generation:
|
||||||
|
return
|
||||||
|
state = self._video_preview_state
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
self._video_preview_state = None
|
||||||
|
self._video_preview_generation += 1
|
||||||
|
window = state.get("window")
|
||||||
|
try:
|
||||||
|
window.setWindowFlag(
|
||||||
|
Qt.WindowType.WindowStaysOnTopHint,
|
||||||
|
bool(state.get("stays_on_top")),
|
||||||
|
)
|
||||||
|
window.setMinimumSize(state["minimum_size"])
|
||||||
|
window.setGeometry(state["geometry"])
|
||||||
|
if state.get("full_screen"):
|
||||||
|
window.showFullScreen()
|
||||||
|
elif state.get("maximized"):
|
||||||
|
window.showMaximized()
|
||||||
|
else:
|
||||||
|
window.showNormal()
|
||||||
|
window.raise_()
|
||||||
|
if activate:
|
||||||
|
window.activateWindow()
|
||||||
|
except (AttributeError, RuntimeError):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||||
|
preview = self._video_preview_state
|
||||||
|
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
||||||
|
self._video_preview_state = None
|
||||||
|
self._video_preview_generation += 1
|
||||||
|
if self.video_calls.get(call_key) is call:
|
||||||
|
self.video_calls.pop(call_key, None)
|
||||||
|
|
||||||
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
||||||
if self.demo_video_dialogs.get(call_key) is dialog:
|
if self.demo_video_dialogs.get(call_key) is dialog:
|
||||||
@@ -775,18 +1075,27 @@ class ApplicationController(QObject):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _apply_window_icon(window: QWidget) -> None:
|
def _apply_window_icon(window: QWidget) -> None:
|
||||||
icon_file = app_icon_path()
|
icon_file = app_icon_path()
|
||||||
if icon_file.exists():
|
if icon_file.exists():
|
||||||
window.setWindowIcon(QIcon(str(icon_file)))
|
window.setWindowIcon(QIcon(str(icon_file)))
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def request_quit(self) -> None:
|
||||||
|
"""Queue a normal application exit so owned resources are released."""
|
||||||
|
|
||||||
|
if self._shutting_down:
|
||||||
|
return
|
||||||
|
QTimer.singleShot(0, self.application.quit)
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
"""Invalidate asynchronous restoration and release owned resources."""
|
"""Invalidate asynchronous restoration and release owned resources."""
|
||||||
|
|
||||||
if self._shutting_down:
|
if self._shutting_down:
|
||||||
return
|
return
|
||||||
self._shutting_down = True
|
self._shutting_down = True
|
||||||
self._cancel_session_restore()
|
self._cancel_session_restore()
|
||||||
set_authentication_expired_handler(None)
|
self.app_updater.shutdown()
|
||||||
calls = tuple(self.video_calls.values())
|
set_authentication_expired_handler(None)
|
||||||
|
self._restore_video_preview(activate=False)
|
||||||
|
calls = tuple(self.video_calls.values())
|
||||||
for call in calls:
|
for call in calls:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
call.close()
|
call.close()
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from doctor_workstation import DEBUG_MODE, ONLINE_API_BASE_URL
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
except ImportError: # pragma: no cover - optional during pure unit tests
|
except ImportError: # pragma: no cover - optional during pure unit tests
|
||||||
@@ -90,7 +92,8 @@ class AppConfig:
|
|||||||
"""Runtime configuration loaded from environment and user preferences."""
|
"""Runtime configuration loaded from environment and user preferences."""
|
||||||
|
|
||||||
api_base_url: str = ""
|
api_base_url: str = ""
|
||||||
demo_mode: bool = True
|
demo_mode: bool = False
|
||||||
|
debug_mode: bool = DEBUG_MODE
|
||||||
video_mode: str = "embedded"
|
video_mode: str = "embedded"
|
||||||
video_web_url: str = ""
|
video_web_url: str = ""
|
||||||
verify_ssl: bool = True
|
verify_ssl: bool = True
|
||||||
@@ -115,18 +118,28 @@ class AppConfig:
|
|||||||
if load_dotenv is not None:
|
if load_dotenv is not None:
|
||||||
load_dotenv(dotenv_path=env_file, override=False)
|
load_dotenv(dotenv_path=env_file, override=False)
|
||||||
|
|
||||||
raw_url = os.getenv("DOCTOR_API_BASE_URL", "")
|
debug_mode = bool(DEBUG_MODE)
|
||||||
|
raw_url = (
|
||||||
|
os.getenv("DOCTOR_API_BASE_URL", "") if debug_mode else ONLINE_API_BASE_URL
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
api_url = normalize_api_base_url(raw_url)
|
api_url = normalize_api_base_url(raw_url)
|
||||||
except ValueError:
|
except ValueError as error:
|
||||||
|
if not debug_mode:
|
||||||
|
raise ValueError("ONLINE_API_BASE_URL 必须是有效的 HTTP(S) 域名") from error
|
||||||
api_url = ""
|
api_url = ""
|
||||||
|
if not debug_mode and not api_url:
|
||||||
|
raise ValueError("正式模式下 ONLINE_API_BASE_URL 不能为空")
|
||||||
|
|
||||||
config = cls(
|
config = cls(
|
||||||
api_base_url=api_url,
|
api_base_url=api_url,
|
||||||
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), True),
|
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), False) if debug_mode else False,
|
||||||
|
debug_mode=debug_mode,
|
||||||
video_mode=os.getenv("DOCTOR_VIDEO_MODE", "embedded").strip().lower(),
|
video_mode=os.getenv("DOCTOR_VIDEO_MODE", "embedded").strip().lower(),
|
||||||
video_web_url=os.getenv("DOCTOR_VIDEO_WEB_URL", "").strip(),
|
video_web_url=os.getenv("DOCTOR_VIDEO_WEB_URL", "").strip(),
|
||||||
verify_ssl=_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True),
|
verify_ssl=(
|
||||||
|
_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True) if debug_mode else True
|
||||||
|
),
|
||||||
request_timeout=_safe_timeout(os.getenv("DOCTOR_REQUEST_TIMEOUT")),
|
request_timeout=_safe_timeout(os.getenv("DOCTOR_REQUEST_TIMEOUT")),
|
||||||
log_level=os.getenv("DOCTOR_LOG_LEVEL", "INFO").strip().upper(),
|
log_level=os.getenv("DOCTOR_LOG_LEVEL", "INFO").strip().upper(),
|
||||||
)
|
)
|
||||||
@@ -137,7 +150,9 @@ class AppConfig:
|
|||||||
payload = json.loads(self.preferences_file.read_text(encoding="utf-8"))
|
payload = json.loads(self.preferences_file.read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError, TypeError):
|
except (OSError, ValueError, TypeError):
|
||||||
return self
|
return self
|
||||||
allowed = {item.name for item in fields(self)}
|
allowed = {item.name for item in fields(self)} - {"debug_mode"}
|
||||||
|
if not self.debug_mode:
|
||||||
|
allowed -= {"api_base_url", "demo_mode", "verify_ssl"}
|
||||||
clean: dict[str, Any] = {key: value for key, value in payload.items() if key in allowed}
|
clean: dict[str, Any] = {key: value for key, value in payload.items() if key in allowed}
|
||||||
if "api_base_url" in clean:
|
if "api_base_url" in clean:
|
||||||
try:
|
try:
|
||||||
@@ -159,12 +174,18 @@ class AppConfig:
|
|||||||
target = self.preferences_file
|
target = self.preferences_file
|
||||||
temporary = target.with_suffix(".tmp")
|
temporary = target.with_suffix(".tmp")
|
||||||
payload = asdict(self)
|
payload = asdict(self)
|
||||||
|
payload.pop("debug_mode", None)
|
||||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
with suppress(OSError):
|
with suppress(OSError):
|
||||||
os.chmod(temporary, 0o600)
|
os.chmod(temporary, 0o600)
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
|
|
||||||
def with_updates(self, **changes: Any) -> AppConfig:
|
def with_updates(self, **changes: Any) -> AppConfig:
|
||||||
|
changes.pop("debug_mode", None)
|
||||||
|
if not self.debug_mode:
|
||||||
|
changes.pop("api_base_url", None)
|
||||||
|
changes.pop("demo_mode", None)
|
||||||
|
changes.pop("verify_ssl", None)
|
||||||
if "api_base_url" in changes:
|
if "api_base_url" in changes:
|
||||||
changes["api_base_url"] = normalize_api_base_url(str(changes["api_base_url"]))
|
changes["api_base_url"] = normalize_api_base_url(str(changes["api_base_url"]))
|
||||||
if "video_mode" in changes and changes["video_mode"] not in {"embedded", "browser"}:
|
if "video_mode" in changes and changes["video_mode"] not in {"embedded", "browser"}:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1705,6 +1705,24 @@ class DemoDoctorRepository:
|
|||||||
}
|
}
|
||||||
return deepcopy(dictionaries.get(dictionary_type, []))
|
return deepcopy(dictionaries.get(dictionary_type, []))
|
||||||
|
|
||||||
|
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||||
|
"""Demo mode never queues server-side chat notifications."""
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_dictionaries(
|
||||||
|
self, dictionary_types: Sequence[str]
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Mirror the remote batch dictionary contract used by readonly screens."""
|
||||||
|
|
||||||
|
result: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for dictionary_type in dictionary_types:
|
||||||
|
clean = str(dictionary_type or "").strip()
|
||||||
|
if not clean or clean in result:
|
||||||
|
continue
|
||||||
|
result[clean] = self.get_dictionary(clean)
|
||||||
|
return result
|
||||||
|
|
||||||
def list_patients(
|
def list_patients(
|
||||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||||
) -> PageResult[Patient]:
|
) -> PageResult[Patient]:
|
||||||
|
|||||||
@@ -455,6 +455,14 @@ class DoctorRepository(Protocol):
|
|||||||
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
|
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
|
||||||
"""Return one configuration dictionary."""
|
"""Return one configuration dictionary."""
|
||||||
|
|
||||||
|
def get_dictionaries(
|
||||||
|
self, dictionary_types: Sequence[str]
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Return several configuration dictionaries in one call."""
|
||||||
|
|
||||||
|
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||||
|
"""Consume this account's queued chat notifications."""
|
||||||
|
|
||||||
def get_patient_order(self, order_id: int) -> dict[str, Any]:
|
def get_patient_order(self, order_id: int) -> dict[str, Any]:
|
||||||
"""Return a patient-scoped order detail."""
|
"""Return a patient-scoped order detail."""
|
||||||
|
|
||||||
@@ -1761,6 +1769,40 @@ class RemoteDoctorRepository:
|
|||||||
payload = self.client.get("config/dict", {"type": clean})
|
payload = self.client.get("config/dict", {"type": clean})
|
||||||
return _dictionary_rows(payload, clean)
|
return _dictionary_rows(payload, clean)
|
||||||
|
|
||||||
|
def get_dictionaries(
|
||||||
|
self, dictionary_types: Sequence[str]
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Load several dictionaries with one request.
|
||||||
|
|
||||||
|
``ConfigLogic::getDictByType`` splits ``type`` on commas and answers
|
||||||
|
``{type_value: [rows...]}``, so readonly screens can translate every
|
||||||
|
dictionary field without one round trip per type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
wanted = [
|
||||||
|
str(dictionary_type or "").strip()
|
||||||
|
for dictionary_type in dictionary_types
|
||||||
|
if str(dictionary_type or "").strip()
|
||||||
|
]
|
||||||
|
ordered = list(dict.fromkeys(wanted))
|
||||||
|
if not ordered:
|
||||||
|
return {}
|
||||||
|
payload = self.client.get("config/dict", {"type": ",".join(ordered)})
|
||||||
|
return {
|
||||||
|
dictionary_type: _dictionary_rows(payload, dictionary_type)
|
||||||
|
for dictionary_type in ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||||
|
"""Consume the queued chat notifications for the signed-in account.
|
||||||
|
|
||||||
|
与后台一致:``ChatController::notifications`` 取一次即消费(服务端删除缓存),
|
||||||
|
所以每条通知只会被送到一个已登录的客户端一次,不能重复轮询后再补偿。
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = self.client.get("chat/notifications")
|
||||||
|
return _mapping_rows(payload)
|
||||||
|
|
||||||
def list_patients(
|
def list_patients(
|
||||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||||
) -> PageResult[Patient]:
|
) -> PageResult[Patient]:
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
"""登录后常驻的聊天通知:患者打开会话、离开会话、面诊结束。
|
||||||
|
|
||||||
|
与后台 ``admin/src/components/chat-notify-toast`` 同一套合同:轮询
|
||||||
|
``chat/notifications``(服务端取一次即消费),把结果堆成右上角卡片,点击卡片进入
|
||||||
|
对应工作面,关闭按钮单独移除。桌面端额外做了一件网页做不到的事——新通知到达时让
|
||||||
|
任务栏闪一下,医生切到别的窗口也能看见。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .widgets import display_text, first_value, run_async
|
||||||
|
|
||||||
|
# 后台 ChatNotifyLogic 写入的三种 type。
|
||||||
|
PATIENT_OPENED_CHAT = "patient_opened_chat"
|
||||||
|
PATIENT_LEFT_CHAT = "patient_left_chat"
|
||||||
|
CONSULTATION_COMPLETE = "consultation_complete"
|
||||||
|
|
||||||
|
_TITLES: dict[str, str] = {
|
||||||
|
PATIENT_OPENED_CHAT: "患者打开会话",
|
||||||
|
PATIENT_LEFT_CHAT: "患者离开会话",
|
||||||
|
CONSULTATION_COMPLETE: "面诊结束",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MAX_CARDS = 5
|
||||||
|
_POLL_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
|
CHAT_NOTIFICATION_QSS = """
|
||||||
|
QFrame#ChatNotifyCard {
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border: 1px solid #BBF0CE;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
QFrame#ChatNotifyCard[kind="left"] { border-color: #D8DEEE; }
|
||||||
|
QFrame#ChatNotifyCard[kind="complete"] { border-color: #C3D6FF; }
|
||||||
|
QLabel#ChatNotifyBadge {
|
||||||
|
min-width: 34px;
|
||||||
|
max-width: 34px;
|
||||||
|
min-height: 34px;
|
||||||
|
max-height: 34px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
background-color: #22C55E;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
QLabel#ChatNotifyBadge[kind="left"] { background-color: #8A94B3; }
|
||||||
|
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #3B82F6; }
|
||||||
|
QLabel#ChatNotifyTitle { color: #1F2A44; font-size: 13px; font-weight: 700; }
|
||||||
|
QLabel#ChatNotifyDesc { color: #4A5878; font-size: 12px; }
|
||||||
|
QLabel#ChatNotifyTime { color: #8A94B3; font-size: 11px; }
|
||||||
|
QPushButton#ChatNotifyOpen {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #3F4E75;
|
||||||
|
background-color: #F4F6FC;
|
||||||
|
border: 1px solid #DDE3F2;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QPushButton#ChatNotifyOpen:hover {
|
||||||
|
color: #4451E2;
|
||||||
|
background-color: #EEF1FF;
|
||||||
|
border-color: #8D9BFF;
|
||||||
|
}
|
||||||
|
QPushButton#ChatNotifyClose {
|
||||||
|
min-width: 22px;
|
||||||
|
max-width: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
max-height: 22px;
|
||||||
|
color: #8A94B3;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
QPushButton#ChatNotifyClose:hover { color: #4A5878; background-color: #EDF0F7; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChatNotification:
|
||||||
|
"""One queued notification, normalised from the admin payload."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
patient_name: str
|
||||||
|
patient_id: str
|
||||||
|
doctor_name: str
|
||||||
|
diagnosis_id: int
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str:
|
||||||
|
return _TITLES.get(self.kind, "系统通知")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description(self) -> str:
|
||||||
|
patient = self.patient_name or "患者"
|
||||||
|
if self.kind == CONSULTATION_COMPLETE:
|
||||||
|
doctor = self.doctor_name or "医生"
|
||||||
|
return f"{patient} 的面诊已由 {doctor} 完成,请及时跟进"
|
||||||
|
if self.kind == PATIENT_LEFT_CHAT:
|
||||||
|
return f"{patient} 已离开问诊会话页面"
|
||||||
|
return f"{patient} 已打开与您的会话,请及时查看"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_text(self) -> str:
|
||||||
|
if self.kind == CONSULTATION_COMPLETE:
|
||||||
|
return "查看诊单"
|
||||||
|
if self.kind == PATIENT_OPENED_CHAT:
|
||||||
|
return "去接诊台"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_notification(row: Any) -> ChatNotification | None:
|
||||||
|
"""Normalise one server row; unknown or id-less rows are dropped."""
|
||||||
|
|
||||||
|
if not isinstance(row, Mapping):
|
||||||
|
return None
|
||||||
|
identifier = str(first_value(row, "id", default="") or "").strip()
|
||||||
|
kind = str(first_value(row, "type", default=PATIENT_OPENED_CHAT) or "").strip()
|
||||||
|
if not identifier or kind not in _TITLES:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
diagnosis_id = int(first_value(row, "diagnosis_id", default=0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
diagnosis_id = 0
|
||||||
|
try:
|
||||||
|
created_at = int(first_value(row, "created_at", default=0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
created_at = 0
|
||||||
|
return ChatNotification(
|
||||||
|
id=identifier,
|
||||||
|
kind=kind,
|
||||||
|
patient_name=display_text(first_value(row, "patient_name"), ""),
|
||||||
|
patient_id=str(first_value(row, "patient_id", default="") or "").strip(),
|
||||||
|
doctor_name=display_text(first_value(row, "doctor_name"), ""),
|
||||||
|
diagnosis_id=diagnosis_id,
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def relative_time(created_at: int, *, now: float | None = None) -> str:
|
||||||
|
"""Match the admin card's 刚刚 / N 分钟前 wording."""
|
||||||
|
|
||||||
|
if not created_at:
|
||||||
|
return ""
|
||||||
|
current = datetime.now().timestamp() if now is None else now
|
||||||
|
delta = max(0, int(current - created_at))
|
||||||
|
if delta < 60:
|
||||||
|
return "刚刚"
|
||||||
|
if delta < 3600:
|
||||||
|
return f"{delta // 60} 分钟前"
|
||||||
|
if delta < 86400:
|
||||||
|
return f"{delta // 3600} 小时前"
|
||||||
|
return datetime.fromtimestamp(created_at).strftime("%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
class _NotificationCard(QFrame):
|
||||||
|
"""One dismissible card; the whole surface is a click target."""
|
||||||
|
|
||||||
|
opened = Signal(str)
|
||||||
|
dismissed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, notification: ChatNotification, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.notification = notification
|
||||||
|
self.setObjectName("ChatNotifyCard")
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||||
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
kind = (
|
||||||
|
"complete"
|
||||||
|
if notification.kind == CONSULTATION_COMPLETE
|
||||||
|
else "left"
|
||||||
|
if notification.kind == PATIENT_LEFT_CHAT
|
||||||
|
else "opened"
|
||||||
|
)
|
||||||
|
self.setProperty("kind", kind)
|
||||||
|
|
||||||
|
root = QHBoxLayout(self)
|
||||||
|
root.setContentsMargins(13, 11, 10, 12)
|
||||||
|
root.setSpacing(11)
|
||||||
|
badge = QLabel("✓" if kind == "complete" else "→" if kind == "left" else "话")
|
||||||
|
badge.setObjectName("ChatNotifyBadge")
|
||||||
|
badge.setProperty("kind", kind)
|
||||||
|
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
root.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
body = QVBoxLayout()
|
||||||
|
body.setContentsMargins(0, 0, 0, 0)
|
||||||
|
body.setSpacing(3)
|
||||||
|
title = QLabel(notification.title)
|
||||||
|
title.setObjectName("ChatNotifyTitle")
|
||||||
|
body.addWidget(title)
|
||||||
|
description = QLabel(notification.description)
|
||||||
|
description.setObjectName("ChatNotifyDesc")
|
||||||
|
description.setWordWrap(True)
|
||||||
|
body.addWidget(description)
|
||||||
|
self.time_label = QLabel(relative_time(notification.created_at))
|
||||||
|
self.time_label.setObjectName("ChatNotifyTime")
|
||||||
|
body.addWidget(self.time_label)
|
||||||
|
if notification.action_text:
|
||||||
|
actions = QHBoxLayout()
|
||||||
|
actions.setContentsMargins(0, 4, 0, 0)
|
||||||
|
actions.setSpacing(8)
|
||||||
|
self.open_button = QPushButton(notification.action_text)
|
||||||
|
self.open_button.setObjectName("ChatNotifyOpen")
|
||||||
|
self.open_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.open_button.clicked.connect(lambda: self.opened.emit(self.notification.id))
|
||||||
|
actions.addWidget(self.open_button)
|
||||||
|
actions.addStretch(1)
|
||||||
|
body.addLayout(actions)
|
||||||
|
root.addLayout(body, 1)
|
||||||
|
|
||||||
|
self.close_button = QPushButton("×")
|
||||||
|
self.close_button.setObjectName("ChatNotifyClose")
|
||||||
|
self.close_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.close_button.setToolTip("忽略这条通知")
|
||||||
|
self.close_button.clicked.connect(lambda: self.dismissed.emit(self.notification.id))
|
||||||
|
root.addWidget(self.close_button, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
def refresh_time(self) -> None:
|
||||||
|
self.time_label.setText(relative_time(self.notification.created_at))
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self.opened.emit(self.notification.id)
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatNotificationCenter(QWidget):
|
||||||
|
"""Polls the server queue and stacks cards over the shell's top-right corner."""
|
||||||
|
|
||||||
|
notification_activated = Signal(object)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: Any,
|
||||||
|
host: QWidget,
|
||||||
|
*,
|
||||||
|
interval_ms: int = _POLL_INTERVAL_MS,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(host)
|
||||||
|
self.repository = repository
|
||||||
|
self.setObjectName("ChatNotifyLayer")
|
||||||
|
self.setStyleSheet(CHAT_NOTIFICATION_QSS)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
|
||||||
|
self._cards: dict[str, _NotificationCard] = {}
|
||||||
|
self._pending: list[ChatNotification] = []
|
||||||
|
self._loading = False
|
||||||
|
self._generation = 0
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
self._layout = layout
|
||||||
|
self.hide()
|
||||||
|
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setInterval(max(1_000, int(interval_ms)))
|
||||||
|
self._timer.timeout.connect(self.poll)
|
||||||
|
self._clock = QTimer(self)
|
||||||
|
self._clock.setInterval(30_000)
|
||||||
|
self._clock.timeout.connect(self._refresh_times)
|
||||||
|
host.installEventFilter(self)
|
||||||
|
|
||||||
|
# ----- polling ------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self._timer.isActive():
|
||||||
|
self._timer.start()
|
||||||
|
self._clock.start()
|
||||||
|
self.poll()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._generation += 1
|
||||||
|
self._timer.stop()
|
||||||
|
self._clock.stop()
|
||||||
|
|
||||||
|
def poll(self) -> None:
|
||||||
|
"""Ask for queued notifications; overlapping polls are skipped.
|
||||||
|
|
||||||
|
服务端取一次即消费,重叠请求会让通知丢在被丢弃的那次响应里。
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._loading:
|
||||||
|
return
|
||||||
|
fetch = getattr(self.repository, "list_chat_notifications", None)
|
||||||
|
if not callable(fetch):
|
||||||
|
return
|
||||||
|
self._loading = True
|
||||||
|
generation = self._generation
|
||||||
|
run_async(
|
||||||
|
fetch,
|
||||||
|
on_success=lambda rows: self._loaded(generation, rows),
|
||||||
|
on_error=lambda _error: None, # 静默失败,避免打断问诊
|
||||||
|
on_finished=self._finished,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finished(self) -> None:
|
||||||
|
self._loading = False
|
||||||
|
|
||||||
|
def _loaded(self, generation: int, rows: Any) -> None:
|
||||||
|
if generation != self._generation:
|
||||||
|
return
|
||||||
|
self.add_notifications(rows)
|
||||||
|
|
||||||
|
# ----- cards --------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending(self) -> list[ChatNotification]:
|
||||||
|
return list(self._pending)
|
||||||
|
|
||||||
|
def add_notifications(self, rows: Any) -> int:
|
||||||
|
"""Show new cards; returns how many were actually added."""
|
||||||
|
|
||||||
|
if isinstance(rows, Mapping) or not isinstance(rows, Sequence):
|
||||||
|
candidates: Sequence[Any] = [rows]
|
||||||
|
else:
|
||||||
|
candidates = rows
|
||||||
|
added = 0
|
||||||
|
for row in candidates:
|
||||||
|
notification = parse_notification(row)
|
||||||
|
if notification is None or notification.id in self._cards:
|
||||||
|
continue
|
||||||
|
self._add_card(notification)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
self._trim()
|
||||||
|
self._relayout()
|
||||||
|
self._alert_taskbar()
|
||||||
|
return added
|
||||||
|
|
||||||
|
def _add_card(self, notification: ChatNotification) -> None:
|
||||||
|
card = _NotificationCard(notification, self)
|
||||||
|
card.opened.connect(self._activate)
|
||||||
|
card.dismissed.connect(self.dismiss)
|
||||||
|
self._cards[notification.id] = card
|
||||||
|
self._pending.insert(0, notification)
|
||||||
|
self._layout.insertWidget(0, card)
|
||||||
|
|
||||||
|
def _trim(self) -> None:
|
||||||
|
while len(self._pending) > _MAX_CARDS:
|
||||||
|
self.dismiss(self._pending[-1].id, relayout=False)
|
||||||
|
|
||||||
|
def dismiss(self, notification_id: str, *, relayout: bool = True) -> None:
|
||||||
|
card = self._cards.pop(str(notification_id), None)
|
||||||
|
self._pending = [item for item in self._pending if item.id != str(notification_id)]
|
||||||
|
if card is not None:
|
||||||
|
self._layout.removeWidget(card)
|
||||||
|
card.hide()
|
||||||
|
card.deleteLater()
|
||||||
|
if relayout:
|
||||||
|
self._relayout()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for notification_id in list(self._cards):
|
||||||
|
self.dismiss(notification_id, relayout=False)
|
||||||
|
self._relayout()
|
||||||
|
|
||||||
|
def _activate(self, notification_id: str) -> None:
|
||||||
|
card = self._cards.get(str(notification_id))
|
||||||
|
if card is None:
|
||||||
|
return
|
||||||
|
notification = card.notification
|
||||||
|
self.dismiss(notification_id)
|
||||||
|
self.notification_activated.emit(notification)
|
||||||
|
|
||||||
|
def _refresh_times(self) -> None:
|
||||||
|
for card in self._cards.values():
|
||||||
|
card.refresh_time()
|
||||||
|
|
||||||
|
def _alert_taskbar(self) -> None:
|
||||||
|
"""Flash the taskbar entry when the doctor is working in another window."""
|
||||||
|
|
||||||
|
application = QApplication.instance()
|
||||||
|
window = self.window()
|
||||||
|
if application is None or window is None or window.isActiveWindow():
|
||||||
|
return
|
||||||
|
application.alert(window, 3_000)
|
||||||
|
|
||||||
|
# ----- placement ----------------------------------------------------
|
||||||
|
|
||||||
|
def _relayout(self) -> None:
|
||||||
|
if not self._cards:
|
||||||
|
self.hide()
|
||||||
|
return
|
||||||
|
host = self.parentWidget()
|
||||||
|
if host is None:
|
||||||
|
return
|
||||||
|
width = min(360, max(260, host.width() - 48))
|
||||||
|
self.setFixedWidth(width)
|
||||||
|
self.adjustSize()
|
||||||
|
self.move(max(12, host.width() - width - 24), 74)
|
||||||
|
self.show()
|
||||||
|
self.raise_()
|
||||||
|
|
||||||
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt virtual
|
||||||
|
if watched is self.parentWidget() and event.type() in {
|
||||||
|
QEvent.Type.Resize,
|
||||||
|
QEvent.Type.Show,
|
||||||
|
}:
|
||||||
|
self._relayout()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CHAT_NOTIFICATION_QSS",
|
||||||
|
"CONSULTATION_COMPLETE",
|
||||||
|
"PATIENT_LEFT_CHAT",
|
||||||
|
"PATIENT_OPENED_CHAT",
|
||||||
|
"ChatNotification",
|
||||||
|
"ChatNotificationCenter",
|
||||||
|
"parse_notification",
|
||||||
|
"relative_time",
|
||||||
|
]
|
||||||
@@ -7,12 +7,25 @@ object names and dynamic properties so adjacent pages keep their own styling.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import weakref
|
import weakref
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QDate, QPointF, QRect, QRectF, QSize, Qt, QThread, QTimer, QUrl, Signal
|
from PySide6.QtCore import (
|
||||||
|
QDate,
|
||||||
|
QEvent,
|
||||||
|
QPointF,
|
||||||
|
QRect,
|
||||||
|
QRectF,
|
||||||
|
QSize,
|
||||||
|
Qt,
|
||||||
|
QThread,
|
||||||
|
QTimer,
|
||||||
|
QUrl,
|
||||||
|
Signal,
|
||||||
|
)
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
QHideEvent,
|
QHideEvent,
|
||||||
@@ -23,7 +36,7 @@ from PySide6.QtGui import (
|
|||||||
QResizeEvent,
|
QResizeEvent,
|
||||||
QShowEvent,
|
QShowEvent,
|
||||||
)
|
)
|
||||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractButton,
|
QAbstractButton,
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
@@ -50,6 +63,8 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .diagnosis_editors import FlowLayout
|
from .diagnosis_editors import FlowLayout
|
||||||
|
from .diagnosis_media import shared_image_manager
|
||||||
|
from .diagnosis_terms import FIELD_DICTIONARIES, format_timestamp, shared_terms
|
||||||
|
|
||||||
DIAGNOSIS_QSS = r"""
|
DIAGNOSIS_QSS = r"""
|
||||||
QDialog#DiagnosisDialogRoot {
|
QDialog#DiagnosisDialogRoot {
|
||||||
@@ -1243,7 +1258,7 @@ class DiagnosisLineEdit(QLineEdit):
|
|||||||
self.setText(text)
|
self.setText(text)
|
||||||
|
|
||||||
|
|
||||||
class DiagnosisTextEdit(QPlainTextEdit):
|
class DiagnosisTextEdit(QPlainTextEdit):
|
||||||
"""Multiline editor using the same scoped field state as line inputs."""
|
"""Multiline editor using the same scoped field state as line inputs."""
|
||||||
|
|
||||||
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
||||||
@@ -1259,11 +1274,113 @@ class DiagnosisTextEdit(QPlainTextEdit):
|
|||||||
# QPlainTextEdit defaults to ~80 columns; that blows past the drawer width.
|
# QPlainTextEdit defaults to ~80 columns; that blows past the drawer width.
|
||||||
return QSize(48, self._hint_height)
|
return QSize(48, self._hint_height)
|
||||||
|
|
||||||
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
|
||||||
return QSize(160, self._hint_height)
|
return QSize(160, self._hint_height)
|
||||||
|
|
||||||
|
|
||||||
class DiagnosisComboBox(QComboBox):
|
class ExpandableDiagnosisTextEdit(DiagnosisTextEdit):
|
||||||
|
"""An inline long-text editor without an inner scrollbar when expanded."""
|
||||||
|
|
||||||
|
def __init__(self, rows: int = 3, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(rows, parent)
|
||||||
|
self._collapsed_height = self._hint_height
|
||||||
|
self._caption = "内容"
|
||||||
|
self._expand_host: QWidget | None = None
|
||||||
|
self.expand_button = QPushButton("展开全部", self)
|
||||||
|
self.expand_button.setObjectName("DiagnosisTextExpandButton")
|
||||||
|
self.expand_button.setProperty("variant", "link")
|
||||||
|
self.expand_button.setCheckable(True)
|
||||||
|
self.expand_button.setAutoDefault(False)
|
||||||
|
self.expand_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.expand_button.setToolTip("展开文本框,查看和编辑全部内容")
|
||||||
|
self.expand_button.hide()
|
||||||
|
self.expand_button.toggled.connect(self._toggle_expanded)
|
||||||
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self._height_sync = QTimer(self)
|
||||||
|
self._height_sync.setSingleShot(True)
|
||||||
|
self._height_sync.setInterval(0)
|
||||||
|
self._height_sync.timeout.connect(self._sync_content_height)
|
||||||
|
self.textChanged.connect(self._height_sync.start)
|
||||||
|
self.document().documentLayout().documentSizeChanged.connect(
|
||||||
|
lambda _size: self._height_sync.start()
|
||||||
|
)
|
||||||
|
|
||||||
|
def wrap_with_expander(self, caption: str) -> QWidget:
|
||||||
|
"""Keep the original plain-text editor as the form's save/readonly target."""
|
||||||
|
|
||||||
|
if self._expand_host is None:
|
||||||
|
self._caption = caption
|
||||||
|
self.setAccessibleName(caption)
|
||||||
|
self.expand_button.setAccessibleName(f"展开{caption}全部内容")
|
||||||
|
self._expand_host = QWidget()
|
||||||
|
self._expand_host.setMinimumWidth(0)
|
||||||
|
self._expand_host.setFocusProxy(self)
|
||||||
|
layout = QVBoxLayout(self._expand_host)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(2)
|
||||||
|
layout.addWidget(self)
|
||||||
|
layout.addWidget(self.expand_button, 0, Qt.AlignmentFlag.AlignRight)
|
||||||
|
return self._expand_host
|
||||||
|
|
||||||
|
def setPlainText(self, text: str) -> None: # noqa: N802 - Qt API
|
||||||
|
# A newly loaded record starts compact; typing/paste/undo keep its state.
|
||||||
|
self.expand_button.setChecked(False)
|
||||||
|
super().setPlainText(text)
|
||||||
|
|
||||||
|
def _toggle_expanded(self, expanded: bool) -> None:
|
||||||
|
self.expand_button.setText("收起" if expanded else "展开全部")
|
||||||
|
self.expand_button.setAccessibleName(
|
||||||
|
f"收起{self._caption}" if expanded else f"展开{self._caption}全部内容"
|
||||||
|
)
|
||||||
|
self.expand_button.setToolTip(
|
||||||
|
"收起为紧凑文本框,内容不会丢失" if expanded else "展开文本框,查看和编辑全部内容"
|
||||||
|
)
|
||||||
|
self.setVerticalScrollBarPolicy(
|
||||||
|
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||||
|
if expanded
|
||||||
|
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||||
|
)
|
||||||
|
self.verticalScrollBar().setValue(0)
|
||||||
|
self._height_sync.start()
|
||||||
|
|
||||||
|
def _sync_content_height(self) -> None:
|
||||||
|
expanded = self.expand_button.isChecked()
|
||||||
|
document = self.document()
|
||||||
|
document_layout = document.documentLayout()
|
||||||
|
# QPlainTextDocumentLayout.documentSize().height() counts lines, not
|
||||||
|
# pixels. Measure the actual wrapped blocks, including offscreen ones.
|
||||||
|
height = self.height() - self.viewport().height() + 2 * document.documentMargin()
|
||||||
|
block = document.begin()
|
||||||
|
while block.isValid():
|
||||||
|
height += document_layout.blockBoundingRect(block).height()
|
||||||
|
if not expanded and height > self._collapsed_height:
|
||||||
|
break
|
||||||
|
block = block.next()
|
||||||
|
self.expand_button.setVisible(expanded or height > self._collapsed_height)
|
||||||
|
target = max(self._collapsed_height, ceil(height)) if expanded else self._collapsed_height
|
||||||
|
if self._hint_height != target:
|
||||||
|
self._hint_height = target
|
||||||
|
self.setFixedHeight(target)
|
||||||
|
self.updateGeometry()
|
||||||
|
|
||||||
|
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||||||
|
super().resizeEvent(event)
|
||||||
|
if hasattr(self, "_height_sync"):
|
||||||
|
self._height_sync.start()
|
||||||
|
|
||||||
|
def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt API
|
||||||
|
super().showEvent(event)
|
||||||
|
self._height_sync.start()
|
||||||
|
|
||||||
|
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||||||
|
super().changeEvent(event)
|
||||||
|
if event.type() in (QEvent.Type.FontChange, QEvent.Type.StyleChange) and hasattr(
|
||||||
|
self, "_height_sync"
|
||||||
|
):
|
||||||
|
self._height_sync.start()
|
||||||
|
|
||||||
|
|
||||||
|
class DiagnosisComboBox(QComboBox):
|
||||||
"""Choice editor with the legacy text API used by diagnosis saving."""
|
"""Choice editor with the legacy text API used by diagnosis saving."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -2467,7 +2584,7 @@ class _RemoteImageButton(QPushButton):
|
|||||||
self._fallback_size = QSize(maximum_size) if cover else QSize(132, 64)
|
self._fallback_size = QSize(maximum_size) if cover else QSize(132, 64)
|
||||||
self._fallback_text = fallback_text
|
self._fallback_text = fallback_text
|
||||||
self._cover = cover
|
self._cover = cover
|
||||||
self._manager = QNetworkAccessManager(self)
|
self._manager = shared_image_manager(self)
|
||||||
self._reply: QNetworkReply | None = None
|
self._reply: QNetworkReply | None = None
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
self._rendered_pixmap = QPixmap()
|
self._rendered_pixmap = QPixmap()
|
||||||
@@ -2508,6 +2625,18 @@ class _RemoteImageButton(QPushButton):
|
|||||||
reply.deleteLater()
|
reply.deleteLater()
|
||||||
return self._generation
|
return self._generation
|
||||||
|
|
||||||
|
def abort_pending_request(self) -> None:
|
||||||
|
"""Let an owner stop this download before it tears the thumbnail down."""
|
||||||
|
|
||||||
|
self._invalidate_request()
|
||||||
|
|
||||||
|
def event(self, event: QEvent) -> bool: # noqa: N802 - Qt virtual
|
||||||
|
# Nothing is left to paint once the thumbnail is being deleted, so stop
|
||||||
|
# the download instead of letting it run against a dying widget.
|
||||||
|
if event.type() in {QEvent.Type.DeferredDelete, QEvent.Type.Close}:
|
||||||
|
self._invalidate_request()
|
||||||
|
return super().event(event)
|
||||||
|
|
||||||
def load_url(self, source: str) -> None:
|
def load_url(self, source: str) -> None:
|
||||||
self._source = str(source).strip()
|
self._source = str(source).strip()
|
||||||
generation = self._invalidate_request()
|
generation = self._invalidate_request()
|
||||||
@@ -3116,6 +3245,8 @@ class CaseGrid(QFrame):
|
|||||||
"2": "复诊",
|
"2": "复诊",
|
||||||
}.get(diagnosis_type, diagnosis_type or "病例")
|
}.get(diagnosis_type, diagnosis_type or "病例")
|
||||||
diagnosis_date = _pick(diagnosis, "diagnosis_date", "create_time", default="—")
|
diagnosis_date = _pick(diagnosis, "diagnosis_date", "create_time", default="—")
|
||||||
|
# create_time 是 Unix 秒级时间戳,直接显示会变成一串数字。
|
||||||
|
diagnosis_date = format_timestamp(diagnosis_date, with_time=False) or diagnosis_date
|
||||||
self.subtitle.setText(f"{type_text} · 诊断日期 {diagnosis_date}")
|
self.subtitle.setText(f"{type_text} · 诊断日期 {diagnosis_date}")
|
||||||
for key, labels in self.value_labels.items():
|
for key, labels in self.value_labels.items():
|
||||||
raw = _pick(
|
raw = _pick(
|
||||||
@@ -3152,6 +3283,9 @@ class CaseGrid(QFrame):
|
|||||||
"pregnancy_history",
|
"pregnancy_history",
|
||||||
}:
|
}:
|
||||||
rendered = {"0": "无", "1": "有"}.get(normalized, rendered)
|
rendered = {"0": "无", "1": "有"}.get(normalized, rendered)
|
||||||
|
elif key in FIELD_DICTIONARIES and raw not in (None, ""):
|
||||||
|
# 后端只读接口会补 `<field>_text`;快照类数据没有时按字典翻译。
|
||||||
|
rendered = shared_terms().dictionary_label(key, raw) or rendered
|
||||||
elif key == "age" and raw not in (None, ""):
|
elif key == "age" and raw not in (None, ""):
|
||||||
rendered = f"{rendered}岁"
|
rendered = f"{rendered}岁"
|
||||||
elif key == "height" and raw not in (None, ""):
|
elif key == "height" and raw not in (None, ""):
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Safe in-application replay players for diagnosis call recordings.
|
"""Safe in-application viewers for diagnosis recordings and attachment images.
|
||||||
|
|
||||||
The web diagnosis page keeps the preferred recording in the table and lists
|
The web diagnosis page keeps the preferred recording in the table and lists
|
||||||
the remaining sources underneath it. This module mirrors that contract with
|
the remaining sources underneath it. This module mirrors that contract with
|
||||||
QtMultimedia while retaining the older standalone dialog as a codec fallback.
|
QtMultimedia while retaining the older standalone dialog as a codec fallback.
|
||||||
|
Attachment images follow the same rule: they are previewed inside the
|
||||||
|
workstation like the admin ``el-image`` viewer, never handed straight to the
|
||||||
|
operating system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,14 +15,16 @@ import weakref
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QUrl, Signal
|
from PySide6.QtCore import QCoreApplication, QObject, QSize, Qt, QUrl, Signal
|
||||||
from PySide6.QtGui import QCloseEvent, QDesktopServices
|
from PySide6.QtGui import QCloseEvent, QDesktopServices, QKeyEvent, QPixmap, QResizeEvent
|
||||||
|
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSlider,
|
QSlider,
|
||||||
QStackedLayout,
|
QStackedLayout,
|
||||||
@@ -656,6 +661,449 @@ class RecordingPlayerDialog(QDialog):
|
|||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
def shared_image_manager(fallback_parent: QObject | None = None) -> QNetworkAccessManager:
|
||||||
|
"""Return the one image download manager owned by the application.
|
||||||
|
|
||||||
|
每个缩略图/预览窗口原来各自持有 ``QNetworkAccessManager``:关闭抽屉或对话框
|
||||||
|
时,网络栈会连同尚未结束的请求一起析构,Windows 上直接以 0xC0000374 结束
|
||||||
|
进程。改为由 QApplication 持有唯一管理器,请求可以比任何控件活得更久。
|
||||||
|
"""
|
||||||
|
|
||||||
|
app = QCoreApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return QNetworkAccessManager(fallback_parent)
|
||||||
|
manager = getattr(app, "_doctor_image_network_manager", None)
|
||||||
|
if isinstance(manager, QNetworkAccessManager):
|
||||||
|
try:
|
||||||
|
manager.parent()
|
||||||
|
except RuntimeError: # the C++ object was destroyed behind the wrapper
|
||||||
|
manager = None
|
||||||
|
if not isinstance(manager, QNetworkAccessManager):
|
||||||
|
manager = QNetworkAccessManager(app)
|
||||||
|
app._doctor_image_network_manager = manager
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
_IMAGE_SUFFIX_PATTERN = re.compile(
|
||||||
|
r"\.(?:png|jpe?g|jfif|gif|bmp|webp|tiff?|heic|heif|avif)(?:\?|#|$)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_previewable_image(target: str) -> bool:
|
||||||
|
"""Return whether a safe remote source can be decoded as an inline image."""
|
||||||
|
|
||||||
|
url = safe_http_url(target)
|
||||||
|
if url is None:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
_IMAGE_SUFFIX_PATTERN.search(url.path() or "")
|
||||||
|
or _IMAGE_SUFFIX_PATTERN.search(url.toString())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_image_sources(sources: Sequence[Any] | Any) -> list[str]:
|
||||||
|
"""Keep the ordered HTTP(S) sources a preview window can actually fetch."""
|
||||||
|
|
||||||
|
return [
|
||||||
|
target
|
||||||
|
for target in normalize_recording_urls(sources)
|
||||||
|
if safe_http_url(target) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def image_display_name(target: str, ordinal: int) -> str:
|
||||||
|
"""Name an attachment from its own path so previews never invent a title."""
|
||||||
|
|
||||||
|
url = safe_http_url(target)
|
||||||
|
name = ((url.path() if url is not None else "") or "").rsplit("/", 1)[-1].strip()
|
||||||
|
return name or f"图片 {ordinal}"
|
||||||
|
|
||||||
|
|
||||||
|
_IMAGE_PREVIEW_QSS = """
|
||||||
|
QDialog#DiagnosisImagePreview { background: #FFFFFF; }
|
||||||
|
QLabel#DiagnosisImagePreviewName { color: #1F2A44; font-size: 14px; font-weight: 600; }
|
||||||
|
QLabel#DiagnosisImagePreviewCounter { color: #64739A; font-size: 12px; }
|
||||||
|
QLabel#DiagnosisImagePreviewStatus { color: #64739A; font-size: 12px; }
|
||||||
|
QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #C0392B; }
|
||||||
|
QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #9A650F; }
|
||||||
|
QScrollArea#DiagnosisImagePreviewViewport {
|
||||||
|
background: #11182E;
|
||||||
|
border: 1px solid #E6EAF5;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
QLabel#DiagnosisImagePreviewCanvas {
|
||||||
|
background: #11182E;
|
||||||
|
color: #C7D0E8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
QPushButton[imagePreviewControl="true"] {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #3F4E75;
|
||||||
|
background: #FAFBFE;
|
||||||
|
border: 1px solid #D8DEEE;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
QPushButton[imagePreviewControl="true"]:hover,
|
||||||
|
QPushButton[imagePreviewControl="true"]:focus {
|
||||||
|
color: #4451E2;
|
||||||
|
background: #F0F2FF;
|
||||||
|
border-color: #8D9BFF;
|
||||||
|
}
|
||||||
|
QPushButton[imagePreviewControl="true"]:disabled {
|
||||||
|
color: #A4ADC3;
|
||||||
|
background: #F0F2F8;
|
||||||
|
border-color: #E6EAF5;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePreviewDialog(QDialog):
|
||||||
|
"""In-window lightbox for safe HTTP(S) attachment images.
|
||||||
|
|
||||||
|
后台用 ``el-image`` + ``preview-src-list`` 直接在页面内放大舌象与报告图片,
|
||||||
|
工作站此前只能把地址交给系统浏览器。这里保持同一合同:医生留在工作站内
|
||||||
|
翻看整组附件,必要时才手动安全外部打开。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MAX_IMAGE_BYTES = 12 * 1024 * 1024
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sources: Sequence[Any] | Any,
|
||||||
|
*,
|
||||||
|
index: int = 0,
|
||||||
|
names: Sequence[Any] | None = None,
|
||||||
|
title: str = "图片预览",
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
if isinstance(sources, (str, bytes, bytearray)) or not isinstance(sources, Sequence):
|
||||||
|
candidates: Sequence[Any] = [sources]
|
||||||
|
else:
|
||||||
|
candidates = sources
|
||||||
|
ordered = [str(candidate or "").strip() for candidate in candidates]
|
||||||
|
labels = [str(label or "").strip() for label in (names or [])]
|
||||||
|
requested = ordered[index] if 0 <= index < len(ordered) else ""
|
||||||
|
self._sources: list[str] = []
|
||||||
|
self._names: list[str] = []
|
||||||
|
for position, target in enumerate(ordered):
|
||||||
|
if not target or target in self._sources or safe_http_url(target) is None:
|
||||||
|
continue
|
||||||
|
label = labels[position] if position < len(labels) else ""
|
||||||
|
self._sources.append(target)
|
||||||
|
self._names.append(label or image_display_name(target, len(self._sources)))
|
||||||
|
self._index = self._sources.index(requested) if requested in self._sources else 0
|
||||||
|
self._cache: dict[str, QPixmap] = {}
|
||||||
|
self._pixmap = QPixmap()
|
||||||
|
self._fit = True
|
||||||
|
self._generation = 0
|
||||||
|
self._reply: QNetworkReply | None = None
|
||||||
|
self._manager = shared_image_manager(self)
|
||||||
|
|
||||||
|
self.setObjectName("DiagnosisImagePreview")
|
||||||
|
self.setWindowTitle(title)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
|
self.setStyleSheet(_IMAGE_PREVIEW_QSS)
|
||||||
|
self.resize(880, 660)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
root.setContentsMargins(18, 16, 18, 16)
|
||||||
|
root.setSpacing(10)
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.setSpacing(10)
|
||||||
|
self.name_label = QLabel(title)
|
||||||
|
self.name_label.setObjectName("DiagnosisImagePreviewName")
|
||||||
|
self.name_label.setTextInteractionFlags(
|
||||||
|
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||||
|
)
|
||||||
|
header.addWidget(self.name_label, 1)
|
||||||
|
self.counter = QLabel("")
|
||||||
|
self.counter.setObjectName("DiagnosisImagePreviewCounter")
|
||||||
|
header.addWidget(self.counter, 0, Qt.AlignmentFlag.AlignRight)
|
||||||
|
root.addLayout(header)
|
||||||
|
|
||||||
|
self.viewport = QScrollArea()
|
||||||
|
self.viewport.setObjectName("DiagnosisImagePreviewViewport")
|
||||||
|
self.viewport.setWidgetResizable(True)
|
||||||
|
self.viewport.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.canvas = QLabel("正在加载图片…")
|
||||||
|
self.canvas.setObjectName("DiagnosisImagePreviewCanvas")
|
||||||
|
self.canvas.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.canvas.setSizePolicy(
|
||||||
|
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||||
|
)
|
||||||
|
self.viewport.setWidget(self.canvas)
|
||||||
|
root.addWidget(self.viewport, 1)
|
||||||
|
|
||||||
|
self.status = QLabel("")
|
||||||
|
self.status.setObjectName("DiagnosisImagePreviewStatus")
|
||||||
|
self.status.setWordWrap(True)
|
||||||
|
self.status.setTextInteractionFlags(
|
||||||
|
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||||
|
)
|
||||||
|
root.addWidget(self.status)
|
||||||
|
|
||||||
|
controls = QHBoxLayout()
|
||||||
|
controls.setSpacing(8)
|
||||||
|
self.previous_button = QPushButton("上一张")
|
||||||
|
self.previous_button.setProperty("imagePreviewControl", True)
|
||||||
|
self.previous_button.clicked.connect(lambda: self.step(-1))
|
||||||
|
controls.addWidget(self.previous_button)
|
||||||
|
self.next_button = QPushButton("下一张")
|
||||||
|
self.next_button.setProperty("imagePreviewControl", True)
|
||||||
|
self.next_button.clicked.connect(lambda: self.step(1))
|
||||||
|
controls.addWidget(self.next_button)
|
||||||
|
controls.addStretch(1)
|
||||||
|
self.zoom_button = QPushButton("原始大小")
|
||||||
|
self.zoom_button.setProperty("imagePreviewControl", True)
|
||||||
|
self.zoom_button.setToolTip("在适应窗口与原始像素之间切换")
|
||||||
|
self.zoom_button.clicked.connect(self.toggle_zoom)
|
||||||
|
controls.addWidget(self.zoom_button)
|
||||||
|
self.reload_button = QPushButton("重新加载")
|
||||||
|
self.reload_button.setProperty("imagePreviewControl", True)
|
||||||
|
self.reload_button.clicked.connect(self.reload_current)
|
||||||
|
controls.addWidget(self.reload_button)
|
||||||
|
self.external_button = QPushButton("安全外部打开")
|
||||||
|
self.external_button.setProperty("imagePreviewControl", True)
|
||||||
|
self.external_button.clicked.connect(self._open_external)
|
||||||
|
controls.addWidget(self.external_button)
|
||||||
|
self.close_button = QPushButton("关闭")
|
||||||
|
self.close_button.setProperty("imagePreviewControl", True)
|
||||||
|
self.close_button.clicked.connect(self.close)
|
||||||
|
controls.addWidget(self.close_button)
|
||||||
|
root.addLayout(controls)
|
||||||
|
|
||||||
|
if not self._sources:
|
||||||
|
self.canvas.setText("附件地址无效:仅支持包含主机名的 HTTP(S) 图片。")
|
||||||
|
self.status.setText("没有可在工作站内预览的图片。")
|
||||||
|
self.status.setProperty("kind", "danger")
|
||||||
|
for button in (
|
||||||
|
self.previous_button,
|
||||||
|
self.next_button,
|
||||||
|
self.zoom_button,
|
||||||
|
self.reload_button,
|
||||||
|
self.external_button,
|
||||||
|
):
|
||||||
|
button.setEnabled(False)
|
||||||
|
return
|
||||||
|
self.show_index(self._index)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sources(self) -> list[str]:
|
||||||
|
return list(self._sources)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_source(self) -> str:
|
||||||
|
return self._sources[self._index] if self._sources else ""
|
||||||
|
|
||||||
|
def has_images(self) -> bool:
|
||||||
|
return bool(self._sources)
|
||||||
|
|
||||||
|
def show_index(self, index: int) -> None:
|
||||||
|
"""Move to one attachment, serving an already decoded image from cache."""
|
||||||
|
|
||||||
|
if not self._sources:
|
||||||
|
return
|
||||||
|
self._index = max(0, min(int(index), len(self._sources) - 1))
|
||||||
|
target = self._sources[self._index]
|
||||||
|
total = len(self._sources)
|
||||||
|
self.name_label.setText(self._names[self._index])
|
||||||
|
self.name_label.setToolTip(target)
|
||||||
|
self.counter.setText(f"第 {self._index + 1} / {total} 张")
|
||||||
|
self.previous_button.setEnabled(total > 1)
|
||||||
|
self.next_button.setEnabled(total > 1)
|
||||||
|
cached = self._cache.get(target)
|
||||||
|
if cached is not None and not cached.isNull():
|
||||||
|
self._invalidate_request()
|
||||||
|
self._pixmap = cached
|
||||||
|
self._set_status(target)
|
||||||
|
self._render()
|
||||||
|
return
|
||||||
|
self._pixmap = QPixmap()
|
||||||
|
self.canvas.setPixmap(QPixmap())
|
||||||
|
self.canvas.setMinimumSize(0, 0)
|
||||||
|
self.canvas.setText("正在加载图片…")
|
||||||
|
self._set_status(target)
|
||||||
|
self._request(target)
|
||||||
|
|
||||||
|
def step(self, delta: int) -> None:
|
||||||
|
if len(self._sources) < 2:
|
||||||
|
return
|
||||||
|
self.show_index((self._index + int(delta)) % len(self._sources))
|
||||||
|
|
||||||
|
def reload_current(self) -> None:
|
||||||
|
target = self.current_source
|
||||||
|
if not target:
|
||||||
|
return
|
||||||
|
self._cache.pop(target, None)
|
||||||
|
self.show_index(self._index)
|
||||||
|
|
||||||
|
def toggle_zoom(self) -> None:
|
||||||
|
self._fit = not self._fit
|
||||||
|
self.zoom_button.setText("原始大小" if self._fit else "适应窗口")
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _invalidate_request(self) -> int:
|
||||||
|
self._generation += 1
|
||||||
|
reply, self._reply = self._reply, None
|
||||||
|
if reply is not None:
|
||||||
|
reply.abort()
|
||||||
|
reply.deleteLater()
|
||||||
|
return self._generation
|
||||||
|
|
||||||
|
def _request(self, target: str) -> None:
|
||||||
|
generation = self._invalidate_request()
|
||||||
|
url = safe_http_url(target)
|
||||||
|
if url is None:
|
||||||
|
self._fail("附件地址无效:仅支持包含主机名的 HTTP(S) 图片。")
|
||||||
|
return
|
||||||
|
request = QNetworkRequest(url)
|
||||||
|
request.setTransferTimeout(15_000)
|
||||||
|
request.setMaximumRedirectsAllowed(4)
|
||||||
|
request.setAttribute(
|
||||||
|
QNetworkRequest.Attribute.RedirectPolicyAttribute,
|
||||||
|
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy,
|
||||||
|
)
|
||||||
|
reply = self._manager.get(request)
|
||||||
|
self._reply = reply
|
||||||
|
reply.setProperty("imagePreviewGeneration", generation)
|
||||||
|
reply.setProperty("imagePreviewOversize", False)
|
||||||
|
reply.downloadProgress.connect(self._download_progress)
|
||||||
|
reply.finished.connect(self._reply_finished)
|
||||||
|
|
||||||
|
def _download_progress(self, bytes_received: int, bytes_total: int) -> None:
|
||||||
|
"""Abort as soon as a received or declared size exceeds the safety cap."""
|
||||||
|
|
||||||
|
reply = self.sender()
|
||||||
|
if reply is not self._reply:
|
||||||
|
return
|
||||||
|
if bytes_received <= self._MAX_IMAGE_BYTES and (
|
||||||
|
bytes_total < 0 or bytes_total <= self._MAX_IMAGE_BYTES
|
||||||
|
):
|
||||||
|
return
|
||||||
|
reply.setProperty("imagePreviewOversize", True)
|
||||||
|
reply.abort()
|
||||||
|
|
||||||
|
def _reply_finished(self) -> None:
|
||||||
|
"""Use a QObject receiver connection so destruction disconnects this slot."""
|
||||||
|
|
||||||
|
reply = self.sender()
|
||||||
|
if reply is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
generation = int(reply.property("imagePreviewGeneration"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
reply.deleteLater()
|
||||||
|
return
|
||||||
|
if reply is not self._reply or generation != self._generation:
|
||||||
|
reply.deleteLater()
|
||||||
|
return
|
||||||
|
self._reply = None
|
||||||
|
oversize = bool(reply.property("imagePreviewOversize"))
|
||||||
|
error = reply.error()
|
||||||
|
payload = b"" if oversize else bytes(reply.readAll())
|
||||||
|
reply.deleteLater()
|
||||||
|
if oversize:
|
||||||
|
self._fail("图片超过 12 MB 安全上限,已停止加载,可安全外部打开。")
|
||||||
|
return
|
||||||
|
if error != QNetworkReply.NetworkError.NoError:
|
||||||
|
self._fail("图片加载失败,可重新加载或安全外部打开。")
|
||||||
|
return
|
||||||
|
self.apply_payload(payload, generation)
|
||||||
|
|
||||||
|
def apply_payload(self, payload: bytes, generation: int | None = None) -> bool:
|
||||||
|
"""Decode one response; kept public so offline tests can exercise rendering."""
|
||||||
|
|
||||||
|
if generation is not None and generation != self._generation:
|
||||||
|
return False
|
||||||
|
pixmap = QPixmap()
|
||||||
|
if (
|
||||||
|
not payload
|
||||||
|
or len(payload) > self._MAX_IMAGE_BYTES
|
||||||
|
or not pixmap.loadFromData(payload)
|
||||||
|
or pixmap.isNull()
|
||||||
|
):
|
||||||
|
self._fail("图片格式不受支持,无法在工作站内预览。")
|
||||||
|
return False
|
||||||
|
self._cache[self.current_source] = pixmap
|
||||||
|
self._pixmap = pixmap
|
||||||
|
self._set_status(self.current_source)
|
||||||
|
self._render()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _set_status(self, text: str, kind: str = "") -> None:
|
||||||
|
"""Keep the source address visible; tint it only when something failed."""
|
||||||
|
|
||||||
|
self.status.setText(text)
|
||||||
|
self.status.setProperty("kind", kind)
|
||||||
|
self._repolish_status()
|
||||||
|
|
||||||
|
def _fail(self, message: str) -> None:
|
||||||
|
self._pixmap = QPixmap()
|
||||||
|
self.canvas.setPixmap(QPixmap())
|
||||||
|
self.canvas.setMinimumSize(0, 0)
|
||||||
|
self.canvas.setText(message)
|
||||||
|
self._set_status(self.current_source or message, "warning")
|
||||||
|
|
||||||
|
def _repolish_status(self) -> None:
|
||||||
|
style = self.status.style()
|
||||||
|
style.unpolish(self.status)
|
||||||
|
style.polish(self.status)
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
if self._pixmap.isNull():
|
||||||
|
return
|
||||||
|
self.canvas.setText("")
|
||||||
|
available = self.viewport.viewport().size() - QSize(10, 10)
|
||||||
|
if self._fit and (
|
||||||
|
self._pixmap.width() > available.width()
|
||||||
|
or self._pixmap.height() > available.height()
|
||||||
|
):
|
||||||
|
rendered = self._pixmap.scaled(
|
||||||
|
available,
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rendered = self._pixmap
|
||||||
|
self.canvas.setMinimumSize(QSize(0, 0) if self._fit else rendered.size())
|
||||||
|
self.canvas.setPixmap(rendered)
|
||||||
|
|
||||||
|
def _open_external(self) -> None:
|
||||||
|
if not open_safe_http_url(self.current_source):
|
||||||
|
self._set_status("系统未能打开该安全外部链接。", "danger")
|
||||||
|
|
||||||
|
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
super().resizeEvent(event)
|
||||||
|
if self._fit:
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
key = event.key()
|
||||||
|
if key in {Qt.Key.Key_Left, Qt.Key.Key_Up, Qt.Key.Key_PageUp}:
|
||||||
|
self.step(-1)
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
if key in {
|
||||||
|
Qt.Key.Key_Right,
|
||||||
|
Qt.Key.Key_Down,
|
||||||
|
Qt.Key.Key_PageDown,
|
||||||
|
Qt.Key.Key_Space,
|
||||||
|
}:
|
||||||
|
self.step(1)
|
||||||
|
event.accept()
|
||||||
|
return
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt virtual
|
||||||
|
self._invalidate_request()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
def _clock(milliseconds: int) -> str:
|
def _clock(milliseconds: int) -> str:
|
||||||
seconds = max(0, int(milliseconds) // 1000)
|
seconds = max(0, int(milliseconds) // 1000)
|
||||||
return f"{seconds // 60:02d}:{seconds % 60:02d}"
|
return f"{seconds // 60:02d}:{seconds % 60:02d}"
|
||||||
@@ -663,13 +1111,18 @@ def _clock(milliseconds: int) -> str:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"MULTIMEDIA_AVAILABLE",
|
"MULTIMEDIA_AVAILABLE",
|
||||||
|
"ImagePreviewDialog",
|
||||||
"InlineRecordingPlayer",
|
"InlineRecordingPlayer",
|
||||||
"RecordingPlaybackCell",
|
"RecordingPlaybackCell",
|
||||||
"RecordingPlayerDialog",
|
"RecordingPlayerDialog",
|
||||||
"alternate_recording_label",
|
"alternate_recording_label",
|
||||||
|
"image_display_name",
|
||||||
|
"is_previewable_image",
|
||||||
"normalize_recording_urls",
|
"normalize_recording_urls",
|
||||||
"open_safe_http_url",
|
"open_safe_http_url",
|
||||||
"preferred_recording_url",
|
"preferred_recording_url",
|
||||||
"safe_http_url",
|
"safe_http_url",
|
||||||
|
"safe_image_sources",
|
||||||
|
"shared_image_manager",
|
||||||
"should_inline_recording",
|
"should_inline_recording",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
"""诊单字段的中文翻译:字典项、枚举与时间戳。
|
||||||
|
|
||||||
|
后台 ``AppointmentLogic::enrichDiagnosisLabels`` 会给只读接口补一份
|
||||||
|
``<field>_text``,所以读取时永远优先用它(与 admin 的 ``makeTextOf`` 同一约定)。
|
||||||
|
处方快照一类的历史数据没有这些字段,就按字段所属字典把 code 翻成中文:字典优先
|
||||||
|
取仓储实时下发的 ``config/dict``,缺失时回退到与
|
||||||
|
``server/sql/present_illness_dict_data.sql`` / ``tcm_diagnosis.sql`` 一致的内置种子。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# 与 AppointmentLogic::enrichDiagnosisLabels 的 $singleDictFields 保持一致。
|
||||||
|
SINGLE_VALUE_DICTIONARIES: dict[str, str] = {
|
||||||
|
"diagnosis_type": "diagnosis_type",
|
||||||
|
"syndrome_type": "syndrome_type",
|
||||||
|
"diabetes_type": "diabetes_type",
|
||||||
|
"water_intake": "water_intake",
|
||||||
|
"weight_change": "weight_change",
|
||||||
|
"fatty_liver_degree": "fatty_liver_degree",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 与 $multiDictFields 保持一致:值是数组或逗号/顿号分隔的字符串。
|
||||||
|
MULTI_VALUE_DICTIONARIES: dict[str, str] = {
|
||||||
|
"past_history": "past_history",
|
||||||
|
"appetite": "appetite",
|
||||||
|
"diet_condition": "diet_condition",
|
||||||
|
"body_feeling": "body_feeling",
|
||||||
|
"sleep_condition": "sleep_condition",
|
||||||
|
"eye_condition": "eye_condition",
|
||||||
|
"head_feeling": "head_feeling",
|
||||||
|
"sweat_condition": "sweat_condition",
|
||||||
|
"skin_condition": "skin_condition",
|
||||||
|
"urine_condition": "urine_condition",
|
||||||
|
"stool_condition": "stool_condition",
|
||||||
|
"kidney_condition": "kidney_condition",
|
||||||
|
}
|
||||||
|
|
||||||
|
FIELD_DICTIONARIES: dict[str, str] = {
|
||||||
|
**SINGLE_VALUE_DICTIONARIES,
|
||||||
|
**MULTI_VALUE_DICTIONARIES,
|
||||||
|
}
|
||||||
|
|
||||||
|
DICTIONARY_TYPES: tuple[str, ...] = tuple(sorted(set(FIELD_DICTIONARIES.values())))
|
||||||
|
|
||||||
|
# 内置种子:与 server/sql/present_illness_dict_data.sql、tcm_diagnosis.sql 的
|
||||||
|
# zyt_dict_data 初始数据一致;线上字典可被管理员改写,所以实时字典优先。
|
||||||
|
SEED_DICTIONARIES: dict[str, dict[str, str]] = {
|
||||||
|
"appetite": {
|
||||||
|
"dry": "干",
|
||||||
|
"bitter": "苦",
|
||||||
|
"greasy": "腻",
|
||||||
|
},
|
||||||
|
"body_feeling": {
|
||||||
|
"numbness": "麻木",
|
||||||
|
"weakness": "乏力",
|
||||||
|
"pain": "疼痛",
|
||||||
|
"cold_aversion": "畏寒",
|
||||||
|
"fever": "烧热",
|
||||||
|
},
|
||||||
|
"diagnosis_type": {
|
||||||
|
"first_visit": "初诊",
|
||||||
|
"follow_up": "复诊",
|
||||||
|
"consultation": "会诊",
|
||||||
|
},
|
||||||
|
"diet_condition": {
|
||||||
|
"overeating": "多食",
|
||||||
|
"poor_appetite": "纳呆",
|
||||||
|
"stomach_bloating": "胃胀",
|
||||||
|
"stomach_pain": "胃痛",
|
||||||
|
"acid_reflux": "反酸",
|
||||||
|
"loss_of_appetite": "食欲减退",
|
||||||
|
},
|
||||||
|
"eye_condition": {
|
||||||
|
"blurred": "模糊",
|
||||||
|
"dry": "干涩",
|
||||||
|
"tearing": "流泪",
|
||||||
|
"floaters": "飞蚊症",
|
||||||
|
"bleeding": "出血",
|
||||||
|
},
|
||||||
|
"fatty_liver_degree": {
|
||||||
|
"mild": "轻度",
|
||||||
|
"moderate": "中度",
|
||||||
|
"severe": "重度",
|
||||||
|
},
|
||||||
|
"head_feeling": {
|
||||||
|
"fatigue": "疲劳困倦",
|
||||||
|
"dizziness": "头晕",
|
||||||
|
"headache": "头痛",
|
||||||
|
"tinnitus": "耳鸣",
|
||||||
|
},
|
||||||
|
"kidney_condition": {
|
||||||
|
"soreness": "酸胀",
|
||||||
|
"pain": "疼痛",
|
||||||
|
"lower_back_pain": "腰痛",
|
||||||
|
"sexual_dysfunction": "性功能下降",
|
||||||
|
},
|
||||||
|
"past_history": {
|
||||||
|
"hypertension": "高血压",
|
||||||
|
"diabetes": "糖尿病",
|
||||||
|
"gastric_ulcer": "胃溃疡",
|
||||||
|
"hyperlipidemia": "高血脂",
|
||||||
|
"thyroid_nodule": "甲状腺结节",
|
||||||
|
"superficial_gastritis": "浅表性胃炎",
|
||||||
|
"stomach_disease": "胃病",
|
||||||
|
"cerebral_infarction": "脑梗",
|
||||||
|
"breast_nodule": "乳腺结节",
|
||||||
|
"atrophic_gastritis": "萎缩性胃炎",
|
||||||
|
"heart_disease": "心脏病",
|
||||||
|
"cerebral_ischemia": "脑缺血",
|
||||||
|
"intestinal_obstruction": "肠梗阻",
|
||||||
|
"hepatitis_a": "甲肝",
|
||||||
|
"hepatitis_b": "乙肝",
|
||||||
|
"hepatitis_c": "丙肝",
|
||||||
|
"big_three_positive": "大三阳",
|
||||||
|
"cerebral_thrombosis": "脑血栓",
|
||||||
|
"coronary_heart_disease": "冠心病",
|
||||||
|
"angina_pectoris": "心绞痛",
|
||||||
|
"palpitation": "心悸",
|
||||||
|
"renal_insufficiency": "肾功能不全",
|
||||||
|
"benign_tumor": "良性肿瘤",
|
||||||
|
"pancreatitis": "胰腺炎",
|
||||||
|
"small_three_positive": "小三阳",
|
||||||
|
"palpitations": "心慌",
|
||||||
|
"edema": "水肿",
|
||||||
|
"infectious_disease": "传染病",
|
||||||
|
"fundus_congestion": "眼底充血",
|
||||||
|
"tuberculosis": "肺结核",
|
||||||
|
"pneumonia": "肺炎",
|
||||||
|
"pulmonary_nodule": "肺结节",
|
||||||
|
"cardiac_stent": "心脏支架",
|
||||||
|
"renal_stent": "肾脏支架",
|
||||||
|
"hepatitis": "肝炎",
|
||||||
|
"tumor": "肿瘤",
|
||||||
|
"emphysema": "肺气肿",
|
||||||
|
"moderate_fatty_liver": "中度脂肪肝",
|
||||||
|
"lacunar_infarction": "腔梗",
|
||||||
|
"alcoholic_liver": "酒精肝",
|
||||||
|
"brain_atrophy": "脑萎缩",
|
||||||
|
"liver_cyst": "肝囊肿",
|
||||||
|
"stroke": "中风",
|
||||||
|
"cerebral_hemorrhage": "脑出血",
|
||||||
|
"hepatic_insufficiency": "肝功能不全",
|
||||||
|
"arterial_plaque": "动脉斑块",
|
||||||
|
"uterine_fibroids": "子宫肌瘤",
|
||||||
|
"splenomegaly": "脾大",
|
||||||
|
"gastric_perforation": "胃穿孔",
|
||||||
|
"gastric_bleeding": "胃出血",
|
||||||
|
},
|
||||||
|
"skin_condition": {
|
||||||
|
"dry": "干燥",
|
||||||
|
"itching": "瘙痒",
|
||||||
|
"peeling": "脱皮",
|
||||||
|
"edema": "水肿",
|
||||||
|
"eczema": "湿疹",
|
||||||
|
},
|
||||||
|
"sleep_condition": {
|
||||||
|
"difficulty_falling_asleep": "入睡难",
|
||||||
|
"easy_to_wake": "容易醒",
|
||||||
|
"early_waking": "早醒",
|
||||||
|
"many_dreams": "多梦",
|
||||||
|
},
|
||||||
|
"stool_condition": {
|
||||||
|
"dry": "干燥",
|
||||||
|
"constipation": "便秘",
|
||||||
|
"sticky": "粘腻",
|
||||||
|
"diarrhea": "腹泻",
|
||||||
|
},
|
||||||
|
"sweat_condition": {
|
||||||
|
"daytime_sweating": "日间出汗",
|
||||||
|
"night_sweating": "夜间出汗",
|
||||||
|
"sticky_sweat": "汗粘",
|
||||||
|
"excessive_sweating": "多汗",
|
||||||
|
},
|
||||||
|
"syndrome_type": {
|
||||||
|
"qi_deficiency": "气虚",
|
||||||
|
"blood_deficiency": "血虚",
|
||||||
|
"yin_deficiency": "阴虚",
|
||||||
|
"yang_deficiency": "阳虚",
|
||||||
|
"qi_stagnation": "气滞",
|
||||||
|
"blood_stasis": "血瘀",
|
||||||
|
"phlegm_dampness": "痰湿",
|
||||||
|
"damp_heat": "湿热",
|
||||||
|
"cold_dampness": "寒湿",
|
||||||
|
"wind_cold": "风寒",
|
||||||
|
"wind_heat": "风热",
|
||||||
|
},
|
||||||
|
"urine_condition": {
|
||||||
|
"urgency": "尿急",
|
||||||
|
"yellow_urine": "尿黄",
|
||||||
|
"foamy": "有泡",
|
||||||
|
"frequency": "尿频",
|
||||||
|
"painful": "尿痛",
|
||||||
|
"nocturia": "夜尿多",
|
||||||
|
},
|
||||||
|
"water_intake": {
|
||||||
|
"one_bottle": "1瓶矿泉水",
|
||||||
|
"one_half_bottle": "1.5瓶矿泉水",
|
||||||
|
"three_bottles": "3瓶矿泉水",
|
||||||
|
"four_bottles": "4瓶矿泉水",
|
||||||
|
},
|
||||||
|
"weight_change": {
|
||||||
|
"lose_5_jin": "瘦5斤",
|
||||||
|
"lose_10_jin": "瘦10斤",
|
||||||
|
"lose_over_10_jin": "瘦10斤以上",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_GENDER_LABELS: dict[str, str] = {
|
||||||
|
"1": "男",
|
||||||
|
"m": "男",
|
||||||
|
"male": "男",
|
||||||
|
"男": "男",
|
||||||
|
"0": "女",
|
||||||
|
"2": "女",
|
||||||
|
"f": "女",
|
||||||
|
"female": "女",
|
||||||
|
"女": "女",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MARITAL_LABELS: dict[str, str] = {"0": "未婚", "1": "已婚", "2": "离异"}
|
||||||
|
|
||||||
|
# 后台 yesNoText:1 有,其余 无。
|
||||||
|
_YES_NO_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"trauma_history",
|
||||||
|
"surgery_history",
|
||||||
|
"allergy_history",
|
||||||
|
"family_history",
|
||||||
|
"pregnancy_history",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_CREATE_SOURCE_LABELS: dict[str, str] = {
|
||||||
|
"mnp": "小程序建档",
|
||||||
|
"mnp_daily": "小程序快捷建档",
|
||||||
|
"admin": "后台创建",
|
||||||
|
"doctor": "医生创建",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RECORD_SOURCE_LABELS: dict[str, str] = {"0": "医生录入", "1": "患者自录"}
|
||||||
|
|
||||||
|
# 纯内部列:启停标记、统计端展示位、排班偏移与软删除时间对医生没有意义。
|
||||||
|
INTERNAL_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"status",
|
||||||
|
"show_card",
|
||||||
|
"revisit_slot_start_offset",
|
||||||
|
"delete_time",
|
||||||
|
"is_delete",
|
||||||
|
"is_deleted",
|
||||||
|
"sort",
|
||||||
|
"assistant_id",
|
||||||
|
"doctor_id",
|
||||||
|
"admin_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 附件字段单独渲染成缩略图,不再以 URL 文本出现在字段网格里。
|
||||||
|
IMAGE_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"tongue_images",
|
||||||
|
"report_files",
|
||||||
|
"images",
|
||||||
|
"breakfast_images",
|
||||||
|
"lunch_images",
|
||||||
|
"dinner_images",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_TIMESTAMP_SUFFIXES = ("_time", "_at", "_date")
|
||||||
|
_TIMESTAMP_MIN = 10**9 # 2001-09-09,早于本项目任何真实数据
|
||||||
|
_TIMESTAMP_MAX = 4 * 10**9 # 2096 年,之后按普通数字显示
|
||||||
|
|
||||||
|
|
||||||
|
# 只读页的单位,与 admin PatientCaseCard / BloodRecordList 一致。
|
||||||
|
_UNIT_SUFFIXES: dict[str, str] = {
|
||||||
|
"height": " cm",
|
||||||
|
"weight": " kg",
|
||||||
|
"systolic_pressure": " mmHg",
|
||||||
|
"diastolic_pressure": " mmHg",
|
||||||
|
"fasting_blood_sugar": " mmol/L",
|
||||||
|
"postprandial_blood_sugar": " mmol/L",
|
||||||
|
"other_blood_sugar": " mmol/L",
|
||||||
|
"blood_sugar": " mmol/L",
|
||||||
|
"duration": " 分钟",
|
||||||
|
"diabetes_discovery_year": "年",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unit_suffix(field: str, value: Any) -> str:
|
||||||
|
"""Return the unit a numeric readonly field should carry, if any."""
|
||||||
|
|
||||||
|
suffix = _UNIT_SUFFIXES.get(str(field or "").strip())
|
||||||
|
if not suffix:
|
||||||
|
return ""
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text or text.endswith(suffix.strip()):
|
||||||
|
return ""
|
||||||
|
# 只给纯数字补单位,"17多"、"五年" 这类自由文本保持原样。
|
||||||
|
normalized = text.replace(".", "", 1)
|
||||||
|
return suffix if normalized.isdigit() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def format_timestamp(value: Any, *, with_time: bool = True) -> str | None:
|
||||||
|
"""把后端的 Unix 秒级时间戳转成中文界面用的日期时间。"""
|
||||||
|
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
seconds = int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not _TIMESTAMP_MIN <= seconds <= _TIMESTAMP_MAX:
|
||||||
|
return None
|
||||||
|
moment = datetime.fromtimestamp(seconds)
|
||||||
|
return moment.strftime("%Y-%m-%d %H:%M" if with_time else "%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def is_timestamp_field(field: str) -> bool:
|
||||||
|
return str(field or "").endswith(_TIMESTAMP_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def gender_label(value: Any) -> str | None:
|
||||||
|
return _GENDER_LABELS.get(str(value or "").strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def split_values(value: Any) -> list[str]:
|
||||||
|
"""按后台 enrichDiagnosisLabels 的方式拆多值字段。"""
|
||||||
|
|
||||||
|
if value in (None, "", []):
|
||||||
|
return []
|
||||||
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||||
|
items = [str(item or "").strip() for item in value]
|
||||||
|
else:
|
||||||
|
items = [part.strip() for part in _split_text(str(value))]
|
||||||
|
return [item for item in items if item]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_text(text: str) -> list[str]:
|
||||||
|
normalized = text.replace(",", ",").replace("、", ",")
|
||||||
|
return normalized.split(",")
|
||||||
|
|
||||||
|
|
||||||
|
class TermIndex:
|
||||||
|
"""字典翻译表:实时字典覆盖内置种子,两者都缺就回显原值。"""
|
||||||
|
|
||||||
|
def __init__(self, dictionaries: Mapping[str, Any] | None = None) -> None:
|
||||||
|
self._dictionaries: dict[str, dict[str, str]] = {
|
||||||
|
dictionary_type: dict(options)
|
||||||
|
for dictionary_type, options in SEED_DICTIONARIES.items()
|
||||||
|
}
|
||||||
|
self.merge(dictionaries)
|
||||||
|
|
||||||
|
def merge(self, dictionaries: Mapping[str, Any] | None) -> None:
|
||||||
|
"""并入 ``config/dict`` 下发的 ``{type: [{name, value}, ...]}``。"""
|
||||||
|
|
||||||
|
if not isinstance(dictionaries, Mapping):
|
||||||
|
return
|
||||||
|
for dictionary_type, rows in dictionaries.items():
|
||||||
|
options = _options_from_rows(rows)
|
||||||
|
if options:
|
||||||
|
self._dictionaries.setdefault(str(dictionary_type), {}).update(options)
|
||||||
|
|
||||||
|
def dictionary(self, dictionary_type: str) -> dict[str, str]:
|
||||||
|
return dict(self._dictionaries.get(str(dictionary_type), {}))
|
||||||
|
|
||||||
|
def dictionary_label(self, field: str, value: Any) -> str | None:
|
||||||
|
"""翻译一个字典字段;不是字典字段或没有可翻译内容时返回 None。"""
|
||||||
|
|
||||||
|
dictionary_type = FIELD_DICTIONARIES.get(str(field or "").strip())
|
||||||
|
if dictionary_type is None:
|
||||||
|
return None
|
||||||
|
options = self._dictionaries.get(dictionary_type, {})
|
||||||
|
items = split_values(value)
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
labels = [options.get(item, item) for item in items]
|
||||||
|
return "、".join(label for label in labels if label) or None
|
||||||
|
|
||||||
|
def value_label(self, field: str, value: Any) -> str | None:
|
||||||
|
"""翻译字典项或枚举;无法翻译时返回 None,由调用方回显原值。"""
|
||||||
|
|
||||||
|
key = str(field or "").strip()
|
||||||
|
if value in (None, "", [], {}):
|
||||||
|
return None
|
||||||
|
dictionary_label = self.dictionary_label(key, value)
|
||||||
|
if dictionary_label is not None:
|
||||||
|
return dictionary_label
|
||||||
|
if key in {"gender", "patient_gender", "sex"}:
|
||||||
|
return gender_label(value)
|
||||||
|
if key in {"marital_status", "marriage"}:
|
||||||
|
return _MARITAL_LABELS.get(str(value).strip())
|
||||||
|
if key in _YES_NO_FIELDS:
|
||||||
|
text = str(value).strip()
|
||||||
|
if text in {"0", "1"}:
|
||||||
|
return "有" if text == "1" else "无"
|
||||||
|
return None
|
||||||
|
if key == "create_source":
|
||||||
|
return _CREATE_SOURCE_LABELS.get(str(value).strip().lower())
|
||||||
|
if key == "source":
|
||||||
|
return _RECORD_SOURCE_LABELS.get(str(value).strip())
|
||||||
|
if is_timestamp_field(key):
|
||||||
|
return format_timestamp(value, with_time=not key.endswith("_date"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def display(self, source: Any, field: str, *, default: str = "") -> str:
|
||||||
|
"""按 admin ``textOf`` 的口径取值:先 ``<field>_text``,再字典/枚举,最后原值。"""
|
||||||
|
|
||||||
|
mapping = source if isinstance(source, Mapping) else {}
|
||||||
|
key = str(field or "").strip()
|
||||||
|
translated = mapping.get(f"{key}_text")
|
||||||
|
if translated in (None, "", []):
|
||||||
|
translated = mapping.get(f"{key}_desc")
|
||||||
|
if translated not in (None, "", []):
|
||||||
|
return _join(translated) + unit_suffix(key, translated)
|
||||||
|
raw = mapping.get(key)
|
||||||
|
if raw in (None, "", [], {}):
|
||||||
|
return default
|
||||||
|
labelled = self.value_label(key, raw)
|
||||||
|
if labelled is not None:
|
||||||
|
return labelled
|
||||||
|
rendered = _join(raw)
|
||||||
|
return rendered + unit_suffix(key, rendered)
|
||||||
|
|
||||||
|
|
||||||
|
_SHARED_INDEX = TermIndex()
|
||||||
|
|
||||||
|
|
||||||
|
def shared_terms() -> TermIndex:
|
||||||
|
"""The process-wide index every readonly screen renders through.
|
||||||
|
|
||||||
|
只读界面只用它翻译展示文案,所以共享一份即可:任何界面取回实时字典后,
|
||||||
|
处方快照那种拿不到 ``*_text`` 的旧数据也能跟着翻译正确。
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _SHARED_INDEX
|
||||||
|
|
||||||
|
|
||||||
|
def merge_shared_dictionaries(dictionaries: Mapping[str, Any] | None) -> None:
|
||||||
|
_SHARED_INDEX.merge(dictionaries)
|
||||||
|
|
||||||
|
|
||||||
|
def _join(value: Any) -> str:
|
||||||
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||||
|
return "、".join(str(item).strip() for item in value if str(item).strip())
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _options_from_rows(rows: Any) -> dict[str, str]:
|
||||||
|
"""从 ``config/dict`` 的行里取 ``value -> name``。"""
|
||||||
|
|
||||||
|
options: dict[str, str] = {}
|
||||||
|
if isinstance(rows, Mapping):
|
||||||
|
for value, name in rows.items():
|
||||||
|
code = str(value).strip()
|
||||||
|
label = str(name).strip()
|
||||||
|
if code and label:
|
||||||
|
options[code] = label
|
||||||
|
return options
|
||||||
|
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes, bytearray)):
|
||||||
|
return options
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, Mapping):
|
||||||
|
continue
|
||||||
|
code = str(row.get("value", "")).strip()
|
||||||
|
label = str(row.get("name", "")).strip()
|
||||||
|
if code and label:
|
||||||
|
options[code] = label
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DICTIONARY_TYPES",
|
||||||
|
"FIELD_DICTIONARIES",
|
||||||
|
"IMAGE_FIELDS",
|
||||||
|
"INTERNAL_FIELDS",
|
||||||
|
"MULTI_VALUE_DICTIONARIES",
|
||||||
|
"SEED_DICTIONARIES",
|
||||||
|
"SINGLE_VALUE_DICTIONARIES",
|
||||||
|
"TermIndex",
|
||||||
|
"format_timestamp",
|
||||||
|
"merge_shared_dictionaries",
|
||||||
|
"shared_terms",
|
||||||
|
"gender_label",
|
||||||
|
"is_timestamp_field",
|
||||||
|
"split_values",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
|||||||
|
"""Confirmation and optional doctor note for completing an appointment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtGui import QCloseEvent
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QLabel,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..theme import mark_business_dialog
|
||||||
|
from ..widgets import MessageBanner
|
||||||
|
|
||||||
|
COMPLETION_NOTE_LIMIT = 500
|
||||||
|
|
||||||
|
|
||||||
|
class AppointmentCompleteDialog(QDialog):
|
||||||
|
"""Keep an unsaved note available if completion or note saving fails."""
|
||||||
|
|
||||||
|
submitted = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, *, can_note: bool, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._can_note = can_note
|
||||||
|
self._busy = False
|
||||||
|
self._completed = False
|
||||||
|
self.setWindowTitle("完成问诊")
|
||||||
|
self.resize(460, 350 if can_note else 230)
|
||||||
|
self.setMinimumWidth(420)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(24, 22, 24, 18)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
title = QLabel("完成问诊", self)
|
||||||
|
title.setProperty("dialogRole", "title")
|
||||||
|
layout.addWidget(title)
|
||||||
|
prompt = QLabel("是否添加医生备注?" if can_note else "确认完成该挂号吗?", self)
|
||||||
|
layout.addWidget(prompt)
|
||||||
|
self.note_edit = QPlainTextEdit(self)
|
||||||
|
self.note_edit.setObjectName("AppointmentCompleteNote")
|
||||||
|
self.note_edit.setAccessibleName("完成问诊医生备注")
|
||||||
|
self.note_edit.setPlaceholderText("填写完成备注,将追加到医生备注时间轴")
|
||||||
|
self.note_edit.setMinimumHeight(100)
|
||||||
|
self.note_edit.setVisible(can_note)
|
||||||
|
self.note_edit.textChanged.connect(self._limit_note)
|
||||||
|
layout.addWidget(self.note_edit)
|
||||||
|
self.note_counter = QLabel(f"0 / {COMPLETION_NOTE_LIMIT}", self)
|
||||||
|
self.note_counter.setObjectName("AppointmentCompleteNoteCounter")
|
||||||
|
self.note_counter.setProperty("dialogRole", "subtitle")
|
||||||
|
self.note_counter.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||||
|
self.note_counter.setVisible(can_note)
|
||||||
|
layout.addWidget(self.note_counter)
|
||||||
|
hint = QLabel("系统会再次核对服务端挂号状态;完成后不可撤销。", self)
|
||||||
|
hint.setProperty("dialogRole", "subtitle")
|
||||||
|
hint.setWordWrap(True)
|
||||||
|
layout.addWidget(hint)
|
||||||
|
self.banner = MessageBanner(parent=self)
|
||||||
|
layout.addWidget(self.banner)
|
||||||
|
self.buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self
|
||||||
|
)
|
||||||
|
self.confirm_button = self.buttons.button(QDialogButtonBox.StandardButton.Ok)
|
||||||
|
self.confirm_button.setText("确认完成")
|
||||||
|
self.confirm_button.setProperty("variant", "primary")
|
||||||
|
self.confirm_button.setAutoDefault(False)
|
||||||
|
self.cancel_button = self.buttons.button(QDialogButtonBox.StandardButton.Cancel)
|
||||||
|
self.cancel_button.setText("取消")
|
||||||
|
self.cancel_button.setAutoDefault(False)
|
||||||
|
self.buttons.accepted.connect(self._submit)
|
||||||
|
self.buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(self.buttons)
|
||||||
|
mark_business_dialog(self, "AppointmentCompleteDialog")
|
||||||
|
|
||||||
|
def _limit_note(self) -> None:
|
||||||
|
text = self.note_edit.toPlainText()
|
||||||
|
if len(text) > COMPLETION_NOTE_LIMIT:
|
||||||
|
text = text[:COMPLETION_NOTE_LIMIT]
|
||||||
|
cursor_position = self.note_edit.textCursor().position()
|
||||||
|
self.note_edit.blockSignals(True)
|
||||||
|
self.note_edit.setPlainText(text)
|
||||||
|
cursor = self.note_edit.textCursor()
|
||||||
|
cursor.setPosition(min(cursor_position, self.note_edit.document().characterCount() - 1))
|
||||||
|
self.note_edit.setTextCursor(cursor)
|
||||||
|
self.note_edit.blockSignals(False)
|
||||||
|
self.note_counter.setText(f"{len(text)} / {COMPLETION_NOTE_LIMIT}")
|
||||||
|
|
||||||
|
def _submit(self) -> None:
|
||||||
|
if not self._busy and not self._completed:
|
||||||
|
self.submitted.emit(self.note_edit.toPlainText().strip() if self._can_note else "")
|
||||||
|
|
||||||
|
def set_busy(self, busy: bool) -> None:
|
||||||
|
self._busy = busy
|
||||||
|
self.note_edit.setReadOnly(busy or self._completed)
|
||||||
|
self.confirm_button.setEnabled(not busy and not self._completed)
|
||||||
|
self.confirm_button.setText("正在完成…" if busy else "确认完成")
|
||||||
|
self.cancel_button.setEnabled(not busy)
|
||||||
|
if busy:
|
||||||
|
self.banner.clear()
|
||||||
|
|
||||||
|
def show_error(self, message: str) -> None:
|
||||||
|
self.set_busy(False)
|
||||||
|
self.banner.show_message(message, "danger")
|
||||||
|
|
||||||
|
def show_completed_warning(self, message: str) -> None:
|
||||||
|
self._completed = True
|
||||||
|
self.set_busy(False)
|
||||||
|
self.confirm_button.hide()
|
||||||
|
self.cancel_button.setText("关闭")
|
||||||
|
self.banner.show_message(f"{message}。可复制上方备注,稍后在医生备注中补录。", "warning")
|
||||||
|
|
||||||
|
def reject(self) -> None:
|
||||||
|
if not self._busy:
|
||||||
|
super().reject()
|
||||||
|
|
||||||
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API
|
||||||
|
if self._busy:
|
||||||
|
event.ignore()
|
||||||
|
else:
|
||||||
|
super().closeEvent(event)
|
||||||
@@ -46,6 +46,7 @@ from ..diagnosis_drawer import (
|
|||||||
DiagnosisSwitch,
|
DiagnosisSwitch,
|
||||||
DiagnosisTabWidget,
|
DiagnosisTabWidget,
|
||||||
DiagnosisTextEdit,
|
DiagnosisTextEdit,
|
||||||
|
ExpandableDiagnosisTextEdit,
|
||||||
LoadingOverlay,
|
LoadingOverlay,
|
||||||
MessageStrip,
|
MessageStrip,
|
||||||
NotesTimeline,
|
NotesTimeline,
|
||||||
@@ -300,7 +301,7 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
|
|||||||
("渠道", "create_source", 12, "create_source"),
|
("渠道", "create_source", 12, "create_source"),
|
||||||
),
|
),
|
||||||
(("统计端就诊卡", "show_card", 12, "show_card"),),
|
(("统计端就诊卡", "show_card", 12, "show_card"),),
|
||||||
(("在用药物", "current_medications", 24, "textarea3"),),
|
(("在用药物", "current_medications", 24, "expandable_textarea3"),),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -331,7 +332,7 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
|
|||||||
(("小便情况", "urine_condition", 24, "urine_condition_choices"),),
|
(("小便情况", "urine_condition", 24, "urine_condition_choices"),),
|
||||||
(("大便情况", "stool_condition", 24, "stool_condition_choices"),),
|
(("大便情况", "stool_condition", 24, "stool_condition_choices"),),
|
||||||
(("腰肾情况", "kidney_condition", 24, "kidney_condition_choices"),),
|
(("腰肾情况", "kidney_condition", 24, "kidney_condition_choices"),),
|
||||||
(("其他补充", "symptoms", 24, "textarea3"),),
|
(("其他补充", "symptoms", 24, "expandable_textarea3"),),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("既往史", ((("既往史", "past_history", 24, "past_history_choices"),),)),
|
("既往史", ((("既往史", "past_history", 24, "past_history_choices"),),)),
|
||||||
@@ -757,9 +758,11 @@ class DiagnosisDialog(QDialog):
|
|||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
*,
|
*,
|
||||||
permissions: Any = None,
|
permissions: Any = None,
|
||||||
|
embedded: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
|
self._embedded = bool(embedded)
|
||||||
self.permissions = (
|
self.permissions = (
|
||||||
permissions
|
permissions
|
||||||
if permissions is not None
|
if permissions is not None
|
||||||
@@ -881,11 +884,17 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.setObjectName("DiagnosisDialogRoot")
|
self.setObjectName("DiagnosisDialogRoot")
|
||||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
if self._embedded:
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
self.setWindowFlags(Qt.WindowType.Widget)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
|
||||||
|
self.setMinimumSize(0, 0)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
else:
|
||||||
|
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
|
self.setMinimumSize(760, 520)
|
||||||
|
self.resize(1024, 640)
|
||||||
self.setWindowTitle("患者信息详情")
|
self.setWindowTitle("患者信息详情")
|
||||||
self.setMinimumSize(760, 520)
|
|
||||||
self.resize(1024, 640)
|
|
||||||
self.setStyleSheet(DIAGNOSIS_QSS)
|
self.setStyleSheet(DIAGNOSIS_QSS)
|
||||||
|
|
||||||
self.view_stack = QStackedLayout(self)
|
self.view_stack = QStackedLayout(self)
|
||||||
@@ -910,16 +919,25 @@ class DiagnosisDialog(QDialog):
|
|||||||
root = QVBoxLayout(page)
|
root = QVBoxLayout(page)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
root.setSpacing(0)
|
root.setSpacing(0)
|
||||||
|
self.readonly_header = QWidget(page)
|
||||||
|
self.readonly_header.setObjectName("DiagnosisReadonlyHeader")
|
||||||
|
header_layout = QVBoxLayout(self.readonly_header)
|
||||||
|
header_layout.setContentsMargins(16, 16, 16, 0)
|
||||||
|
header_layout.setSpacing(0)
|
||||||
|
self.readonly_hero = self._build_readonly_hero()
|
||||||
|
header_layout.addWidget(self.readonly_hero)
|
||||||
|
root.addWidget(self.readonly_header)
|
||||||
self.readonly_scroll = QScrollArea()
|
self.readonly_scroll = QScrollArea()
|
||||||
self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll")
|
self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll")
|
||||||
self.readonly_scroll.setWidgetResizable(True)
|
self.readonly_scroll.setWidgetResizable(True)
|
||||||
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
|
self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.readonly_scroll.verticalScrollBar().setSingleStep(28)
|
||||||
content = QWidget()
|
content = QWidget()
|
||||||
self.readonly_content_layout = QVBoxLayout(content)
|
self.readonly_content_layout = QVBoxLayout(content)
|
||||||
self.readonly_content_layout.setContentsMargins(16, 16, 16, 16)
|
self.readonly_content_layout.setContentsMargins(16, 16, 16, 16)
|
||||||
self.readonly_content_layout.setSpacing(16)
|
self.readonly_content_layout.setSpacing(16)
|
||||||
self.readonly_hero = self._build_readonly_hero()
|
|
||||||
self.readonly_content_layout.addWidget(self.readonly_hero)
|
|
||||||
self.readonly_error = QFrame()
|
self.readonly_error = QFrame()
|
||||||
self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard")
|
self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard")
|
||||||
self.readonly_error.setProperty("diagnosisReadonlyCard", True)
|
self.readonly_error.setProperty("diagnosisReadonlyCard", True)
|
||||||
@@ -980,16 +998,16 @@ class DiagnosisDialog(QDialog):
|
|||||||
left_layout = QHBoxLayout(self.readonly_hero_left)
|
left_layout = QHBoxLayout(self.readonly_hero_left)
|
||||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
left_layout.setSpacing(12)
|
left_layout.setSpacing(12)
|
||||||
back = QPushButton("← 返回")
|
self.readonly_back_button = QPushButton("← 收起资料" if self._embedded else "← 返回")
|
||||||
back.setObjectName("DiagnosisReadonlyBack")
|
self.readonly_back_button.setObjectName("DiagnosisReadonlyBack")
|
||||||
back.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
back.setStyleSheet(
|
self.readonly_back_button.setStyleSheet(
|
||||||
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
|
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
|
||||||
"color:#5265F6;font-size:13px;font-weight:500;}"
|
"color:#5265F6;font-size:13px;font-weight:500;}"
|
||||||
"QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}"
|
"QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}"
|
||||||
)
|
)
|
||||||
back.clicked.connect(self.reject)
|
self.readonly_back_button.clicked.connect(self.reject)
|
||||||
left_layout.addWidget(back)
|
left_layout.addWidget(self.readonly_back_button)
|
||||||
title = QLabel("患者信息详情")
|
title = QLabel("患者信息详情")
|
||||||
title.setObjectName("DiagnosisReadonlyTitle")
|
title.setObjectName("DiagnosisReadonlyTitle")
|
||||||
left_layout.addWidget(title)
|
left_layout.addWidget(title)
|
||||||
@@ -1008,6 +1026,13 @@ class DiagnosisDialog(QDialog):
|
|||||||
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
|
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
|
||||||
self.readonly_status.setProperty("severity", "neutral")
|
self.readonly_status.setProperty("severity", "neutral")
|
||||||
right_layout.addWidget(self.readonly_status)
|
right_layout.addWidget(self.readonly_status)
|
||||||
|
self.readonly_close_button = QPushButton("×")
|
||||||
|
self.readonly_close_button.setObjectName("DiagnosisCloseButton")
|
||||||
|
self.readonly_close_button.setToolTip("关闭诊单详情")
|
||||||
|
self.readonly_close_button.setAccessibleName("关闭诊单详情")
|
||||||
|
self.readonly_close_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.readonly_close_button.clicked.connect(self.reject)
|
||||||
|
right_layout.addWidget(self.readonly_close_button)
|
||||||
layout.addWidget(self.readonly_hero_left, 0, 0)
|
layout.addWidget(self.readonly_hero_left, 0, 0)
|
||||||
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
||||||
layout.setColumnStretch(0, 1)
|
layout.setColumnStretch(0, 1)
|
||||||
@@ -1234,11 +1259,12 @@ class DiagnosisDialog(QDialog):
|
|||||||
occupied = 0
|
occupied = 0
|
||||||
for label, key, span, field_kind in row_fields:
|
for label, key, span, field_kind in row_fields:
|
||||||
editor = self._ensure_editor(key, field_kind)
|
editor = self._ensure_editor(key, field_kind)
|
||||||
display = (
|
if isinstance(editor, DiagnosisNumberEdit):
|
||||||
editor.wrap_with_unit()
|
display = editor.wrap_with_unit()
|
||||||
if isinstance(editor, DiagnosisNumberEdit)
|
elif isinstance(editor, ExpandableDiagnosisTextEdit):
|
||||||
else editor
|
display = editor.wrap_with_expander(label)
|
||||||
)
|
else:
|
||||||
|
display = editor
|
||||||
container = self._field_container(label, display, span, editor=editor)
|
container = self._field_container(label, display, span, editor=editor)
|
||||||
row_layout.addWidget(container, span)
|
row_layout.addWidget(container, span)
|
||||||
fields.append(container)
|
fields.append(container)
|
||||||
@@ -1371,9 +1397,13 @@ class DiagnosisDialog(QDialog):
|
|||||||
columns=4 if multiple else 5,
|
columns=4 if multiple else 5,
|
||||||
)
|
)
|
||||||
self._choice_fields[key] = editor
|
self._choice_fields[key] = editor
|
||||||
elif field_kind.startswith("textarea"):
|
elif field_kind.startswith(("textarea", "expandable_textarea")):
|
||||||
rows = int(field_kind[-1])
|
rows = int(field_kind[-1])
|
||||||
editor = DiagnosisTextEdit(rows)
|
editor = (
|
||||||
|
ExpandableDiagnosisTextEdit(rows)
|
||||||
|
if field_kind.startswith("expandable_")
|
||||||
|
else DiagnosisTextEdit(rows)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
editor = DiagnosisLineEdit()
|
editor = DiagnosisLineEdit()
|
||||||
editor.setObjectName(f"DiagnosisField_{key.removeprefix('__')}")
|
editor.setObjectName(f"DiagnosisField_{key.removeprefix('__')}")
|
||||||
@@ -1784,6 +1814,8 @@ class DiagnosisDialog(QDialog):
|
|||||||
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
|
||||||
def _sync_host_geometry(self) -> None:
|
def _sync_host_geometry(self) -> None:
|
||||||
|
if self._embedded:
|
||||||
|
return
|
||||||
owner = self._owner
|
owner = self._owner
|
||||||
if owner is None:
|
if owner is None:
|
||||||
if self.width() < 760 or self.height() < 520:
|
if self.width() < 760 or self.height() < 520:
|
||||||
@@ -1817,6 +1849,8 @@ class DiagnosisDialog(QDialog):
|
|||||||
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
||||||
|
|
||||||
def _install_owner_filter(self) -> None:
|
def _install_owner_filter(self) -> None:
|
||||||
|
if self._embedded:
|
||||||
|
return
|
||||||
if self._owner is not None and not self._owner_filter_installed:
|
if self._owner is not None and not self._owner_filter_installed:
|
||||||
self._owner.installEventFilter(self)
|
self._owner.installEventFilter(self)
|
||||||
self._owner_filter_installed = True
|
self._owner_filter_installed = True
|
||||||
@@ -1824,6 +1858,10 @@ class DiagnosisDialog(QDialog):
|
|||||||
def _rebind_owner(self) -> None:
|
def _rebind_owner(self) -> None:
|
||||||
"""Resolve the live Shell window for every open/show cycle."""
|
"""Resolve the live Shell window for every open/show cycle."""
|
||||||
|
|
||||||
|
if self._embedded:
|
||||||
|
self._owner = None
|
||||||
|
self._owner_filter_installed = False
|
||||||
|
return
|
||||||
parent = self.parentWidget()
|
parent = self.parentWidget()
|
||||||
candidate = parent.window() if parent is not None else None
|
candidate = parent.window() if parent is not None else None
|
||||||
if candidate is self:
|
if candidate is self:
|
||||||
@@ -1846,8 +1884,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
|
|
||||||
def showEvent(self, event: Any) -> None:
|
def showEvent(self, event: Any) -> None:
|
||||||
self._rebind_owner()
|
if not self._embedded:
|
||||||
self._sync_host_geometry()
|
self._rebind_owner()
|
||||||
|
self._sync_host_geometry()
|
||||||
self._update_drawer_geometry()
|
self._update_drawer_geometry()
|
||||||
self._reflow_readonly_hero()
|
self._reflow_readonly_hero()
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
@@ -1911,8 +1950,20 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
|
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
|
||||||
|
|
||||||
def open_view_only(self, diagnosis_id: int, *, seed: Any = None) -> None:
|
def open_view_only(
|
||||||
self.open_for(diagnosis_id, editable=False, seed=seed, view_only=True)
|
self,
|
||||||
|
diagnosis_id: int,
|
||||||
|
*,
|
||||||
|
seed: Any = None,
|
||||||
|
modeless: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.open_for(
|
||||||
|
diagnosis_id,
|
||||||
|
editable=False,
|
||||||
|
seed=seed,
|
||||||
|
view_only=True,
|
||||||
|
modeless=modeless,
|
||||||
|
)
|
||||||
|
|
||||||
def open_for(
|
def open_for(
|
||||||
self,
|
self,
|
||||||
@@ -1920,9 +1971,12 @@ class DiagnosisDialog(QDialog):
|
|||||||
*,
|
*,
|
||||||
editable: bool = False,
|
editable: bool = False,
|
||||||
seed: Any = None,
|
seed: Any = None,
|
||||||
|
authoritative_detail: Any = None,
|
||||||
view_only: bool = False,
|
view_only: bool = False,
|
||||||
|
modeless: bool = False,
|
||||||
|
auto_show: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Open immediately, then replace the seed with authoritative server data."""
|
"""Prepare a diagnosis view, optionally showing it immediately."""
|
||||||
|
|
||||||
self._rebind_owner()
|
self._rebind_owner()
|
||||||
for player in list(self._recording_players):
|
for player in list(self._recording_players):
|
||||||
@@ -1967,13 +2021,13 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._daily_todo_status = None
|
self._daily_todo_status = None
|
||||||
self._orders_page = 1
|
self._orders_page = 1
|
||||||
self._orders_total = 0
|
self._orders_total = 0
|
||||||
self._detail = seed
|
self._detail = authoritative_detail if authoritative_detail is not None else seed
|
||||||
self.save_button.set_state("idle")
|
self.save_button.set_state("idle")
|
||||||
self.refresh_permissions()
|
self.refresh_permissions()
|
||||||
self.view_stack.setCurrentWidget(
|
self.view_stack.setCurrentWidget(
|
||||||
self.readonly_page if self._standalone_readonly else self.drawer_overlay
|
self.readonly_page if self._standalone_readonly else self.drawer_overlay
|
||||||
)
|
)
|
||||||
self.setModal(not self._standalone_readonly)
|
self.setModal(False if self._embedded else not modeless and not self._standalone_readonly)
|
||||||
self.setWindowTitle(
|
self.setWindowTitle(
|
||||||
"患者信息详情"
|
"患者信息详情"
|
||||||
if self._standalone_readonly
|
if self._standalone_readonly
|
||||||
@@ -1999,15 +2053,38 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self._clear_tables()
|
self._clear_tables()
|
||||||
self._clear_message()
|
self._clear_message()
|
||||||
if seed is not None:
|
if self._detail is not None:
|
||||||
self._render(seed, [], [])
|
self._render(self._detail, [], [])
|
||||||
self._sync_form_interactivity()
|
self._sync_form_interactivity()
|
||||||
self._sync_save_button()
|
self._sync_save_button()
|
||||||
|
self._sync_host_geometry()
|
||||||
|
if auto_show:
|
||||||
|
self.show()
|
||||||
|
if not self._embedded:
|
||||||
|
self.raise_()
|
||||||
|
if authoritative_detail is not None:
|
||||||
|
diagnosis = get_value(authoritative_detail, "diagnosis", None) or authoritative_detail
|
||||||
|
patient = get_value(authoritative_detail, "patient", None) or {}
|
||||||
|
self._patient_id = _int(
|
||||||
|
first_value(
|
||||||
|
diagnosis,
|
||||||
|
"patient_id",
|
||||||
|
"source_patient_id",
|
||||||
|
default=first_value(patient, "patient_id", "id", default=0),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self._authoritative_detail_loaded = True
|
||||||
|
self._show_authoritative_content(True)
|
||||||
|
self._clear_message()
|
||||||
|
self._set_loading(False)
|
||||||
|
if self._standalone_readonly:
|
||||||
|
self._load_visible_readonly_sections()
|
||||||
|
else:
|
||||||
|
self._ensure_tab_loaded(self._current_tab_key())
|
||||||
|
return
|
||||||
self._show_message("正在加载权威诊单详情…", "info")
|
self._show_message("正在加载权威诊单详情…", "info")
|
||||||
self._set_loading(True)
|
self._set_loading(True)
|
||||||
self._sync_host_geometry()
|
|
||||||
self.show()
|
|
||||||
self.raise_()
|
|
||||||
self._start_detail_load()
|
self._start_detail_load()
|
||||||
|
|
||||||
def _start_detail_load(self) -> None:
|
def _start_detail_load(self) -> None:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
from datetime import date, datetime
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -80,6 +80,7 @@ from PySide6.QtWidgets import (
|
|||||||
QScrollArea,
|
QScrollArea,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
|
QSplitter,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
QTabWidget,
|
QTabWidget,
|
||||||
@@ -89,6 +90,7 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..diagnosis_terms import shared_terms
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
MessageBanner,
|
MessageBanner,
|
||||||
display_text,
|
display_text,
|
||||||
@@ -103,6 +105,7 @@ from ..widgets import (
|
|||||||
run_async,
|
run_async,
|
||||||
show_toast,
|
show_toast,
|
||||||
)
|
)
|
||||||
|
from .diagnosis import DiagnosisDialog as _StructuredDiagnosisDialog
|
||||||
|
|
||||||
PRESCRIPTION_DRAWER_QSS = r"""
|
PRESCRIPTION_DRAWER_QSS = r"""
|
||||||
QFrame#PrescriptionDrawerSurface {
|
QFrame#PrescriptionDrawerSurface {
|
||||||
@@ -2696,6 +2699,7 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
self._source = _mapping(prescription)
|
self._source = _mapping(prescription)
|
||||||
self._loading_data = False
|
self._loading_data = False
|
||||||
self._linked_order_generation = 0
|
self._linked_order_generation = 0
|
||||||
|
self._diagnosis_view: _StructuredDiagnosisDialog | None = None
|
||||||
self._prescribing_creator_id = _int(
|
self._prescribing_creator_id = _int(
|
||||||
first_value(
|
first_value(
|
||||||
prescription,
|
prescription,
|
||||||
@@ -2708,17 +2712,37 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumSize(420, 600)
|
self.setMinimumSize(420, 600)
|
||||||
self.resize(self.DRAWER_WIDTH, 900)
|
self.resize(self.DRAWER_WIDTH, 900)
|
||||||
root = QVBoxLayout(self)
|
root = QHBoxLayout(self)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
root.setSpacing(0)
|
root.setSpacing(0)
|
||||||
|
|
||||||
|
self.workspace_splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||||
|
self.workspace_splitter.setObjectName("PrescriptionWorkspaceSplitter")
|
||||||
|
self.workspace_splitter.setChildrenCollapsible(False)
|
||||||
|
self.workspace_splitter.setHandleWidth(1)
|
||||||
|
root.addWidget(self.workspace_splitter)
|
||||||
|
|
||||||
|
self.diagnosis_host = QFrame(self.workspace_splitter)
|
||||||
|
self.diagnosis_host.setObjectName("PrescriptionDiagnosisPane")
|
||||||
|
self.diagnosis_host.setMinimumWidth(300)
|
||||||
|
diagnosis_layout = QVBoxLayout(self.diagnosis_host)
|
||||||
|
diagnosis_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
diagnosis_layout.setSpacing(0)
|
||||||
|
self.diagnosis_layout = diagnosis_layout
|
||||||
|
self.workspace_splitter.addWidget(self.diagnosis_host)
|
||||||
|
self.diagnosis_host.hide()
|
||||||
|
|
||||||
self.drawer_surface = QFrame()
|
self.drawer_surface = QFrame()
|
||||||
self.drawer_surface.setObjectName("PrescriptionDrawerSurface")
|
self.drawer_surface.setObjectName("PrescriptionDrawerSurface")
|
||||||
self.drawer_surface.setStyleSheet(_prescription_drawer_qss())
|
self.drawer_surface.setStyleSheet(_prescription_drawer_qss())
|
||||||
|
self.drawer_surface.setMinimumWidth(self.minimumWidth())
|
||||||
|
self.drawer_surface.setMaximumWidth(self.DRAWER_WIDTH)
|
||||||
surface_layout = QVBoxLayout(self.drawer_surface)
|
surface_layout = QVBoxLayout(self.drawer_surface)
|
||||||
surface_layout.setContentsMargins(0, 0, 0, 0)
|
surface_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
surface_layout.setSpacing(0)
|
surface_layout.setSpacing(0)
|
||||||
root.addWidget(self.drawer_surface)
|
self.workspace_splitter.addWidget(self.drawer_surface)
|
||||||
|
self.workspace_splitter.setStretchFactor(0, 1)
|
||||||
|
self.workspace_splitter.setStretchFactor(1, 0)
|
||||||
|
|
||||||
self.header = self._build_header()
|
self.header = self._build_header()
|
||||||
surface_layout.addWidget(self.header)
|
surface_layout.addWidget(self.header)
|
||||||
@@ -2919,7 +2943,7 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
self.diagnosis_button.setProperty("size", "small")
|
self.diagnosis_button.setProperty("size", "small")
|
||||||
self.diagnosis_button.setVisible(diagnosis_id > 0)
|
self.diagnosis_button.setVisible(diagnosis_id > 0)
|
||||||
self.diagnosis_button.clicked.connect(
|
self.diagnosis_button.clicked.connect(
|
||||||
lambda _checked=False, value=diagnosis_id: self.diagnosis_requested.emit(value)
|
lambda _checked=False, value=diagnosis_id: self._toggle_diagnosis_view(value)
|
||||||
)
|
)
|
||||||
diagnosis_layout.addWidget(self.diagnosis_button)
|
diagnosis_layout.addWidget(self.diagnosis_button)
|
||||||
self.diagnosis_id_hint = QLabel(f"关联诊单 #{diagnosis_id}" if diagnosis_id > 0 else "")
|
self.diagnosis_id_hint = QLabel(f"关联诊单 #{diagnosis_id}" if diagnosis_id > 0 else "")
|
||||||
@@ -3236,15 +3260,58 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
target = targets[max(0, min(len(targets) - 1, index))]
|
target = targets[max(0, min(len(targets) - 1, index))]
|
||||||
self.body_scroll.ensureWidgetVisible(target, 0, 24)
|
self.body_scroll.ensureWidgetVisible(target, 0, 24)
|
||||||
|
|
||||||
|
def _toggle_diagnosis_view(self, diagnosis_id: int) -> None:
|
||||||
|
if self.diagnosis_host.isVisible():
|
||||||
|
if self._diagnosis_view is not None:
|
||||||
|
self._diagnosis_view.reject()
|
||||||
|
return
|
||||||
|
if diagnosis_id <= 0:
|
||||||
|
return
|
||||||
|
if not has_permission(self.permissions, "tcm.diagnosis/readonlyDetail", default=True):
|
||||||
|
self.context_banner.show_message("无权查看诊单详情。", "danger")
|
||||||
|
return
|
||||||
|
if self._diagnosis_view is None:
|
||||||
|
self._diagnosis_view = _StructuredDiagnosisDialog(
|
||||||
|
self.repository,
|
||||||
|
self.diagnosis_host,
|
||||||
|
permissions=self.permissions,
|
||||||
|
embedded=True,
|
||||||
|
)
|
||||||
|
self._diagnosis_view.finished.connect(
|
||||||
|
lambda _result, view=self._diagnosis_view: self._diagnosis_view_finished(view)
|
||||||
|
)
|
||||||
|
self.diagnosis_layout.addWidget(self._diagnosis_view)
|
||||||
|
self.diagnosis_host.show()
|
||||||
|
self.diagnosis_button.setText("收起患者诊单")
|
||||||
|
self._fit_drawer_geometry()
|
||||||
|
self._diagnosis_view.open_for(diagnosis_id, editable=False)
|
||||||
|
|
||||||
|
def _diagnosis_view_finished(self, view: _StructuredDiagnosisDialog) -> None:
|
||||||
|
if view is not self._diagnosis_view:
|
||||||
|
return
|
||||||
|
self.diagnosis_host.hide()
|
||||||
|
self.diagnosis_button.setText("查看患者诊单详情")
|
||||||
|
self._fit_drawer_geometry()
|
||||||
|
|
||||||
def _fit_drawer_geometry(self) -> None:
|
def _fit_drawer_geometry(self) -> None:
|
||||||
parent = self.parentWidget()
|
parent = self.parentWidget()
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return
|
return
|
||||||
anchor = parent.window()
|
anchor = parent.window()
|
||||||
origin = anchor.mapToGlobal(QPoint(0, 0))
|
origin = anchor.mapToGlobal(QPoint(0, 0))
|
||||||
width = min(self.DRAWER_WIDTH, max(self.minimumWidth(), anchor.width()))
|
expanded = self.diagnosis_host.isVisible()
|
||||||
|
width = (
|
||||||
|
anchor.width()
|
||||||
|
if expanded
|
||||||
|
else min(self.DRAWER_WIDTH, max(self.minimumWidth(), anchor.width()))
|
||||||
|
)
|
||||||
height = max(self.minimumHeight(), anchor.height())
|
height = max(self.minimumHeight(), anchor.height())
|
||||||
self.setGeometry(origin.x() + anchor.width() - width, origin.y(), width, height)
|
self.setGeometry(origin.x() + anchor.width() - width, origin.y(), width, height)
|
||||||
|
if expanded:
|
||||||
|
editor_width = min(self.DRAWER_WIDTH, max(520, round(width * 0.55)))
|
||||||
|
self.workspace_splitter.setSizes([max(300, width - editor_width - 1), editor_width])
|
||||||
|
else:
|
||||||
|
self.workspace_splitter.setSizes([0, width])
|
||||||
|
|
||||||
def showEvent(self, event: Any) -> None:
|
def showEvent(self, event: Any) -> None:
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
@@ -3835,6 +3902,11 @@ class PrescriptionEditorDialog(QDialog):
|
|||||||
self.validation.clear()
|
self.validation.clear()
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|
||||||
|
def done(self, result: int) -> None:
|
||||||
|
if self._diagnosis_view is not None and self._diagnosis_view.isVisible():
|
||||||
|
self._diagnosis_view.reject()
|
||||||
|
super().done(result)
|
||||||
|
|
||||||
|
|
||||||
class PatchPatientDialog(QDialog):
|
class PatchPatientDialog(QDialog):
|
||||||
"""Narrow patient identity correction that preserves audit state."""
|
"""Narrow patient identity correction that preserves audit state."""
|
||||||
@@ -3970,6 +4042,8 @@ def render_case_record_html(prescription: Any, *, print_layout: bool = False) ->
|
|||||||
value = json.dumps(value, ensure_ascii=False, default=str)
|
value = json.dumps(value, ensure_ascii=False, default=str)
|
||||||
return html.escape(display_text(value, default))
|
return html.escape(display_text(value, default))
|
||||||
|
|
||||||
|
terms = shared_terms()
|
||||||
|
|
||||||
def value(*keys: str, default: Any = None) -> Any:
|
def value(*keys: str, default: Any = None) -> Any:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
translated = case.get(f"{key}_text")
|
translated = case.get(f"{key}_text")
|
||||||
@@ -3977,7 +4051,9 @@ def render_case_record_html(prescription: Any, *, print_layout: bool = False) ->
|
|||||||
return translated
|
return translated
|
||||||
candidate = case.get(key)
|
candidate = case.get(key)
|
||||||
if candidate not in (None, ""):
|
if candidate not in (None, ""):
|
||||||
return candidate
|
# 处方快照是开方当时的原始 code,没有后端补的 *_text,
|
||||||
|
# 这里按字典把它翻成中文,翻不了才回显原值。
|
||||||
|
return terms.value_label(key, candidate) or candidate
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def present_date(raw: Any) -> Any:
|
def present_date(raw: Any) -> Any:
|
||||||
@@ -5097,8 +5173,8 @@ class PrescriptionDetailDialog(QDialog):
|
|||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
class DiagnosisDetailDialog(QDialog):
|
class DiagnosisDetailDialog(_StructuredDiagnosisDialog):
|
||||||
"""Read-only diagnosis view preserving the important admin tab boundaries."""
|
"""Compatibility entry that renders prescription-linked diagnoses with the shared UI."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -5108,180 +5184,29 @@ class DiagnosisDetailDialog(QDialog):
|
|||||||
repository: Any = None,
|
repository: Any = None,
|
||||||
permissions: Any = None,
|
permissions: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
if repository is None:
|
||||||
source = _mapping(diagnosis)
|
raise ValueError("repository is required to show diagnosis details")
|
||||||
self.repository = repository
|
super().__init__(repository, parent, permissions=permissions)
|
||||||
self.permissions = permissions
|
|
||||||
self._order_detail_generation = 0
|
|
||||||
self._order_detail_order_id = 0
|
|
||||||
self._order_detail_table: QTableWidget | None = None
|
|
||||||
self._order_detail_button: QPushButton | None = None
|
|
||||||
self.setWindowTitle("诊单详情(只读)")
|
self.setWindowTitle("诊单详情(只读)")
|
||||||
self.resize(880, 700)
|
source = _mapping(diagnosis)
|
||||||
root = QVBoxLayout(self)
|
nested = _mapping(source.get("diagnosis"))
|
||||||
tabs = QTabWidget()
|
diagnosis_id = _int(
|
||||||
groups = (
|
first_value(
|
||||||
(
|
nested,
|
||||||
"病历",
|
"id",
|
||||||
(
|
"diagnosis_id",
|
||||||
"id",
|
default=first_value(source, "id", "diagnosis_id"),
|
||||||
"patient_id",
|
|
||||||
"patient_name",
|
|
||||||
"gender",
|
|
||||||
"age",
|
|
||||||
"phone",
|
|
||||||
"chief_complaint",
|
|
||||||
"present_illness",
|
|
||||||
"past_history",
|
|
||||||
"diagnosis",
|
|
||||||
"syndrome",
|
|
||||||
"treatment",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
("医生备注", ("doctor_notes", "doctor_note", "notes")),
|
0,
|
||||||
("日常记录", ("daily_records", "blood_records")),
|
|
||||||
("处方", ("prescriptions", "case_records")),
|
|
||||||
("沟通与指派", ("call_records", "chat_records", "assign_logs", "appointments")),
|
|
||||||
)
|
)
|
||||||
for title, keys in groups:
|
if diagnosis_id <= 0:
|
||||||
browser = QTextBrowser()
|
raise ValueError("diagnosis detail is missing a valid diagnosis id")
|
||||||
rows = []
|
self.open_for(
|
||||||
for key in keys:
|
diagnosis_id,
|
||||||
value = source.get(key)
|
editable=False,
|
||||||
if value in (None, "", [], {}):
|
seed=source,
|
||||||
continue
|
authoritative_detail=source,
|
||||||
rendered = (
|
auto_show=False,
|
||||||
json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
|
||||||
if isinstance(value, (Mapping, list, tuple))
|
|
||||||
else str(value)
|
|
||||||
)
|
|
||||||
rows.append(f"<h3>{html.escape(key)}</h3><pre>{html.escape(rendered)}</pre>")
|
|
||||||
browser.setHtml("".join(rows) or "<p>暂无数据</p>")
|
|
||||||
tabs.addTab(browser, title)
|
|
||||||
tabs.addTab(self._build_orders_tab(source), "业务订单")
|
|
||||||
root.addWidget(tabs, 1)
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
||||||
buttons.rejected.connect(self.reject)
|
|
||||||
root.addWidget(buttons)
|
|
||||||
|
|
||||||
def _build_orders_tab(self, source: Mapping[str, Any]) -> QWidget:
|
|
||||||
host = QWidget()
|
|
||||||
layout = QVBoxLayout(host)
|
|
||||||
layout.setContentsMargins(8, 8, 8, 8)
|
|
||||||
layout.setSpacing(8)
|
|
||||||
rows: list[Any] = []
|
|
||||||
for key in ("prescription_orders", "orders"):
|
|
||||||
value = source.get(key)
|
|
||||||
if isinstance(value, list):
|
|
||||||
rows.extend(value)
|
|
||||||
latest = source.get("latest_prescription_order")
|
|
||||||
if isinstance(latest, Mapping) and latest:
|
|
||||||
latest_id = _int(first_value(latest, "id", "order_id"), 0)
|
|
||||||
if latest_id and not any(
|
|
||||||
_int(first_value(row, "id", "order_id"), 0) == latest_id for row in rows
|
|
||||||
):
|
|
||||||
rows.insert(0, latest)
|
|
||||||
if not rows:
|
|
||||||
empty = QLabel("暂无关联业务订单")
|
|
||||||
empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
layout.addWidget(empty, 1)
|
|
||||||
return host
|
|
||||||
table = QTableWidget(len(rows), 6)
|
|
||||||
table.setHorizontalHeaderLabels(
|
|
||||||
["订单号", "金额", "履约状态", "收货人", "手机", "创建时间"]
|
|
||||||
)
|
|
||||||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
||||||
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
||||||
table.verticalHeader().hide()
|
|
||||||
table.horizontalHeader().setStretchLastSection(True)
|
|
||||||
self._order_detail_table = table
|
|
||||||
for row_index, row in enumerate(rows):
|
|
||||||
values = (
|
|
||||||
first_value(row, "order_no", "sn", "id"),
|
|
||||||
first_value(row, "amount", "effective_amount"),
|
|
||||||
first_value(row, "fulfillment_status_text", "status_text", "status"),
|
|
||||||
first_value(row, "recipient_name", "patient_name"),
|
|
||||||
first_value(row, "recipient_phone", "phone"),
|
|
||||||
first_value(row, "create_time_text", "create_time"),
|
|
||||||
)
|
|
||||||
for column, value in enumerate(values):
|
|
||||||
item = QTableWidgetItem(display_text(value))
|
|
||||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
|
||||||
table.setItem(row_index, column, item)
|
|
||||||
layout.addWidget(table, 1)
|
|
||||||
actions = QHBoxLayout()
|
|
||||||
view = QPushButton("查看订单详情")
|
|
||||||
view.setProperty("variant", "primary")
|
|
||||||
self._order_detail_button = view
|
|
||||||
view.clicked.connect(self._open_selected_order)
|
|
||||||
table.itemDoubleClicked.connect(lambda _item: self._open_selected_order())
|
|
||||||
actions.addWidget(view)
|
|
||||||
actions.addStretch(1)
|
|
||||||
layout.addLayout(actions)
|
|
||||||
return host
|
|
||||||
|
|
||||||
def _set_order_detail_loading(self, loading: bool) -> None:
|
|
||||||
if self._order_detail_table is not None:
|
|
||||||
self._order_detail_table.setEnabled(not loading)
|
|
||||||
if self._order_detail_button is not None:
|
|
||||||
self._order_detail_button.setEnabled(not loading)
|
|
||||||
|
|
||||||
def _open_selected_order(self) -> None:
|
|
||||||
table = self._order_detail_table
|
|
||||||
if table is None:
|
|
||||||
return
|
|
||||||
row = table.currentRow()
|
|
||||||
item = table.item(row, 0) if row >= 0 else None
|
|
||||||
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
|
||||||
if order is None:
|
|
||||||
return
|
|
||||||
order_id = _int(first_value(order, "id", "order_id"), 0)
|
|
||||||
self._order_detail_generation += 1
|
|
||||||
generation = self._order_detail_generation
|
|
||||||
self._order_detail_order_id = order_id
|
|
||||||
getter = getattr(self.repository, "get_prescription_order", None)
|
|
||||||
if order_id <= 0 or not callable(getter):
|
|
||||||
self._set_order_detail_loading(False)
|
|
||||||
self._present_order_detail(order, order_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._set_order_detail_loading(True)
|
|
||||||
run_async(
|
|
||||||
lambda: getter(order_id),
|
|
||||||
on_success=lambda result: self._order_detail_success(result, order_id, generation),
|
|
||||||
on_error=lambda error: self._order_detail_error(error, order, order_id, generation),
|
|
||||||
on_finished=lambda: self._order_detail_finished(order_id, generation),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _order_detail_success(self, order: Any, order_id: int, generation: int) -> None:
|
|
||||||
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
|
|
||||||
return
|
|
||||||
self._present_order_detail(order, order_id)
|
|
||||||
|
|
||||||
def _order_detail_error(
|
|
||||||
self,
|
|
||||||
_error: Exception,
|
|
||||||
fallback_order: Any,
|
|
||||||
order_id: int,
|
|
||||||
generation: int,
|
|
||||||
) -> None:
|
|
||||||
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
|
|
||||||
return
|
|
||||||
self._present_order_detail(fallback_order, order_id)
|
|
||||||
|
|
||||||
def _order_detail_finished(self, order_id: int, generation: int) -> None:
|
|
||||||
if generation == self._order_detail_generation and order_id == self._order_detail_order_id:
|
|
||||||
self._set_order_detail_loading(False)
|
|
||||||
|
|
||||||
def _present_order_detail(self, order: Any, order_id: int) -> None:
|
|
||||||
from .diagnosis import present_order_detail
|
|
||||||
|
|
||||||
present_order_detail(
|
|
||||||
self.window() if self.window() is not None else self,
|
|
||||||
order,
|
|
||||||
order_id=order_id,
|
|
||||||
permissions=self.permissions,
|
|
||||||
exec_=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -460,6 +460,7 @@ class LoginWindow(QMainWindow):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
||||||
if demo_repository is None:
|
if demo_repository is None:
|
||||||
demo_repository = getattr(config, "demo_repository", None)
|
demo_repository = getattr(config, "demo_repository", None)
|
||||||
self.demo_repository = demo_repository
|
self.demo_repository = demo_repository
|
||||||
@@ -751,6 +752,7 @@ class LoginWindow(QMainWindow):
|
|||||||
if self.demo_repository is None:
|
if self.demo_repository is None:
|
||||||
self.demo_check.setToolTip("当前未配置演示数据")
|
self.demo_check.setToolTip("当前未配置演示数据")
|
||||||
self.demo_check.toggled.connect(self._on_demo_toggled)
|
self.demo_check.toggled.connect(self._on_demo_toggled)
|
||||||
|
self.demo_check.setVisible(self.debug_mode)
|
||||||
choices.addWidget(self.demo_check)
|
choices.addWidget(self.demo_check)
|
||||||
card_layout.addLayout(choices)
|
card_layout.addLayout(choices)
|
||||||
card_layout.addSpacing(26)
|
card_layout.addSpacing(26)
|
||||||
@@ -765,8 +767,12 @@ class LoginWindow(QMainWindow):
|
|||||||
self.login_button.setGraphicsEffect(login_shadow)
|
self.login_button.setGraphicsEffect(login_shadow)
|
||||||
self.login_button.clicked.connect(self.submit)
|
self.login_button.clicked.connect(self.submit)
|
||||||
card_layout.addWidget(self.login_button)
|
card_layout.addWidget(self.login_button)
|
||||||
card_layout.addSpacing(20)
|
self.debug_settings_section = QWidget()
|
||||||
|
self.debug_settings_section.setObjectName("DebugSettingsSection")
|
||||||
|
debug_settings_layout = QVBoxLayout(self.debug_settings_section)
|
||||||
|
debug_settings_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
debug_settings_layout.setSpacing(0)
|
||||||
|
debug_settings_layout.addSpacing(20)
|
||||||
divider = QHBoxLayout()
|
divider = QHBoxLayout()
|
||||||
divider.setSpacing(18)
|
divider.setSpacing(18)
|
||||||
line_left = QFrame()
|
line_left = QFrame()
|
||||||
@@ -782,8 +788,8 @@ class LoginWindow(QMainWindow):
|
|||||||
line_right.setFrameShape(QFrame.Shape.HLine)
|
line_right.setFrameShape(QFrame.Shape.HLine)
|
||||||
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
||||||
divider.addWidget(line_right, 1)
|
divider.addWidget(line_right, 1)
|
||||||
card_layout.addLayout(divider)
|
debug_settings_layout.addLayout(divider)
|
||||||
card_layout.addSpacing(20)
|
debug_settings_layout.addSpacing(20)
|
||||||
|
|
||||||
self.server_toggle = _ServerButton("服务器设置 +")
|
self.server_toggle = _ServerButton("服务器设置 +")
|
||||||
self.server_toggle.setObjectName("ServerSettingsToggle")
|
self.server_toggle.setObjectName("ServerSettingsToggle")
|
||||||
@@ -791,7 +797,7 @@ class LoginWindow(QMainWindow):
|
|||||||
self.server_toggle.setCheckable(True)
|
self.server_toggle.setCheckable(True)
|
||||||
self.server_toggle.setFixedHeight(56)
|
self.server_toggle.setFixedHeight(56)
|
||||||
self.server_toggle.clicked.connect(self._toggle_server_panel)
|
self.server_toggle.clicked.connect(self._toggle_server_panel)
|
||||||
card_layout.addWidget(self.server_toggle)
|
debug_settings_layout.addWidget(self.server_toggle)
|
||||||
|
|
||||||
self.server_panel = QFrame()
|
self.server_panel = QFrame()
|
||||||
self.server_panel.setObjectName("SubtleCard")
|
self.server_panel.setObjectName("SubtleCard")
|
||||||
@@ -850,9 +856,11 @@ class LoginWindow(QMainWindow):
|
|||||||
self.server_hint.setWordWrap(True)
|
self.server_hint.setWordWrap(True)
|
||||||
server_layout.addWidget(self.server_hint)
|
server_layout.addWidget(self.server_hint)
|
||||||
self.server_panel.setVisible(False)
|
self.server_panel.setVisible(False)
|
||||||
card_layout.addSpacing(9)
|
debug_settings_layout.addSpacing(9)
|
||||||
card_layout.addWidget(self.server_panel)
|
debug_settings_layout.addWidget(self.server_panel)
|
||||||
card_layout.addSpacing(12)
|
debug_settings_layout.addSpacing(12)
|
||||||
|
self.debug_settings_section.setVisible(self.debug_mode)
|
||||||
|
card_layout.addWidget(self.debug_settings_section)
|
||||||
|
|
||||||
footnote_row = QHBoxLayout()
|
footnote_row = QHBoxLayout()
|
||||||
footnote_row.setContentsMargins(0, 0, 0, 0)
|
footnote_row.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -922,8 +930,13 @@ class LoginWindow(QMainWindow):
|
|||||||
configured_url = getattr(self.config, "base_url", "") or getattr(
|
configured_url = getattr(self.config, "base_url", "") or getattr(
|
||||||
self.config, "api_base_url", ""
|
self.config, "api_base_url", ""
|
||||||
)
|
)
|
||||||
|
restored_url = (
|
||||||
|
self.settings.value("server/base_url", configured_url)
|
||||||
|
if self.debug_mode
|
||||||
|
else configured_url
|
||||||
|
)
|
||||||
self.server_url_edit.setText(
|
self.server_url_edit.setText(
|
||||||
str(self.settings.value("server/base_url", configured_url) or "")
|
str(restored_url or "")
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
configured_timeout = getattr(self.config, "request_timeout", 60)
|
configured_timeout = getattr(self.config, "request_timeout", 60)
|
||||||
@@ -937,15 +950,22 @@ class LoginWindow(QMainWindow):
|
|||||||
configured_verify_ssl = _setting_bool(
|
configured_verify_ssl = _setting_bool(
|
||||||
getattr(self.config, "verify_ssl", True), True
|
getattr(self.config, "verify_ssl", True), True
|
||||||
)
|
)
|
||||||
verify_ssl = _setting_bool(
|
verify_ssl = (
|
||||||
self.settings.value("server/verify_ssl", configured_verify_ssl),
|
_setting_bool(
|
||||||
configured_verify_ssl,
|
self.settings.value("server/verify_ssl", configured_verify_ssl),
|
||||||
|
configured_verify_ssl,
|
||||||
|
)
|
||||||
|
if self.debug_mode
|
||||||
|
else configured_verify_ssl
|
||||||
)
|
)
|
||||||
self.allow_self_signed_check.setChecked(not verify_ssl)
|
self.allow_self_signed_check.setChecked(not verify_ssl)
|
||||||
if self.demo_repository is not None and bool(
|
if self.debug_mode and self.demo_repository is not None and bool(
|
||||||
getattr(self.config, "demo_mode", False)
|
getattr(self.config, "demo_mode", False)
|
||||||
):
|
):
|
||||||
self.demo_check.setChecked(True)
|
self.demo_check.setChecked(True)
|
||||||
|
elif not self.debug_mode:
|
||||||
|
self.demo_check.setChecked(False)
|
||||||
|
self.active_repository = self.repository
|
||||||
self.account_edit.setText(remembered)
|
self.account_edit.setText(remembered)
|
||||||
self.restore_remembered_credentials()
|
self.restore_remembered_credentials()
|
||||||
if remembered:
|
if remembered:
|
||||||
@@ -954,6 +974,8 @@ class LoginWindow(QMainWindow):
|
|||||||
self.account_edit.setFocus()
|
self.account_edit.setFocus()
|
||||||
|
|
||||||
def _credential_scope(self) -> str:
|
def _credential_scope(self) -> str:
|
||||||
|
if not self.debug_mode:
|
||||||
|
return str(getattr(self.config, "api_base_url", "") or "").strip().rstrip("/")
|
||||||
if hasattr(self, "server_url_edit"):
|
if hasattr(self, "server_url_edit"):
|
||||||
scope = self.server_url_edit.text().strip()
|
scope = self.server_url_edit.text().strip()
|
||||||
if scope:
|
if scope:
|
||||||
@@ -1000,6 +1022,12 @@ class LoginWindow(QMainWindow):
|
|||||||
self.reveal_button.setText("隐藏" if visible else "显示")
|
self.reveal_button.setText("隐藏" if visible else "显示")
|
||||||
|
|
||||||
def _on_demo_toggled(self, enabled: bool) -> None:
|
def _on_demo_toggled(self, enabled: bool) -> None:
|
||||||
|
if enabled and not self.debug_mode:
|
||||||
|
self.demo_check.blockSignals(True)
|
||||||
|
self.demo_check.setChecked(False)
|
||||||
|
self.demo_check.blockSignals(False)
|
||||||
|
self.active_repository = self.repository
|
||||||
|
return
|
||||||
self.active_repository = self.demo_repository if enabled else self.repository
|
self.active_repository = self.demo_repository if enabled else self.repository
|
||||||
self.server_toggle.setEnabled(not enabled and not self._loading)
|
self.server_toggle.setEnabled(not enabled and not self._loading)
|
||||||
self.demo_mode_changed.emit(enabled)
|
self.demo_mode_changed.emit(enabled)
|
||||||
@@ -1013,6 +1041,10 @@ class LoginWindow(QMainWindow):
|
|||||||
self.password_edit.setPlaceholderText("请输入密码")
|
self.password_edit.setPlaceholderText("请输入密码")
|
||||||
|
|
||||||
def _toggle_server_panel(self, expanded: bool) -> None:
|
def _toggle_server_panel(self, expanded: bool) -> None:
|
||||||
|
if not self.debug_mode:
|
||||||
|
self.server_toggle.setChecked(False)
|
||||||
|
self.server_panel.hide()
|
||||||
|
return
|
||||||
self.server_panel.setVisible(expanded)
|
self.server_panel.setVisible(expanded)
|
||||||
self.server_toggle.setText("服务器设置 -" if expanded else "服务器设置 +")
|
self.server_toggle.setText("服务器设置 -" if expanded else "服务器设置 +")
|
||||||
self.server_panel.updateGeometry()
|
self.server_panel.updateGeometry()
|
||||||
@@ -1027,6 +1059,13 @@ class LoginWindow(QMainWindow):
|
|||||||
self._apply_server_settings(announce=True)
|
self._apply_server_settings(announce=True)
|
||||||
|
|
||||||
def _apply_server_settings(self, *, announce: bool) -> bool:
|
def _apply_server_settings(self, *, announce: bool) -> bool:
|
||||||
|
if not self.debug_mode:
|
||||||
|
base_url = str(getattr(self.config, "api_base_url", "") or "").strip()
|
||||||
|
if base_url:
|
||||||
|
self.server_url_edit.setText(base_url)
|
||||||
|
return True
|
||||||
|
self.error_banner.show_message("线上服务器地址尚未配置,请联系管理员。", "warning")
|
||||||
|
return False
|
||||||
base_url = self.server_url_edit.text().strip().rstrip("/")
|
base_url = self.server_url_edit.text().strip().rstrip("/")
|
||||||
if base_url and not base_url.startswith(
|
if base_url and not base_url.startswith(
|
||||||
("https://", "http://localhost", "http://127.0.0.1")
|
("https://", "http://localhost", "http://127.0.0.1")
|
||||||
@@ -1079,7 +1118,7 @@ class LoginWindow(QMainWindow):
|
|||||||
def submit(self) -> None:
|
def submit(self) -> None:
|
||||||
if self._loading:
|
if self._loading:
|
||||||
return
|
return
|
||||||
demo_mode = self.demo_check.isChecked()
|
demo_mode = self.debug_mode and self.demo_check.isChecked()
|
||||||
remember_account = self.remember_check.isChecked()
|
remember_account = self.remember_check.isChecked()
|
||||||
account = self.account_edit.text().strip()
|
account = self.account_edit.text().strip()
|
||||||
password = self.password_edit.text()
|
password = self.password_edit.text()
|
||||||
@@ -1150,9 +1189,13 @@ class LoginWindow(QMainWindow):
|
|||||||
self.password_edit.setEnabled(not loading)
|
self.password_edit.setEnabled(not loading)
|
||||||
self.remember_check.setEnabled(not loading)
|
self.remember_check.setEnabled(not loading)
|
||||||
self.reveal_button.setEnabled(not loading)
|
self.reveal_button.setEnabled(not loading)
|
||||||
self.demo_check.setEnabled(not loading and self.demo_repository is not None)
|
self.demo_check.setEnabled(
|
||||||
self.server_toggle.setEnabled(not loading and not self.demo_check.isChecked())
|
self.debug_mode and not loading and self.demo_repository is not None
|
||||||
self.server_panel.setEnabled(not loading)
|
)
|
||||||
|
self.server_toggle.setEnabled(
|
||||||
|
self.debug_mode and not loading and not self.demo_check.isChecked()
|
||||||
|
)
|
||||||
|
self.server_panel.setEnabled(self.debug_mode and not loading)
|
||||||
self.server_url_edit.setEnabled(not loading)
|
self.server_url_edit.setEnabled(not loading)
|
||||||
self.timeout_spin.setEnabled(not loading)
|
self.timeout_spin.setEnabled(not loading)
|
||||||
self.allow_self_signed_check.setEnabled(not loading)
|
self.allow_self_signed_check.setEnabled(not loading)
|
||||||
@@ -1184,7 +1227,7 @@ class LoginWindow(QMainWindow):
|
|||||||
if remember_account is None:
|
if remember_account is None:
|
||||||
remember_account = self.remember_check.isChecked()
|
remember_account = self.remember_check.isChecked()
|
||||||
scope = self._credential_scope()
|
scope = self._credential_scope()
|
||||||
is_demo = bool(payload.get("demo_mode"))
|
is_demo = self.debug_mode and bool(payload.get("demo_mode"))
|
||||||
password_saved = False
|
password_saved = False
|
||||||
clearer = getattr(self.credential_store, "clear_password", None)
|
clearer = getattr(self.credential_store, "clear_password", None)
|
||||||
if (
|
if (
|
||||||
@@ -1223,13 +1266,18 @@ class LoginWindow(QMainWindow):
|
|||||||
|
|
||||||
def _on_login_error(self, error: Exception) -> None:
|
def _on_login_error(self, error: Exception) -> None:
|
||||||
error_text = str(error).lower()
|
error_text = str(error).lower()
|
||||||
if (
|
certificate_error = (
|
||||||
"certificate_verify_failed" in error_text
|
"certificate_verify_failed" in error_text
|
||||||
or "self-signed certificate" in error_text
|
or "self-signed certificate" in error_text
|
||||||
):
|
)
|
||||||
|
if certificate_error and self.debug_mode:
|
||||||
self.server_toggle.setChecked(True)
|
self.server_toggle.setChecked(True)
|
||||||
self._toggle_server_panel(True)
|
self._toggle_server_panel(True)
|
||||||
message = friendly_error(error)
|
message = (
|
||||||
|
friendly_error(error)
|
||||||
|
if self.debug_mode or not certificate_error
|
||||||
|
else "服务器证书校验失败,请联系管理员检查线上域名和证书配置。"
|
||||||
|
)
|
||||||
self.error_banner.show_message(message, "danger")
|
self.error_banner.show_message(message, "danger")
|
||||||
self.login_failed.emit(message)
|
self.login_failed.emit(message)
|
||||||
self.password_edit.selectAll()
|
self.password_edit.selectAll()
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from html import escape
|
||||||
|
from math import ceil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt
|
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
|
||||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
|
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QTextDocument, QTextOption
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDateTimeEdit,
|
QDateTimeEdit,
|
||||||
@@ -23,6 +25,9 @@ from PySide6.QtWidgets import (
|
|||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QStackedWidget,
|
QStackedWidget,
|
||||||
|
QStyle,
|
||||||
|
QStyledItemDelegate,
|
||||||
|
QStyleOptionViewItem,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
@@ -381,26 +386,84 @@ def _formula(value: Any) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _order_warnings(row: Any) -> list[str]:
|
def _order_warnings(row: Any) -> list[str]:
|
||||||
if not _truthy(first_value(row, "has_prescription_order", default=False)):
|
"""Match the PC list's linked-order checks, including blank herb rows."""
|
||||||
|
|
||||||
|
raw = getattr(row, "raw", None)
|
||||||
|
source = raw if isinstance(raw, Mapping) and raw else row
|
||||||
|
try:
|
||||||
|
has_order = float(get_value(source, "has_prescription_order", 0) or 0) == 1
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
has_order = False
|
||||||
|
if not has_order:
|
||||||
return []
|
return []
|
||||||
herbs = get_value(row, "herbs", None) or []
|
herbs = get_value(source, "herbs", None)
|
||||||
if not isinstance(herbs, (list, tuple)) or not herbs:
|
herbs = herbs if isinstance(herbs, (list, tuple)) else []
|
||||||
|
names = []
|
||||||
|
for herb in herbs:
|
||||||
|
raw_name = get_value(herb, "name", "")
|
||||||
|
names.append(str("" if raw_name is None else raw_name).strip())
|
||||||
|
names = [name for name in names if name]
|
||||||
|
if not names:
|
||||||
return ["请开方,当前处方药材为空白"]
|
return ["请开方,当前处方药材为空白"]
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
duplicate: list[str] = []
|
duplicate: list[str] = []
|
||||||
for herb in herbs:
|
for name in names:
|
||||||
name = str(first_value(herb, "name", "medicine_name", default="")).strip()
|
|
||||||
key = "".join(name.split()).lower()
|
key = "".join(name.split()).lower()
|
||||||
if key and key in seen and name not in duplicate:
|
if key and key in seen and name not in duplicate:
|
||||||
duplicate.append(name)
|
duplicate.append(name)
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
return [f"已有关联业务订单,存在重复药材:{'、'.join(duplicate)}"] if duplicate else []
|
return [f"已有关联业务订单,当前处方存在重复药材:{'、'.join(duplicate)}"] if duplicate else []
|
||||||
|
|
||||||
|
|
||||||
def _sn_cell(_value: Any, row: Any) -> str:
|
def _sn_cell(_value: Any, row: Any) -> str:
|
||||||
return str(first_value(row, "sn", "prescription_no", "id", default="—"))
|
return str(first_value(row, "sn", "prescription_no", "id", default="—"))
|
||||||
|
|
||||||
|
|
||||||
|
class _PrescriptionNumberDelegate(QStyledItemDelegate):
|
||||||
|
"""Paint visible PC-style reminders without changing the sortable SN value."""
|
||||||
|
|
||||||
|
def document(self, option: QStyleOptionViewItem, index: QModelIndex, width: int) -> QTextDocument:
|
||||||
|
row = index.data(Qt.ItemDataRole.UserRole)
|
||||||
|
number = str(index.data(Qt.ItemDataRole.DisplayRole) or "—")
|
||||||
|
record_id = display_text(first_value(row, "id", "prescription_id", default="—"))
|
||||||
|
document = QTextDocument()
|
||||||
|
document.setDocumentMargin(0)
|
||||||
|
document.setDefaultFont(option.font)
|
||||||
|
text_option = document.defaultTextOption()
|
||||||
|
text_option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
|
||||||
|
document.setDefaultTextOption(text_option)
|
||||||
|
paragraphs = [
|
||||||
|
f'<p style="margin:0;color:#315CF4;font-size:12px;font-weight:600">{escape(number)}</p>',
|
||||||
|
f'<p style="margin:2px 0 0;color:#7481A3;font-size:11px;font-weight:400">ID: {escape(record_id)}</p>',
|
||||||
|
]
|
||||||
|
paragraphs.extend(
|
||||||
|
'<p style="margin:2px 0 0;color:#DC2626;font-size:12px;font-weight:400">'
|
||||||
|
+ escape(warning)
|
||||||
|
+ "</p>"
|
||||||
|
for warning in _order_warnings(row)
|
||||||
|
)
|
||||||
|
document.setHtml("".join(paragraphs))
|
||||||
|
document.setTextWidth(max(1, width - 16))
|
||||||
|
return document
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
|
||||||
|
styled = QStyleOptionViewItem(option)
|
||||||
|
self.initStyleOption(styled, index)
|
||||||
|
styled.text = ""
|
||||||
|
self.parent().style().drawControl(QStyle.ControlElement.CE_ItemViewItem, styled, painter)
|
||||||
|
document = self.document(option, index, option.rect.width())
|
||||||
|
painter.save()
|
||||||
|
painter.setClipRect(option.rect)
|
||||||
|
painter.translate(option.rect.left() + 8, option.rect.top() + 6)
|
||||||
|
document.drawContents(painter)
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
|
||||||
|
width = self.parent().columnWidth(index.column())
|
||||||
|
document = self.document(option, index, width)
|
||||||
|
return QSize(width, max(36, ceil(document.size().height()) + 12))
|
||||||
|
|
||||||
|
|
||||||
def _patient_cell(_value: Any, row: Any) -> str:
|
def _patient_cell(_value: Any, row: Any) -> str:
|
||||||
gender = first_value(row, "gender", default=None)
|
gender = first_value(row, "gender", default=None)
|
||||||
gender_text = "男" if gender in (1, "1") else "女" if gender in (0, "0") else "未知"
|
gender_text = "男" if gender in (1, "1") else "女" if gender in (0, "0") else "未知"
|
||||||
@@ -698,7 +761,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.table = SortableTable(
|
self.table = SortableTable(
|
||||||
[
|
[
|
||||||
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
|
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
|
||||||
TableColumn("sn", "处方编号", 174, _sn_cell),
|
TableColumn("sn", "处方编号", 260, _sn_cell),
|
||||||
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
|
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
|
||||||
TableColumn("prescription_type", "处方类型", 96),
|
TableColumn("prescription_type", "处方类型", 96),
|
||||||
TableColumn("is_system_auto", "来源", 88, _source_cell),
|
TableColumn("is_system_auto", "来源", 88, _source_cell),
|
||||||
@@ -713,6 +776,9 @@ class PrescriptionsPage(QWidget):
|
|||||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
self.table.verticalHeader().setDefaultSectionSize(36)
|
||||||
self.table.horizontalHeader().setFixedHeight(38)
|
self.table.horizontalHeader().setFixedHeight(38)
|
||||||
self.table.setWordWrap(False)
|
self.table.setWordWrap(False)
|
||||||
|
self.table.setItemDelegateForColumn(1, _PrescriptionNumberDelegate(self.table))
|
||||||
|
self.table.horizontalHeader().sectionResized.connect(self._number_column_resized)
|
||||||
|
self.table.model().layoutChanged.connect(self.table.resizeRowsToContents)
|
||||||
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 14))
|
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 14))
|
||||||
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||||
@@ -729,6 +795,10 @@ class PrescriptionsPage(QWidget):
|
|||||||
layout.addWidget(self.stack, 1)
|
layout.addWidget(self.stack, 1)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
def _number_column_resized(self, column: int, _old_size: int, _new_size: int) -> None:
|
||||||
|
if column == 1:
|
||||||
|
self.table.resizeRowsToContents()
|
||||||
|
|
||||||
def _action_button(
|
def _action_button(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
@@ -910,8 +980,11 @@ class PrescriptionsPage(QWidget):
|
|||||||
font.setWeight(QFont.Weight.DemiBold)
|
font.setWeight(QFont.Weight.DemiBold)
|
||||||
sn_item.setFont(font)
|
sn_item.setFont(font)
|
||||||
warnings = _order_warnings(row)
|
warnings = _order_warnings(row)
|
||||||
if warnings:
|
description = "\n".join(
|
||||||
sn_item.setToolTip("\n".join(warnings))
|
[sn_item.text(), f"ID: {first_value(row, 'id', 'prescription_id', default='—')}", *warnings]
|
||||||
|
)
|
||||||
|
sn_item.setToolTip(description)
|
||||||
|
sn_item.setData(Qt.ItemDataRole.AccessibleTextRole, description)
|
||||||
|
|
||||||
prescription_type = display_text(
|
prescription_type = display_text(
|
||||||
first_value(row, "prescription_type", default="—")
|
first_value(row, "prescription_type", default="—")
|
||||||
@@ -1002,6 +1075,7 @@ class PrescriptionsPage(QWidget):
|
|||||||
actions.addStretch(1)
|
actions.addStretch(1)
|
||||||
self.table.setCellWidget(row_index, 2, actions_host)
|
self.table.setCellWidget(row_index, 2, actions_host)
|
||||||
self._sync_row_mutation_actions()
|
self._sync_row_mutation_actions()
|
||||||
|
self.table.resizeRowsToContents()
|
||||||
|
|
||||||
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
|
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
|
||||||
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
|
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ from PySide6.QtGui import (
|
|||||||
QFont,
|
QFont,
|
||||||
QFontMetrics,
|
QFontMetrics,
|
||||||
QIcon,
|
QIcon,
|
||||||
|
QKeySequence,
|
||||||
QPainter,
|
QPainter,
|
||||||
QPainterPath,
|
QPainterPath,
|
||||||
QPen,
|
QPen,
|
||||||
QPixmap,
|
QPixmap,
|
||||||
|
QShortcut,
|
||||||
QTextCursor,
|
QTextCursor,
|
||||||
QTextLayout,
|
QTextLayout,
|
||||||
QTextOption,
|
QTextOption,
|
||||||
@@ -72,6 +74,7 @@ from ..diagnosis_drawer import DailyRecordPanel
|
|||||||
from ..diagnosis_editors import FlowLayout
|
from ..diagnosis_editors import FlowLayout
|
||||||
from ..dialogs import DiagnosisDialog
|
from ..dialogs import DiagnosisDialog
|
||||||
from ..dialogs.ai_consult import present_ai_consult
|
from ..dialogs.ai_consult import present_ai_consult
|
||||||
|
from ..dialogs.appointment_complete import COMPLETION_NOTE_LIMIT, AppointmentCompleteDialog
|
||||||
from ..dialogs.prescription_ai import (
|
from ..dialogs.prescription_ai import (
|
||||||
can_open_diagnosis_ai_report,
|
can_open_diagnosis_ai_report,
|
||||||
can_use_diagnosis_ai_assistant,
|
can_use_diagnosis_ai_assistant,
|
||||||
@@ -112,6 +115,12 @@ _AI_AUTOMATIC_REQUEST_SLOTS = BoundedSemaphore(2)
|
|||||||
_AI_GENERATION_POOL = QThreadPool()
|
_AI_GENERATION_POOL = QThreadPool()
|
||||||
_AI_GENERATION_POOL.setMaxThreadCount(4)
|
_AI_GENERATION_POOL.setMaxThreadCount(4)
|
||||||
_AI_GENERATION_POOL.setExpiryTimeout(30_000)
|
_AI_GENERATION_POOL.setExpiryTimeout(30_000)
|
||||||
|
# Keep explicit recovery reads independent of automatic analysis workers.
|
||||||
|
_RECEPTION_REFRESH_POOL = QThreadPool()
|
||||||
|
_RECEPTION_REFRESH_POOL.setMaxThreadCount(3)
|
||||||
|
_RECEPTION_REFRESH_POOL.setExpiryTimeout(30_000)
|
||||||
|
_REFRESH_TIMEOUT_MS = 30_000
|
||||||
|
_REFRESH_COOLDOWN_MS = 1_500
|
||||||
AI_MEDICAL_DISCLAIMER = (
|
AI_MEDICAL_DISCLAIMER = (
|
||||||
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
|
||||||
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
|
||||||
@@ -157,6 +166,32 @@ QCalendarWidget#ReceptionDateCalendar {
|
|||||||
border: 1px solid #DDE5FA;
|
border: 1px solid #DDE5FA;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton {
|
||||||
|
min-height: 32px;
|
||||||
|
max-height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
color: #5469F0;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
border: 1px solid #E4E9F6;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:enabled:hover {
|
||||||
|
background-color: #EEF1FF;
|
||||||
|
border-color: #C5CEFF;
|
||||||
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:enabled:pressed {
|
||||||
|
background-color: #E2E7FF;
|
||||||
|
border-color: #9EACFF;
|
||||||
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:focus {
|
||||||
|
border-color: #5469F0;
|
||||||
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionRefreshButton:disabled {
|
||||||
|
color: #8A93A8;
|
||||||
|
background-color: #F7F8FB;
|
||||||
|
border-color: #E4E9F6;
|
||||||
|
}
|
||||||
QWidget#ReceptionPage QPushButton[receptionQueueChip="true"] {
|
QWidget#ReceptionPage QPushButton[receptionQueueChip="true"] {
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
max-height: 32px;
|
max-height: 32px;
|
||||||
@@ -259,6 +294,7 @@ QWidget#ReceptionPage QLabel#StatusBadge {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton,
|
||||||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton,
|
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton,
|
||||||
QWidget#ReceptionPage QPushButton#ReceptionImButton {
|
QWidget#ReceptionPage QPushButton#ReceptionImButton {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
@@ -282,6 +318,7 @@ QWidget#ReceptionPage QPushButton#ReceptionMoreButton {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton:hover,
|
||||||
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton:hover,
|
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton:hover,
|
||||||
QWidget#ReceptionPage QPushButton#ReceptionImButton:hover {
|
QWidget#ReceptionPage QPushButton#ReceptionImButton:hover {
|
||||||
color: #5469F0;
|
color: #5469F0;
|
||||||
@@ -304,6 +341,24 @@ QWidget#ReceptionPage QPushButton#ReceptionCompleteButton {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:hover {
|
||||||
|
color: #CF4656;
|
||||||
|
background-color: #FFF0F2;
|
||||||
|
border-color: #EFA3AD;
|
||||||
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:pressed {
|
||||||
|
color: #BF3949;
|
||||||
|
background-color: #FFE4E8;
|
||||||
|
border-color: #E58A98;
|
||||||
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:enabled:focus {
|
||||||
|
border-color: #CF4656;
|
||||||
|
}
|
||||||
|
QWidget#ReceptionPage QPushButton#ReceptionCompleteButton:disabled {
|
||||||
|
color: #A8AFBF;
|
||||||
|
background-color: #F7F8FB;
|
||||||
|
border-color: #E5E8EF;
|
||||||
|
}
|
||||||
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs {
|
QWidget#ReceptionPage QTabBar#ReceptionDetailTabs {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border-bottom: 1px solid #E6EAF5;
|
border-bottom: 1px solid #E6EAF5;
|
||||||
@@ -2088,6 +2143,26 @@ def _is_local_material_reference(value: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ReceptionCompleteButton(QPushButton):
|
||||||
|
"""Completion action with a pointer only while it can be activated."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__("结束问诊", parent)
|
||||||
|
self.setObjectName("ReceptionCompleteButton")
|
||||||
|
self.setIcon(_painted_reception_action_icon("stop", "#F15B67"))
|
||||||
|
self.setIconSize(QSize(14, 14))
|
||||||
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
|
||||||
|
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||||||
|
super().changeEvent(event)
|
||||||
|
if event.type() == QEvent.Type.EnabledChange:
|
||||||
|
self.setCursor(
|
||||||
|
Qt.CursorShape.PointingHandCursor
|
||||||
|
if self.isEnabled()
|
||||||
|
else Qt.CursorShape.ArrowCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _NoteAttachmentPreview(QPushButton):
|
class _NoteAttachmentPreview(QPushButton):
|
||||||
"""Responsive inline thumbnail that remains clickable for a full preview."""
|
"""Responsive inline thumbnail that remains clickable for a full preview."""
|
||||||
|
|
||||||
@@ -3585,6 +3660,8 @@ class ReceptionPage(QWidget):
|
|||||||
self._queue_query: dict[str, Any] | None = None
|
self._queue_query: dict[str, Any] | None = None
|
||||||
self._queue_query_key: tuple[Any, ...] | None = None
|
self._queue_query_key: tuple[Any, ...] | None = None
|
||||||
self._detail_loading = False
|
self._detail_loading = False
|
||||||
|
self._completion_pending = False
|
||||||
|
self._completion_dialog: AppointmentCompleteDialog | None = None
|
||||||
self._detail_requests: set[tuple[int, int]] = set()
|
self._detail_requests: set[tuple[int, int]] = set()
|
||||||
self._detail_cancel_events: dict[tuple[int, int], Event] = {}
|
self._detail_cancel_events: dict[tuple[int, int], Event] = {}
|
||||||
self._detail_failed_requests: set[tuple[int, int]] = set()
|
self._detail_failed_requests: set[tuple[int, int]] = set()
|
||||||
@@ -3677,6 +3754,12 @@ class ReceptionPage(QWidget):
|
|||||||
self.poll_timer = QTimer(self)
|
self.poll_timer = QTimer(self)
|
||||||
self.poll_timer.setInterval(5_000)
|
self.poll_timer.setInterval(5_000)
|
||||||
self.poll_timer.timeout.connect(self._poll_queue)
|
self.poll_timer.timeout.connect(self._poll_queue)
|
||||||
|
self._refresh_cooldown = QTimer(self)
|
||||||
|
self._refresh_cooldown.setSingleShot(True)
|
||||||
|
self._refresh_cooldown.timeout.connect(self._finish_refresh_cooldown)
|
||||||
|
self.refresh_shortcut = QShortcut(QKeySequence("F5"), self)
|
||||||
|
self.refresh_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||||
|
self.refresh_shortcut.activated.connect(self._refresh_workspace)
|
||||||
|
|
||||||
def _ensure_diagnosis_dialog(self) -> DiagnosisDialog:
|
def _ensure_diagnosis_dialog(self) -> DiagnosisDialog:
|
||||||
dialog = getattr(self, "diagnosis_dialog", None)
|
dialog = getattr(self, "diagnosis_dialog", None)
|
||||||
@@ -3703,6 +3786,14 @@ class ReceptionPage(QWidget):
|
|||||||
title.setObjectName("ReceptionQueueTitle")
|
title.setObjectName("ReceptionQueueTitle")
|
||||||
header_layout.addWidget(title)
|
header_layout.addWidget(title)
|
||||||
header_layout.addStretch(1)
|
header_layout.addStretch(1)
|
||||||
|
self.refresh_button = QPushButton("刷新", header)
|
||||||
|
self.refresh_button.setObjectName("ReceptionRefreshButton")
|
||||||
|
self.refresh_button.setFixedWidth(58)
|
||||||
|
self.refresh_button.setAccessibleName("刷新接诊台")
|
||||||
|
self.refresh_button.setToolTip("刷新队列、当前患者和已保存的 AI 报告(F5)")
|
||||||
|
self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.refresh_button.clicked.connect(self._refresh_workspace)
|
||||||
|
header_layout.addWidget(self.refresh_button)
|
||||||
self.queue_date_button = _ReceptionDateButton(self._queue_date, header)
|
self.queue_date_button = _ReceptionDateButton(self._queue_date, header)
|
||||||
self.queue_date_button.setObjectName("ReceptionDateButton")
|
self.queue_date_button.setObjectName("ReceptionDateButton")
|
||||||
self.queue_date_button.setFixedWidth(118)
|
self.queue_date_button.setFixedWidth(118)
|
||||||
@@ -3754,7 +3845,7 @@ class ReceptionPage(QWidget):
|
|||||||
self.queue_list.verticalScrollBar().valueChanged.connect(self._on_queue_scroll)
|
self.queue_list.verticalScrollBar().valueChanged.connect(self._on_queue_scroll)
|
||||||
self.queue_stack.addWidget(self.queue_list)
|
self.queue_stack.addWidget(self.queue_list)
|
||||||
self.queue_empty = EmptyState("队列为空", "当前筛选下没有待处理患者。", "重新加载")
|
self.queue_empty = EmptyState("队列为空", "当前筛选下没有待处理患者。", "重新加载")
|
||||||
self.queue_empty.action_requested.connect(lambda: self.refresh())
|
self.queue_empty.action_requested.connect(self._refresh_workspace)
|
||||||
self.queue_stack.addWidget(self.queue_empty)
|
self.queue_stack.addWidget(self.queue_empty)
|
||||||
layout.addWidget(self.queue_stack, 1)
|
layout.addWidget(self.queue_stack, 1)
|
||||||
self.queue_loading_indicator = _ReceptionQueueLoading(panel)
|
self.queue_loading_indicator = _ReceptionQueueLoading(panel)
|
||||||
@@ -3825,13 +3916,14 @@ class ReceptionPage(QWidget):
|
|||||||
patient_head.addLayout(identity, 1)
|
patient_head.addLayout(identity, 1)
|
||||||
patient_head.addStretch(1)
|
patient_head.addStretch(1)
|
||||||
|
|
||||||
self.complete_button = QPushButton("结束问诊", hero)
|
self.complete_button = _ReceptionCompleteButton(hero)
|
||||||
self.complete_button.setObjectName("ReceptionCompleteButton")
|
|
||||||
self.complete_button.setIcon(_painted_reception_action_icon("stop", "#F15B67"))
|
|
||||||
self.complete_button.setIconSize(QSize(14, 14))
|
|
||||||
self.complete_button.clicked.connect(self._complete_appointment)
|
self.complete_button.clicked.connect(self._complete_appointment)
|
||||||
self.complete_button.setVisible(self._can_complete)
|
self.complete_button.setVisible(self._can_complete)
|
||||||
patient_head.addWidget(self.complete_button)
|
patient_head.addWidget(self.complete_button)
|
||||||
|
self.notify_button = QPushButton("通知医助", hero)
|
||||||
|
self.notify_button.setObjectName("ReceptionNotifyButton")
|
||||||
|
self.notify_button.clicked.connect(self._notify_assistant)
|
||||||
|
patient_head.addWidget(self.notify_button)
|
||||||
self.history_button = QPushButton("查看历史", hero)
|
self.history_button = QPushButton("查看历史", hero)
|
||||||
self.history_button.setObjectName("ReceptionHistoryButton")
|
self.history_button.setObjectName("ReceptionHistoryButton")
|
||||||
self.history_button.setIcon(_painted_reception_action_icon("info", "#3F4E75"))
|
self.history_button.setIcon(_painted_reception_action_icon("info", "#3F4E75"))
|
||||||
@@ -3847,7 +3939,6 @@ class ReceptionPage(QWidget):
|
|||||||
self.more_button = QPushButton("更多", hero)
|
self.more_button = QPushButton("更多", hero)
|
||||||
self.more_button.setObjectName("ReceptionMoreButton")
|
self.more_button.setObjectName("ReceptionMoreButton")
|
||||||
more_menu = QMenu(self.more_button)
|
more_menu = QMenu(self.more_button)
|
||||||
more_menu.addAction("通知医助").triggered.connect(self._notify_assistant)
|
|
||||||
edit_action = more_menu.addAction("编辑病历")
|
edit_action = more_menu.addAction("编辑病历")
|
||||||
edit_action.setVisible(self._can_edit)
|
edit_action.setVisible(self._can_edit)
|
||||||
edit_action.triggered.connect(self._edit_diagnosis)
|
edit_action.triggered.connect(self._edit_diagnosis)
|
||||||
@@ -3865,8 +3956,6 @@ class ReceptionPage(QWidget):
|
|||||||
action_compat = QWidget(hero)
|
action_compat = QWidget(hero)
|
||||||
action_compat.setFixedSize(0, 0)
|
action_compat.setFixedSize(0, 0)
|
||||||
action_compat.move(-100, -100)
|
action_compat.move(-100, -100)
|
||||||
self.notify_button = QPushButton("通知医助", action_compat)
|
|
||||||
self.notify_button.clicked.connect(self._notify_assistant)
|
|
||||||
self.edit_button = QPushButton("编辑病历", action_compat)
|
self.edit_button = QPushButton("编辑病历", action_compat)
|
||||||
self.edit_button.setProperty("variant", "secondary")
|
self.edit_button.setProperty("variant", "secondary")
|
||||||
self.edit_button.clicked.connect(self._edit_diagnosis)
|
self.edit_button.clicked.connect(self._edit_diagnosis)
|
||||||
@@ -5264,6 +5353,7 @@ class ReceptionPage(QWidget):
|
|||||||
appointment_id: int,
|
appointment_id: int,
|
||||||
diagnosis_id: int | None,
|
diagnosis_id: int | None,
|
||||||
force: bool,
|
force: bool,
|
||||||
|
read_only_refresh: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not force and _same_id(patient_id, self._ai_analysis_patient_id):
|
if not force and _same_id(patient_id, self._ai_analysis_patient_id):
|
||||||
qwen_state = self._ai_analysis_model_states["qwen"]
|
qwen_state = self._ai_analysis_model_states["qwen"]
|
||||||
@@ -5310,7 +5400,7 @@ class ReceptionPage(QWidget):
|
|||||||
self._patient_ai_list_requests.discard(cancelled_key)
|
self._patient_ai_list_requests.discard(cancelled_key)
|
||||||
self._patient_ai_list_cancel_events.pop(cancelled_key, None)
|
self._patient_ai_list_cancel_events.pop(cancelled_key, None)
|
||||||
self._patient_ai_list_epochs.pop(cancelled_key, None)
|
self._patient_ai_list_epochs.pop(cancelled_key, None)
|
||||||
if self._patient_ai_request_pending(patient_id, "qwen"):
|
if not read_only_refresh and self._patient_ai_request_pending(patient_id, "qwen"):
|
||||||
self._sync_ai_analysis_view()
|
self._sync_ai_analysis_view()
|
||||||
return
|
return
|
||||||
request_key = (request_generation, appointment_id, patient_id)
|
request_key = (request_generation, appointment_id, patient_id)
|
||||||
@@ -5330,8 +5420,10 @@ class ReceptionPage(QWidget):
|
|||||||
patient_id,
|
patient_id,
|
||||||
) or cancel_event.is_set():
|
) or cancel_event.is_set():
|
||||||
return _ASYNC_REQUEST_CANCELLED
|
return _ASYNC_REQUEST_CANCELLED
|
||||||
automatic_slot = _AI_AUTOMATIC_REQUEST_SLOTS.acquire(blocking=False)
|
automatic_slot = not read_only_refresh and _AI_AUTOMATIC_REQUEST_SLOTS.acquire(
|
||||||
if not automatic_slot:
|
blocking=False
|
||||||
|
)
|
||||||
|
if not read_only_refresh and not automatic_slot:
|
||||||
return _ASYNC_REQUEST_DEFERRED
|
return _ASYNC_REQUEST_DEFERRED
|
||||||
try:
|
try:
|
||||||
self._patient_ai_list_started.add(request_key)
|
self._patient_ai_list_started.add(request_key)
|
||||||
@@ -5344,15 +5436,18 @@ class ReceptionPage(QWidget):
|
|||||||
return _ASYNC_REQUEST_CANCELLED
|
return _ASYNC_REQUEST_CANCELLED
|
||||||
return method(patient_id)
|
return method(patient_id)
|
||||||
finally:
|
finally:
|
||||||
_AI_AUTOMATIC_REQUEST_SLOTS.release()
|
if automatic_slot:
|
||||||
|
_AI_AUTOMATIC_REQUEST_SLOTS.release()
|
||||||
|
|
||||||
run_async(
|
runner = self._run_refresh_read if read_only_refresh else run_async
|
||||||
|
runner(
|
||||||
request,
|
request,
|
||||||
on_success=lambda result: self._apply_patient_ai_report_list(
|
on_success=lambda result: self._apply_patient_ai_report_list(
|
||||||
result,
|
result,
|
||||||
request_generation,
|
request_generation,
|
||||||
appointment_id,
|
appointment_id,
|
||||||
patient_id,
|
patient_id,
|
||||||
|
read_only_refresh=read_only_refresh,
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._patient_ai_report_list_error(
|
on_error=lambda error: self._patient_ai_report_list_error(
|
||||||
error,
|
error,
|
||||||
@@ -5374,6 +5469,8 @@ class ReceptionPage(QWidget):
|
|||||||
request_generation: int,
|
request_generation: int,
|
||||||
appointment_id: int,
|
appointment_id: int,
|
||||||
patient_id: int,
|
patient_id: int,
|
||||||
|
*,
|
||||||
|
read_only_refresh: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
request_key = (request_generation, appointment_id, patient_id)
|
request_key = (request_generation, appointment_id, patient_id)
|
||||||
authoritative = request_key in self._patient_ai_list_requests
|
authoritative = request_key in self._patient_ai_list_requests
|
||||||
@@ -5423,6 +5520,12 @@ class ReceptionPage(QWidget):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if read_only_refresh:
|
||||||
|
self._set_ai_analysis_state(
|
||||||
|
"missing", "暂无已保存的患者报告;刷新不会自动生成,可点击重新分析。"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if not self._can_ai_regenerate:
|
if not self._can_ai_regenerate:
|
||||||
self._set_ai_analysis_state(
|
self._set_ai_analysis_state(
|
||||||
"missing",
|
"missing",
|
||||||
@@ -6214,14 +6317,111 @@ class ReceptionPage(QWidget):
|
|||||||
self._reset_queue_state()
|
self._reset_queue_state()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def refresh(self, silent: bool = False) -> None:
|
def _run_refresh_read(
|
||||||
|
self,
|
||||||
|
function: Any,
|
||||||
|
*,
|
||||||
|
on_success: Any,
|
||||||
|
on_error: Any,
|
||||||
|
on_finished: Any,
|
||||||
|
priority: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Bound GUI waiting; a timed-out worker may finish but cannot apply data.
|
||||||
|
|
||||||
|
Running HTTP calls are not forcibly terminated. The fixed-size pool
|
||||||
|
bounds concurrency, and expired queued reads never call the backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settled = Event()
|
||||||
|
timer = QTimer(self)
|
||||||
|
timer.setSingleShot(True)
|
||||||
|
timer.destroyed.connect(lambda _object=None: settled.set())
|
||||||
|
|
||||||
|
def settle(callback: Any, result: Any) -> None:
|
||||||
|
if settled.is_set():
|
||||||
|
return
|
||||||
|
settled.set()
|
||||||
|
timer.stop()
|
||||||
|
timer.deleteLater()
|
||||||
|
try:
|
||||||
|
callback(result)
|
||||||
|
finally:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def request() -> Any:
|
||||||
|
if settled.is_set():
|
||||||
|
return _ASYNC_REQUEST_CANCELLED
|
||||||
|
return function()
|
||||||
|
|
||||||
|
timer.timeout.connect(
|
||||||
|
lambda: settle(on_error, TimeoutError("刷新超时,请检查网络后再次点击刷新。"))
|
||||||
|
)
|
||||||
|
timer.start(_REFRESH_TIMEOUT_MS)
|
||||||
|
run_async(
|
||||||
|
request,
|
||||||
|
on_success=lambda result: settle(on_success, result),
|
||||||
|
on_error=lambda error: settle(on_error, error),
|
||||||
|
on_finished=lambda: settle(
|
||||||
|
on_error, RuntimeError("刷新未返回有效结果,请重试。")
|
||||||
|
),
|
||||||
|
pool=_RECEPTION_REFRESH_POOL,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel_patient_ai_reads(self, patient_id: int | None) -> None:
|
||||||
|
# Reads are normally shared across selection generations. Explicit
|
||||||
|
# refresh revokes their authority, but never forgets an in-flight POST.
|
||||||
|
for key in list(self._patient_ai_list_requests):
|
||||||
|
if patient_id is not None and not _same_id(key[2], patient_id):
|
||||||
|
continue
|
||||||
|
cancel_event = self._patient_ai_list_cancel_events.pop(key, None)
|
||||||
|
if cancel_event is not None:
|
||||||
|
cancel_event.set()
|
||||||
|
self._patient_ai_list_requests.discard(key)
|
||||||
|
self._patient_ai_list_epochs.pop(key, None)
|
||||||
|
self._patient_ai_list_started.discard(key)
|
||||||
|
|
||||||
|
def _refresh_workspace(self) -> None:
|
||||||
|
"""Recover reads without replaying writes or clearing the doctor's draft."""
|
||||||
|
|
||||||
|
if self._refresh_cooldown.isActive():
|
||||||
|
return
|
||||||
|
if self._note_busy or self._completion_pending:
|
||||||
|
show_toast(self, "正在提交,请等待提交结束后再刷新。", "warning")
|
||||||
|
return
|
||||||
|
if self._completion_dialog is not None and self._completion_dialog.isVisible():
|
||||||
|
show_toast(self, "请先关闭完成问诊窗口,再刷新接诊台。", "warning")
|
||||||
|
return
|
||||||
|
if self._selected_appointment_id is not None and not self.notify_button.isEnabled():
|
||||||
|
show_toast(self, "正在通知医助,请稍后刷新。", "warning")
|
||||||
|
return
|
||||||
|
self.refresh_button.setEnabled(False)
|
||||||
|
self.refresh_button.setCursor(Qt.CursorShape.ArrowCursor)
|
||||||
|
self._refresh_cooldown.start(_REFRESH_COOLDOWN_MS)
|
||||||
|
context = self._selection_context()
|
||||||
|
self._cancel_patient_ai_reads(context[3] if context is not None else None)
|
||||||
|
# Invalidate a pending daily-range result before the fresh detail arrives.
|
||||||
|
self._daily_generation += 1
|
||||||
|
self._daily_loading = False
|
||||||
|
self.daily_panel.set_loading(False)
|
||||||
|
record = self._selected_record
|
||||||
|
if record is not None:
|
||||||
|
self._load_detail(record, clear=False, read_only_refresh=True)
|
||||||
|
# A stuck queue must not prevent refreshing the selected patient's detail.
|
||||||
|
self.refresh(workspace_refresh=True)
|
||||||
|
|
||||||
|
def _finish_refresh_cooldown(self) -> None:
|
||||||
|
self.refresh_button.setEnabled(True)
|
||||||
|
self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
|
||||||
|
def refresh(self, silent: bool = False, *, workspace_refresh: bool = False) -> None:
|
||||||
"""Replace the queue with page one using a GUI-thread query snapshot."""
|
"""Replace the queue with page one using a GUI-thread query snapshot."""
|
||||||
|
|
||||||
if silent and self._queue_loading:
|
if silent and self._queue_loading and not workspace_refresh:
|
||||||
return
|
return
|
||||||
selected_date = self._queue_date or date.today().isoformat()
|
selected_date = self._queue_date or date.today().isoformat()
|
||||||
page_size = self._queue_page_size
|
page_size = self._queue_page_size
|
||||||
if silent:
|
if silent or workspace_refresh:
|
||||||
loaded = max(len(self._queue_records), self._queue_page * self._queue_page_size)
|
loaded = max(len(self._queue_records), self._queue_page * self._queue_page_size)
|
||||||
if loaded > page_size:
|
if loaded > page_size:
|
||||||
page_size = loaded
|
page_size = loaded
|
||||||
@@ -6236,12 +6436,15 @@ class ReceptionPage(QWidget):
|
|||||||
}
|
}
|
||||||
query_key = self._query_key(query)
|
query_key = self._query_key(query)
|
||||||
if query_key != self._queue_query_key:
|
if query_key != self._queue_query_key:
|
||||||
self._clear_selection()
|
if not workspace_refresh:
|
||||||
|
self._clear_selection()
|
||||||
self._reset_queue_state()
|
self._reset_queue_state()
|
||||||
self._queue_query = dict(query)
|
self._queue_query = dict(query)
|
||||||
self._queue_query["page_size"] = self._queue_page_size
|
self._queue_query["page_size"] = self._queue_page_size
|
||||||
self._queue_query_key = query_key
|
self._queue_query_key = query_key
|
||||||
self._request_queue_page(query, append=False, silent=silent)
|
self._request_queue_page(
|
||||||
|
query, append=False, silent=silent, workspace_refresh=workspace_refresh
|
||||||
|
)
|
||||||
|
|
||||||
def _poll_queue(self) -> None:
|
def _poll_queue(self) -> None:
|
||||||
"""Do not let the timer supersede an explicit or slower queue request."""
|
"""Do not let the timer supersede an explicit or slower queue request."""
|
||||||
@@ -6398,6 +6601,7 @@ class ReceptionPage(QWidget):
|
|||||||
*,
|
*,
|
||||||
append: bool,
|
append: bool,
|
||||||
silent: bool,
|
silent: bool,
|
||||||
|
workspace_refresh: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._queue_generation += 1
|
self._queue_generation += 1
|
||||||
generation = self._queue_generation
|
generation = self._queue_generation
|
||||||
@@ -6409,12 +6613,18 @@ class ReceptionPage(QWidget):
|
|||||||
frozen_query = dict(query)
|
frozen_query = dict(query)
|
||||||
page_no = int(frozen_query["page_no"])
|
page_no = int(frozen_query["page_no"])
|
||||||
query_key = self._query_key(frozen_query)
|
query_key = self._query_key(frozen_query)
|
||||||
run_async(
|
def request() -> Any:
|
||||||
lambda frozen_query=frozen_query: invoke(
|
if generation != self._queue_generation:
|
||||||
|
return _ASYNC_REQUEST_CANCELLED
|
||||||
|
return invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"list_appointments",
|
"list_appointments",
|
||||||
**frozen_query,
|
**frozen_query,
|
||||||
),
|
)
|
||||||
|
|
||||||
|
runner = self._run_refresh_read if workspace_refresh else run_async
|
||||||
|
runner(
|
||||||
|
request,
|
||||||
on_success=lambda result: self._apply_queue(
|
on_success=lambda result: self._apply_queue(
|
||||||
result,
|
result,
|
||||||
generation,
|
generation,
|
||||||
@@ -6422,6 +6632,7 @@ class ReceptionPage(QWidget):
|
|||||||
append=append,
|
append=append,
|
||||||
query_key=query_key,
|
query_key=query_key,
|
||||||
silent=silent,
|
silent=silent,
|
||||||
|
workspace_refresh=workspace_refresh,
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._queue_error(error, generation, silent=silent),
|
on_error=lambda error: self._queue_error(error, generation, silent=silent),
|
||||||
on_finished=lambda: self._queue_finished(generation),
|
on_finished=lambda: self._queue_finished(generation),
|
||||||
@@ -6436,6 +6647,7 @@ class ReceptionPage(QWidget):
|
|||||||
append: bool = False,
|
append: bool = False,
|
||||||
query_key: tuple[Any, ...] | None = None,
|
query_key: tuple[Any, ...] | None = None,
|
||||||
silent: bool = False,
|
silent: bool = False,
|
||||||
|
workspace_refresh: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if generation != self._queue_generation or query_key not in (None, self._queue_query_key):
|
if generation != self._queue_generation or query_key not in (None, self._queue_query_key):
|
||||||
return
|
return
|
||||||
@@ -6477,27 +6689,38 @@ class ReceptionPage(QWidget):
|
|||||||
),
|
),
|
||||||
-1,
|
-1,
|
||||||
)
|
)
|
||||||
if row_to_select < 0 and records:
|
preserve_selection = (workspace_refresh or silent) and selected_id is not None
|
||||||
|
if row_to_select < 0 and records and not preserve_selection:
|
||||||
row_to_select = 0
|
row_to_select = 0
|
||||||
self.queue_list.blockSignals(True)
|
self.queue_list.blockSignals(True)
|
||||||
self._sync_queue_rows(records, append=append)
|
self._sync_queue_rows(records, append=append)
|
||||||
if row_to_select >= 0 and self.queue_list.currentRow() != row_to_select:
|
if row_to_select >= 0 and self.queue_list.currentRow() != row_to_select:
|
||||||
self.queue_list.setCurrentRow(row_to_select)
|
self.queue_list.setCurrentRow(row_to_select)
|
||||||
|
elif row_to_select < 0:
|
||||||
|
self.queue_list.setCurrentRow(-1)
|
||||||
self.queue_list.blockSignals(False)
|
self.queue_list.blockSignals(False)
|
||||||
self._sync_queue_row_selection()
|
self._sync_queue_row_selection()
|
||||||
self.queue_summary.setText(f"已加载 {len(records)} / 共 {self._queue_total} 位患者")
|
self.queue_summary.setText(f"已加载 {len(records)} / 共 {self._queue_total} 位患者")
|
||||||
self.queue_stack.setCurrentIndex(0 if records else 1)
|
self.queue_stack.setCurrentIndex(0 if records else 1)
|
||||||
self.queue_banner.clear()
|
self.queue_banner.clear()
|
||||||
if not records:
|
if row_to_select < 0:
|
||||||
self._clear_selection()
|
# The patient may have left the queue. Keep the detail and unsaved
|
||||||
|
# draft attached to that patient until the doctor selects another.
|
||||||
|
if preserve_selection:
|
||||||
|
self.queue_banner.show_message(
|
||||||
|
"当前患者已不在筛选队列,已保留详情与未保存内容,可手动选择其他患者。",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._clear_selection()
|
||||||
return
|
return
|
||||||
chosen = records[row_to_select]
|
chosen = records[row_to_select]
|
||||||
if selected_id is not None and _same_id(_record_id(chosen), selected_id):
|
if selected_id is not None and _same_id(_record_id(chosen), selected_id):
|
||||||
self._selected_record = chosen
|
self._selected_record = chosen
|
||||||
if not append and not silent:
|
if not append and not silent and not workspace_refresh:
|
||||||
self._load_detail(chosen, silent=True, clear=False)
|
self._load_detail(chosen, silent=True, clear=False)
|
||||||
else:
|
else:
|
||||||
self._select_record(chosen, silent=True)
|
self._select_record(chosen, silent=True, read_only_refresh=workspace_refresh)
|
||||||
|
|
||||||
def _update_queue_filter_counts(self, result: Any, records: list[Any]) -> None:
|
def _update_queue_filter_counts(self, result: Any, records: list[Any]) -> None:
|
||||||
extend = get_value(result, "extend", None)
|
extend = get_value(result, "extend", None)
|
||||||
@@ -6570,7 +6793,9 @@ class ReceptionPage(QWidget):
|
|||||||
if isinstance(row, QueueRow):
|
if isinstance(row, QueueRow):
|
||||||
row.set_selected(item is current)
|
row.set_selected(item is current)
|
||||||
|
|
||||||
def _select_record(self, record: Any, *, silent: bool = False) -> None:
|
def _select_record(
|
||||||
|
self, record: Any, *, silent: bool = False, read_only_refresh: bool = False
|
||||||
|
) -> None:
|
||||||
appointment_id = _record_id(record)
|
appointment_id = _record_id(record)
|
||||||
if appointment_id is None:
|
if appointment_id is None:
|
||||||
self._clear_selection()
|
self._clear_selection()
|
||||||
@@ -6586,9 +6811,16 @@ class ReceptionPage(QWidget):
|
|||||||
self._selected_record = record
|
self._selected_record = record
|
||||||
self._selected_appointment_id = appointment_id
|
self._selected_appointment_id = appointment_id
|
||||||
self._selected_detail = None
|
self._selected_detail = None
|
||||||
self._load_detail(record, silent=silent, clear=True)
|
self._load_detail(record, silent=silent, clear=True, read_only_refresh=read_only_refresh)
|
||||||
|
|
||||||
def _load_detail(self, record: Any, silent: bool = False, *, clear: bool = True) -> None:
|
def _load_detail(
|
||||||
|
self,
|
||||||
|
record: Any,
|
||||||
|
silent: bool = False,
|
||||||
|
*,
|
||||||
|
clear: bool = True,
|
||||||
|
read_only_refresh: bool = False,
|
||||||
|
) -> None:
|
||||||
"""Start a new detail generation even while an older request is running."""
|
"""Start a new detail generation even while an older request is running."""
|
||||||
|
|
||||||
appointment_id = _record_id(record)
|
appointment_id = _record_id(record)
|
||||||
@@ -6611,9 +6843,12 @@ class ReceptionPage(QWidget):
|
|||||||
self.detail_banner.show_message("正在加载患者详情…", "info")
|
self.detail_banner.show_message("正在加载患者详情…", "info")
|
||||||
self._detail_loading = True
|
self._detail_loading = True
|
||||||
self._detail_requests.add(request_key)
|
self._detail_requests.add(request_key)
|
||||||
run_async(
|
runner = self._run_refresh_read if read_only_refresh else run_async
|
||||||
|
runner(
|
||||||
lambda: self._fetch_detail_bundle(record, appointment_id, cancel_event),
|
lambda: self._fetch_detail_bundle(record, appointment_id, cancel_event),
|
||||||
on_success=lambda bundle: self._apply_detail(bundle, generation, appointment_id),
|
on_success=lambda bundle: self._apply_detail(
|
||||||
|
bundle, generation, appointment_id, read_only_refresh=read_only_refresh
|
||||||
|
),
|
||||||
on_error=lambda error: self._detail_error(error, generation, appointment_id),
|
on_error=lambda error: self._detail_error(error, generation, appointment_id),
|
||||||
on_finished=lambda: self._detail_finished(generation, appointment_id),
|
on_finished=lambda: self._detail_finished(generation, appointment_id),
|
||||||
priority=generation,
|
priority=generation,
|
||||||
@@ -6874,7 +7109,12 @@ class ReceptionPage(QWidget):
|
|||||||
self.daily_panel.set_loading(False)
|
self.daily_panel.set_loading(False)
|
||||||
|
|
||||||
def _apply_detail(
|
def _apply_detail(
|
||||||
self, bundle: Any, generation: int, appointment_id: int | None = None
|
self,
|
||||||
|
bundle: Any,
|
||||||
|
generation: int,
|
||||||
|
appointment_id: int | None = None,
|
||||||
|
*,
|
||||||
|
read_only_refresh: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
expected_id = appointment_id or self._selected_appointment_id
|
expected_id = appointment_id or self._selected_appointment_id
|
||||||
if (
|
if (
|
||||||
@@ -6963,7 +7203,9 @@ class ReceptionPage(QWidget):
|
|||||||
supports_patient_reports = self._can_patient_ai_read and callable(
|
supports_patient_reports = self._can_patient_ai_read and callable(
|
||||||
getattr(self.repository, "list_patient_ai_reports", None)
|
getattr(self.repository, "list_patient_ai_reports", None)
|
||||||
) and callable(getattr(self.repository, "generate_patient_ai_report", None))
|
) and callable(getattr(self.repository, "generate_patient_ai_report", None))
|
||||||
if patient_id is None and supports_patient_reports:
|
if read_only_refresh:
|
||||||
|
self._refresh_saved_ai_reports(expected_id, diagnosis_id, patient_id)
|
||||||
|
elif patient_id is None and supports_patient_reports:
|
||||||
self._ai_analysis_generation += 1
|
self._ai_analysis_generation += 1
|
||||||
self._ai_analysis_loading = False
|
self._ai_analysis_loading = False
|
||||||
self._ai_analysis_diagnosis_id = None
|
self._ai_analysis_diagnosis_id = None
|
||||||
@@ -6985,6 +7227,37 @@ class ReceptionPage(QWidget):
|
|||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _refresh_saved_ai_reports(
|
||||||
|
self, appointment_id: int, diagnosis_id: int | None, patient_id: int | None
|
||||||
|
) -> None:
|
||||||
|
self._cancel_patient_ai_reads(patient_id)
|
||||||
|
if self._can_patient_ai_read and patient_id is not None and callable(
|
||||||
|
getattr(self.repository, "list_patient_ai_reports", None)
|
||||||
|
):
|
||||||
|
# A generation is a write with an uncertain outcome while running.
|
||||||
|
# Keep its single-flight identity and do not race it with an older GET.
|
||||||
|
if any(
|
||||||
|
self._patient_ai_request_pending(patient_id, model)
|
||||||
|
for model in AI_ANALYSIS_MODELS
|
||||||
|
):
|
||||||
|
self._ai_analysis_operation_error = "报告生成任务仍在处理中,刷新不会重复生成。"
|
||||||
|
self._sync_ai_analysis_view()
|
||||||
|
return
|
||||||
|
self._load_patient_ai_reports(
|
||||||
|
patient_id,
|
||||||
|
appointment_id=appointment_id,
|
||||||
|
diagnosis_id=diagnosis_id,
|
||||||
|
force=True,
|
||||||
|
read_only_refresh=True,
|
||||||
|
)
|
||||||
|
elif not self._can_ai_analysis:
|
||||||
|
self._set_ai_analysis_state("permission")
|
||||||
|
elif not self._ai_analysis_payloads and not self._ai_analysis_requests:
|
||||||
|
# The legacy analysis endpoint is a POST despite its get_* name.
|
||||||
|
self._set_ai_analysis_state(
|
||||||
|
"error", "当前数据源无法只读刷新 AI 报告;如需生成分析,请点击重试。"
|
||||||
|
)
|
||||||
|
|
||||||
def _render_identity(self, appointment: Any, patient: Any, diagnosis: Any) -> None:
|
def _render_identity(self, appointment: Any, patient: Any, diagnosis: Any) -> None:
|
||||||
patient_name = first_value(
|
patient_name = first_value(
|
||||||
appointment,
|
appointment,
|
||||||
@@ -8165,7 +8438,10 @@ class ReceptionPage(QWidget):
|
|||||||
self.history_button.setEnabled(appointment_id is not None)
|
self.history_button.setEnabled(appointment_id is not None)
|
||||||
self.more_button.setEnabled(appointment_id is not None)
|
self.more_button.setEnabled(appointment_id is not None)
|
||||||
self.complete_button.setEnabled(
|
self.complete_button.setEnabled(
|
||||||
self._can_complete and appointment_id is not None and status in RECEPTION_STATUSES
|
self._can_complete
|
||||||
|
and not self._completion_pending
|
||||||
|
and appointment_id is not None
|
||||||
|
and status in RECEPTION_STATUSES
|
||||||
)
|
)
|
||||||
note_enabled = self._can_note and diagnosis_id is not None and not self._note_busy
|
note_enabled = self._can_note and diagnosis_id is not None and not self._note_busy
|
||||||
self.note_edit.setEnabled(note_enabled)
|
self.note_edit.setEnabled(note_enabled)
|
||||||
@@ -8177,6 +8453,10 @@ class ReceptionPage(QWidget):
|
|||||||
self, error: Exception, generation: int, appointment_id: int | None = None
|
self, error: Exception, generation: int, appointment_id: int | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
expected_id = appointment_id or self._selected_appointment_id
|
expected_id = appointment_id or self._selected_appointment_id
|
||||||
|
if expected_id is not None:
|
||||||
|
cancel_event = self._detail_cancel_events.get((generation, expected_id))
|
||||||
|
if cancel_event is not None:
|
||||||
|
cancel_event.set()
|
||||||
if (
|
if (
|
||||||
generation == self._detail_generation
|
generation == self._detail_generation
|
||||||
and expected_id is not None
|
and expected_id is not None
|
||||||
@@ -8731,6 +9011,12 @@ class ReceptionPage(QWidget):
|
|||||||
self._load_detail(self._selected_record, silent=True, clear=False)
|
self._load_detail(self._selected_record, silent=True, clear=False)
|
||||||
|
|
||||||
def _complete_appointment(self) -> None:
|
def _complete_appointment(self) -> None:
|
||||||
|
if self._completion_pending:
|
||||||
|
return
|
||||||
|
if self._completion_dialog is not None:
|
||||||
|
self._completion_dialog.raise_()
|
||||||
|
self._completion_dialog.activateWindow()
|
||||||
|
return
|
||||||
if not self._can_complete:
|
if not self._can_complete:
|
||||||
show_toast(self, "当前账号没有完成接诊权限。", "danger")
|
show_toast(self, "当前账号没有完成接诊权限。", "danger")
|
||||||
return
|
return
|
||||||
@@ -8745,24 +9031,75 @@ class ReceptionPage(QWidget):
|
|||||||
if status not in RECEPTION_STATUSES:
|
if status not in RECEPTION_STATUSES:
|
||||||
show_toast(self, "仅待接诊或已过号记录可以完成接诊。", "danger")
|
show_toast(self, "仅待接诊或已过号记录可以完成接诊。", "danger")
|
||||||
return
|
return
|
||||||
answer = QMessageBox.question(
|
dialog = AppointmentCompleteDialog(can_note=self._can_note, parent=self)
|
||||||
self,
|
self._completion_dialog = dialog
|
||||||
"确认完成接诊",
|
dialog.submitted.connect(
|
||||||
"系统会再次核对服务端挂号状态;完成后不可撤销。确认继续吗?",
|
lambda note: self._submit_completion(dialog, generation, appointment_id, note)
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
|
||||||
QMessageBox.StandardButton.Cancel,
|
|
||||||
)
|
)
|
||||||
if answer != QMessageBox.StandardButton.Yes:
|
dialog.finished.connect(lambda _result: self._close_completion_dialog(dialog))
|
||||||
|
dialog.open()
|
||||||
|
|
||||||
|
def _close_completion_dialog(self, dialog: AppointmentCompleteDialog) -> None:
|
||||||
|
if self._completion_dialog is dialog:
|
||||||
|
self._completion_dialog = None
|
||||||
|
dialog.deleteLater()
|
||||||
|
|
||||||
|
def _submit_completion(
|
||||||
|
self,
|
||||||
|
dialog: AppointmentCompleteDialog,
|
||||||
|
generation: int,
|
||||||
|
appointment_id: int,
|
||||||
|
note: str,
|
||||||
|
) -> None:
|
||||||
|
if self._completion_pending or self._completion_dialog is not dialog:
|
||||||
return
|
return
|
||||||
|
if not self._context_current(generation, appointment_id):
|
||||||
|
dialog.show_error("当前患者已切换或详情已更新,请关闭窗口后重新操作。")
|
||||||
|
return
|
||||||
|
self._completion_pending = True
|
||||||
|
dialog.set_busy(True)
|
||||||
self.complete_button.setEnabled(False)
|
self.complete_button.setEnabled(False)
|
||||||
run_async(
|
run_async(
|
||||||
lambda: self._complete_after_revalidation(appointment_id),
|
lambda: self._complete_after_revalidation(appointment_id, note),
|
||||||
on_success=lambda _result: self._appointment_completed(generation, appointment_id),
|
on_success=lambda warning: self._completion_succeeded(
|
||||||
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4200),
|
dialog, generation, appointment_id, warning
|
||||||
on_finished=lambda: self._restore_action_state(generation, appointment_id),
|
),
|
||||||
|
on_error=lambda error: self._completion_failed(dialog, error),
|
||||||
|
on_finished=self._completion_finished,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _complete_after_revalidation(self, appointment_id: int) -> Any:
|
def _completion_succeeded(
|
||||||
|
self,
|
||||||
|
dialog: AppointmentCompleteDialog,
|
||||||
|
generation: int,
|
||||||
|
appointment_id: int,
|
||||||
|
warning: str,
|
||||||
|
) -> None:
|
||||||
|
if self._completion_dialog is dialog:
|
||||||
|
if warning:
|
||||||
|
dialog.show_completed_warning(warning)
|
||||||
|
else:
|
||||||
|
dialog.set_busy(False)
|
||||||
|
dialog.accept()
|
||||||
|
self._appointment_completed(generation, appointment_id, warning)
|
||||||
|
|
||||||
|
def _completion_failed(self, dialog: AppointmentCompleteDialog, error: Exception) -> None:
|
||||||
|
if self._completion_dialog is dialog:
|
||||||
|
dialog.show_error(friendly_error(error))
|
||||||
|
|
||||||
|
def _completion_finished(self) -> None:
|
||||||
|
self._completion_pending = False
|
||||||
|
if self._selected_appointment_id is not None:
|
||||||
|
self._restore_action_state(self._detail_generation, self._selected_appointment_id)
|
||||||
|
|
||||||
|
def _complete_after_revalidation(self, appointment_id: int, note: str = "") -> str:
|
||||||
|
if not self._can_complete:
|
||||||
|
raise ValueError("当前账号没有完成接诊权限。")
|
||||||
|
note = note.strip()
|
||||||
|
if note and not self._can_note:
|
||||||
|
raise ValueError("当前账号没有添加医生备注权限。")
|
||||||
|
if len(note) > COMPLETION_NOTE_LIMIT:
|
||||||
|
raise ValueError(f"备注不能超过 {COMPLETION_NOTE_LIMIT} 字。")
|
||||||
detail = invoke(
|
detail = invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"reception_detail",
|
"reception_detail",
|
||||||
@@ -8781,15 +9118,35 @@ class ReceptionPage(QWidget):
|
|||||||
raise ValueError("服务端挂号记录与当前患者不一致,已停止完成操作")
|
raise ValueError("服务端挂号记录与当前患者不一致,已停止完成操作")
|
||||||
if status not in RECEPTION_STATUSES:
|
if status not in RECEPTION_STATUSES:
|
||||||
raise ValueError("挂号状态已变化,请刷新队列后重试")
|
raise ValueError("挂号状态已变化,请刷新队列后重试")
|
||||||
return invoke(
|
invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"complete_appointment",
|
"complete_appointment",
|
||||||
appointment_id=appointment_id,
|
appointment_id=appointment_id,
|
||||||
id=appointment_id,
|
id=appointment_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _appointment_completed(self, generation: int, appointment_id: int) -> None:
|
if note:
|
||||||
show_toast(self, "接诊已完成。", "success")
|
# Use the freshly validated diagnosis, never the patient's ID or
|
||||||
|
# mutable selection: notes belong to the diagnosis timeline.
|
||||||
|
diagnosis = get_value(detail, "diagnosis", None) or {}
|
||||||
|
diagnosis_id = _as_int(first_value(diagnosis, "id", "diagnosis_id", default=None))
|
||||||
|
if diagnosis_id is None or diagnosis_id <= 0:
|
||||||
|
return "问诊已完成,但该预约没有关联诊单,备注未保存"
|
||||||
|
try:
|
||||||
|
invoke(
|
||||||
|
self.repository,
|
||||||
|
"add_doctor_note",
|
||||||
|
diagnosis_id=diagnosis_id,
|
||||||
|
content=note,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return "问诊已完成,但备注未保存"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _appointment_completed(
|
||||||
|
self, generation: int, appointment_id: int, warning: str = ""
|
||||||
|
) -> None:
|
||||||
|
show_toast(self, warning or "接诊已完成。", "warning" if warning else "success")
|
||||||
if self._context_current(generation, appointment_id):
|
if self._context_current(generation, appointment_id):
|
||||||
self._clear_selection()
|
self._clear_selection()
|
||||||
self.refresh(silent=True)
|
self.refresh(silent=True)
|
||||||
|
|||||||
@@ -41,8 +41,15 @@ from PySide6.QtWidgets import (
|
|||||||
|
|
||||||
from doctor_workstation.resources import app_icon_path
|
from doctor_workstation.resources import app_icon_path
|
||||||
|
|
||||||
|
from .chat_notifications import (
|
||||||
|
CONSULTATION_COMPLETE,
|
||||||
|
PATIENT_OPENED_CHAT,
|
||||||
|
ChatNotification,
|
||||||
|
ChatNotificationCenter,
|
||||||
|
)
|
||||||
from .dialogs.ai_consult import can_open_ai_consult
|
from .dialogs.ai_consult import can_open_ai_consult
|
||||||
from .dialogs.ai_consult_picker import select_and_present_ai_consult
|
from .dialogs.ai_consult_picker import select_and_present_ai_consult
|
||||||
|
from .dialogs.diagnosis import DiagnosisDialog
|
||||||
from .dialogs.local_audio_queue import LocalAudioQueueDialog
|
from .dialogs.local_audio_queue import LocalAudioQueueDialog
|
||||||
from .pages import (
|
from .pages import (
|
||||||
AppointmentsPage,
|
AppointmentsPage,
|
||||||
@@ -59,6 +66,7 @@ from .widgets import (
|
|||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
get_value,
|
get_value,
|
||||||
|
has_permission,
|
||||||
show_toast,
|
show_toast,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -895,6 +903,12 @@ class ShellWindow(QMainWindow):
|
|||||||
self._activation_generation = 0
|
self._activation_generation = 0
|
||||||
self._activation_refreshed = False
|
self._activation_refreshed = False
|
||||||
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
|
self._local_audio_settings_dialog: LocalAudioQueueDialog | None = None
|
||||||
|
self._global_diagnosis_dialog: DiagnosisDialog | None = None
|
||||||
|
# 与后台 chat-notify-toast 同一份数据源,登录后常驻轮询。
|
||||||
|
self.chat_notifications = ChatNotificationCenter(repository, self)
|
||||||
|
self.chat_notifications.notification_activated.connect(
|
||||||
|
self._open_chat_notification
|
||||||
|
)
|
||||||
|
|
||||||
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
|
self.setMinimumSize(_SHELL_MINIMUM_SIZE)
|
||||||
screen = self.screen() or QApplication.primaryScreen()
|
screen = self.screen() or QApplication.primaryScreen()
|
||||||
@@ -1294,9 +1308,7 @@ class ShellWindow(QMainWindow):
|
|||||||
"notification", size=38, parent=topbar
|
"notification", size=38, parent=topbar
|
||||||
)
|
)
|
||||||
self.notification_button.setToolTip("消息通知")
|
self.notification_button.setToolTip("消息通知")
|
||||||
self.notification_button.clicked.connect(
|
self.notification_button.clicked.connect(self._show_chat_notification_summary)
|
||||||
lambda: show_toast(self, "当前没有新的系统通知。", "info")
|
|
||||||
)
|
|
||||||
layout.addWidget(self.notification_button)
|
layout.addWidget(self.notification_button)
|
||||||
self.settings_button = _PaintedIconButton("settings", size=38, parent=topbar)
|
self.settings_button = _PaintedIconButton("settings", size=38, parent=topbar)
|
||||||
self.settings_button.setToolTip("设置中心")
|
self.settings_button.setToolTip("设置中心")
|
||||||
@@ -1845,8 +1857,58 @@ class ShellWindow(QMainWindow):
|
|||||||
if callable(refresh):
|
if callable(refresh):
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
def _ensure_global_diagnosis_dialog(self) -> DiagnosisDialog:
|
||||||
|
dialog = self._global_diagnosis_dialog
|
||||||
|
if dialog is None:
|
||||||
|
dialog = DiagnosisDialog(
|
||||||
|
self.repository,
|
||||||
|
self,
|
||||||
|
permissions=self.permissions,
|
||||||
|
)
|
||||||
|
dialog.saved.connect(self.refresh_current_page)
|
||||||
|
self._global_diagnosis_dialog = dialog
|
||||||
|
else:
|
||||||
|
dialog.refresh_permissions(self.permissions)
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
def open_diagnosis_by_id(
|
||||||
|
self,
|
||||||
|
diagnosis_id: Any,
|
||||||
|
*,
|
||||||
|
modeless: bool = False,
|
||||||
|
) -> DiagnosisDialog | None:
|
||||||
|
"""Open one authoritative diagnosis in the strongest permitted mode."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_id = int(diagnosis_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
normalized_id = 0
|
||||||
|
if normalized_id <= 0:
|
||||||
|
show_toast(self, "当前视频缺少有效诊单编号。", "warning", 3600)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if has_permission(self.permissions, "tcm.diagnosis/edit", default=False):
|
||||||
|
dialog = self._ensure_global_diagnosis_dialog()
|
||||||
|
dialog.open_for(normalized_id, editable=True, modeless=modeless)
|
||||||
|
elif has_permission(
|
||||||
|
self.permissions,
|
||||||
|
"tcm.diagnosis/readonlyDetail",
|
||||||
|
default=False,
|
||||||
|
):
|
||||||
|
dialog = self._ensure_global_diagnosis_dialog()
|
||||||
|
dialog.open_view_only(normalized_id, modeless=modeless)
|
||||||
|
else:
|
||||||
|
show_toast(self, "当前账号没有查看该诊单的权限。", "danger", 4200)
|
||||||
|
return None
|
||||||
|
|
||||||
|
dialog.raise_()
|
||||||
|
dialog.activateWindow()
|
||||||
|
return dialog
|
||||||
|
|
||||||
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
|
# 登录后就开始轮询:不管医生停在哪个页面,患者进入会话都要提示。
|
||||||
|
self.chat_notifications.start()
|
||||||
page = self.stack.currentWidget()
|
page = self.stack.currentWidget()
|
||||||
if page is None or page is not self._activation_page:
|
if page is None or page is not self._activation_page:
|
||||||
return
|
return
|
||||||
@@ -1856,6 +1918,33 @@ class ShellWindow(QMainWindow):
|
|||||||
lambda page=page, generation=generation: self._finish_activation(page, generation),
|
lambda page=page, generation=generation: self._finish_activation(page, generation),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def closeEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||||
|
self.chat_notifications.stop()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
def _open_chat_notification(self, notification: ChatNotification) -> None:
|
||||||
|
"""Take the doctor where the notification's work actually lives."""
|
||||||
|
|
||||||
|
if notification.kind == CONSULTATION_COMPLETE and notification.diagnosis_id > 0:
|
||||||
|
self.open_diagnosis_by_id(notification.diagnosis_id, modeless=True)
|
||||||
|
return
|
||||||
|
if notification.kind == PATIENT_OPENED_CHAT and self.navigate("reception"):
|
||||||
|
return
|
||||||
|
show_toast(self, notification.description, "info", 4200)
|
||||||
|
|
||||||
|
def _show_chat_notification_summary(self) -> None:
|
||||||
|
pending = self.chat_notifications.pending
|
||||||
|
if not pending:
|
||||||
|
show_toast(self, "当前没有新的系统通知。", "info")
|
||||||
|
return
|
||||||
|
latest = pending[0]
|
||||||
|
show_toast(
|
||||||
|
self,
|
||||||
|
f"待处理通知 {len(pending)} 条,最新:{latest.title} · {latest.description}",
|
||||||
|
"info",
|
||||||
|
4200,
|
||||||
|
)
|
||||||
|
|
||||||
def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt API
|
def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt API
|
||||||
if visible:
|
if visible:
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False)
|
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False)
|
||||||
|
|||||||
@@ -15,9 +15,19 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from string import Template
|
from string import Template
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, Qt
|
from PySide6.QtCore import QEvent, QObject, Qt
|
||||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette, QPixmap
|
from PySide6.QtGui import (
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QFontDatabase,
|
||||||
|
QPalette,
|
||||||
|
QPixmap,
|
||||||
|
QTextBlockFormat,
|
||||||
|
QTextCharFormat,
|
||||||
|
QTextCursor,
|
||||||
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -25,6 +35,7 @@ from PySide6.QtWidgets import (
|
|||||||
QFileDialog,
|
QFileDialog,
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QTextBrowser,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
|
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
|
||||||
@@ -997,6 +1008,111 @@ QToolTip {
|
|||||||
).substitute(_QSS_TOKENS)
|
).substitute(_QSS_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
#: Per-block reading rhythm, in device pixels. ``(top, bottom, line%)``.
|
||||||
|
#: QTextDocument's markdown importer builds block formats directly and ignores
|
||||||
|
#: ``setDefaultStyleSheet``, so spacing has to be applied to the parsed
|
||||||
|
#: document instead of being declared in CSS.
|
||||||
|
# 行内收紧、行间放开:一条要点自己的折行必须比两条要点之间更紧,否则整段会散成
|
||||||
|
# 一行一行的碎片,反而更难读。
|
||||||
|
_BLOCK_RHYTHM = {
|
||||||
|
"heading": (22, 10, 148),
|
||||||
|
"subheading": (18, 8, 148),
|
||||||
|
# 顶层列表项通常就是小节标题,之间留出整行空白让各小节一眼可数。
|
||||||
|
"section": (20, 8, 150),
|
||||||
|
"item": (13, 5, 158), # nested bullet under a section
|
||||||
|
"paragraph": (10, 10, 168),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _block_is_all_bold(block: Any) -> bool:
|
||||||
|
"""True when every visible run in the block is bold.
|
||||||
|
|
||||||
|
Models title their sections as ``1. **症状演变与疗效评估**`` rather than as
|
||||||
|
real markdown headings, so a fully bold top-level list item is the only
|
||||||
|
reliable signal that a block is a section title and not a content bullet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
iterator = block.begin()
|
||||||
|
seen = False
|
||||||
|
while not iterator.atEnd():
|
||||||
|
fragment = iterator.fragment()
|
||||||
|
iterator += 1
|
||||||
|
if not fragment.isValid() or not fragment.text().strip():
|
||||||
|
continue
|
||||||
|
seen = True
|
||||||
|
if fragment.charFormat().fontWeight() < QFont.Weight.DemiBold.value:
|
||||||
|
return False
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def apply_reading_rhythm(browser: QTextBrowser, *, role: str) -> None:
|
||||||
|
"""Give a parsed reply real hierarchy and breathing room.
|
||||||
|
|
||||||
|
Model answers are long "section heading + two levels of bullets" documents.
|
||||||
|
Qt renders every one of those blocks at the same size with 6px margins, so
|
||||||
|
the reply arrives as a wall of text. This walks the parsed document once
|
||||||
|
and applies a heading scale plus per-level spacing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
document = browser.document()
|
||||||
|
base_font = browser.font()
|
||||||
|
base_px = base_font.pixelSize()
|
||||||
|
if base_px <= 0:
|
||||||
|
base_px = max(12, round(base_font.pointSizeF() * 96 / 72))
|
||||||
|
|
||||||
|
cursor = QTextCursor(document)
|
||||||
|
cursor.beginEditBlock()
|
||||||
|
try:
|
||||||
|
block = document.begin()
|
||||||
|
while block.isValid():
|
||||||
|
heading = block.blockFormat().headingLevel()
|
||||||
|
text_list = block.textList()
|
||||||
|
indent = text_list.format().indent() if text_list is not None else 0
|
||||||
|
if heading:
|
||||||
|
kind = "heading" if heading <= 2 else "subheading"
|
||||||
|
size = base_px + (3 if heading <= 2 else 1)
|
||||||
|
elif indent >= 2:
|
||||||
|
kind, size = "item", None
|
||||||
|
elif indent == 1:
|
||||||
|
kind = "section"
|
||||||
|
# 只有整行加粗的顶层条目才是小节标题,普通要点保持正文字号。
|
||||||
|
size = base_px + 2 if _block_is_all_bold(block) else None
|
||||||
|
else:
|
||||||
|
kind, size = "paragraph", None
|
||||||
|
|
||||||
|
top, bottom, line = _BLOCK_RHYTHM[kind]
|
||||||
|
# 首块的上边距会在气泡顶部留出一段空白,视觉上像是排版错位。
|
||||||
|
if block.position() == 0:
|
||||||
|
top = 0
|
||||||
|
block_format = QTextBlockFormat(block.blockFormat())
|
||||||
|
block_format.setTopMargin(top)
|
||||||
|
block_format.setBottomMargin(bottom)
|
||||||
|
block_format.setLineHeight(
|
||||||
|
line, QTextBlockFormat.LineHeightTypes.ProportionalHeight.value
|
||||||
|
)
|
||||||
|
cursor.setPosition(block.position())
|
||||||
|
cursor.setBlockFormat(block_format)
|
||||||
|
|
||||||
|
if size is not None and block.length() > 1:
|
||||||
|
heading_font = QFont(base_font)
|
||||||
|
heading_font.setPixelSize(size)
|
||||||
|
heading_font.setBold(True)
|
||||||
|
char_format = QTextCharFormat()
|
||||||
|
char_format.setFont(heading_font)
|
||||||
|
if role != "doctor":
|
||||||
|
char_format.setForeground(QColor("#111B3F"))
|
||||||
|
cursor.setPosition(block.position())
|
||||||
|
cursor.setPosition(
|
||||||
|
block.position() + block.length() - 1,
|
||||||
|
QTextCursor.MoveMode.KeepAnchor,
|
||||||
|
)
|
||||||
|
cursor.mergeCharFormat(char_format)
|
||||||
|
block = block.next()
|
||||||
|
finally:
|
||||||
|
cursor.endEditBlock()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_group(
|
def _apply_group(
|
||||||
palette: QPalette,
|
palette: QPalette,
|
||||||
group: QPalette.ColorGroup,
|
group: QPalette.ColorGroup,
|
||||||
@@ -1085,17 +1201,48 @@ def _polish_dialog_buttons(button_box: QDialogButtonBox) -> None:
|
|||||||
_refresh_widget_style(button)
|
_refresh_widget_style(button)
|
||||||
|
|
||||||
|
|
||||||
|
def allow_dialog_resize(dialog: QDialog) -> None:
|
||||||
|
"""Give a business subwindow the usual minimise/maximise affordances.
|
||||||
|
|
||||||
|
``QDialog`` ships with a close button only, so every AI panel, report and
|
||||||
|
editor was stuck at whatever size it was constructed with — unusable for the
|
||||||
|
long, dense clinical replies these windows carry. Transient prompts
|
||||||
|
(message and input boxes) are deliberately left alone, and a dialog that has
|
||||||
|
pinned itself to a fixed size keeps that decision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(dialog, (QMessageBox, QInputDialog, QFileDialog)):
|
||||||
|
return
|
||||||
|
if dialog.minimumSize() == dialog.maximumSize():
|
||||||
|
return
|
||||||
|
# Qt hides a window whose flags change while it is visible, so a dialog that
|
||||||
|
# is already on screen keeps the flags it was shown with.
|
||||||
|
if dialog.isVisible():
|
||||||
|
dialog.setSizeGripEnabled(True)
|
||||||
|
return
|
||||||
|
flags = dialog.windowFlags()
|
||||||
|
if flags & Qt.WindowType.WindowMaximizeButtonHint:
|
||||||
|
return
|
||||||
|
dialog.setWindowFlags(
|
||||||
|
flags
|
||||||
|
| Qt.WindowType.WindowMinimizeButtonHint
|
||||||
|
| Qt.WindowType.WindowMaximizeButtonHint
|
||||||
|
)
|
||||||
|
dialog.setSizeGripEnabled(True)
|
||||||
|
|
||||||
|
|
||||||
def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> None:
|
def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> None:
|
||||||
"""Opt a business subwindow into the shared visual contract.
|
"""Opt a business subwindow into the shared visual contract.
|
||||||
|
|
||||||
The helper intentionally does not change modality, ownership or result
|
The helper intentionally does not change modality, ownership or result
|
||||||
handling. It only supplies stable styling metadata and semantic button
|
handling. It only supplies stable styling metadata, semantic button roles
|
||||||
roles, so existing workflows keep their original behavior.
|
and window affordances, so existing workflows keep their original behavior.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if object_name and not dialog.objectName():
|
if object_name and not dialog.objectName():
|
||||||
dialog.setObjectName(object_name)
|
dialog.setObjectName(object_name)
|
||||||
dialog.setProperty("businessDialog", True)
|
dialog.setProperty("businessDialog", True)
|
||||||
|
allow_dialog_resize(dialog)
|
||||||
for button_box in dialog.findChildren(QDialogButtonBox):
|
for button_box in dialog.findChildren(QDialogButtonBox):
|
||||||
_polish_dialog_buttons(button_box)
|
_polish_dialog_buttons(button_box)
|
||||||
_refresh_widget_style(dialog)
|
_refresh_widget_style(dialog)
|
||||||
@@ -1119,6 +1266,12 @@ class _BusinessDialogStyleFilter(QObject):
|
|||||||
QEvent.Type.Show,
|
QEvent.Type.Show,
|
||||||
}:
|
}:
|
||||||
_polish_dialog_buttons(watched)
|
_polish_dialog_buttons(watched)
|
||||||
|
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
|
||||||
|
# Window flags can only be changed before the dialog is on screen,
|
||||||
|
# so dynamically-created dialogs get their affordances at polish
|
||||||
|
# time rather than when they are already visible.
|
||||||
|
if not self._is_native_or_overlay(watched):
|
||||||
|
allow_dialog_resize(watched)
|
||||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
|
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
|
||||||
for button_box in watched.findChildren(QDialogButtonBox):
|
for button_box in watched.findChildren(QDialogButtonBox):
|
||||||
_polish_dialog_buttons(button_box)
|
_polish_dialog_buttons(button_box)
|
||||||
@@ -1215,6 +1368,8 @@ def apply_theme(app: QApplication) -> None:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"COLORS",
|
"COLORS",
|
||||||
|
"allow_dialog_resize",
|
||||||
|
"apply_reading_rhythm",
|
||||||
"GLOBAL_QSS",
|
"GLOBAL_QSS",
|
||||||
"METRICS",
|
"METRICS",
|
||||||
"TYPE",
|
"TYPE",
|
||||||
|
|||||||
@@ -378,6 +378,8 @@ class VideoCallLauncher:
|
|||||||
patient_id: Any = None,
|
patient_id: Any = None,
|
||||||
open_im: bool = False,
|
open_im: bool = False,
|
||||||
patient_name: str = "患者",
|
patient_name: str = "患者",
|
||||||
|
patient_case: Mapping[str, Any] | None = None,
|
||||||
|
on_open_diagnosis: Callable[[], None] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
request = self.prepare(
|
request = self.prepare(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -395,6 +397,8 @@ class VideoCallLauncher:
|
|||||||
browser_opener=self.browser_opener,
|
browser_opener=self.browser_opener,
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
|
patient_case=patient_case,
|
||||||
|
on_open_diagnosis=on_open_diagnosis,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -411,6 +415,8 @@ def launch_video_call(
|
|||||||
browser_opener: Callable[[str], bool] | None = None,
|
browser_opener: Callable[[str], bool] | None = None,
|
||||||
open_im: bool = False,
|
open_im: bool = False,
|
||||||
patient_name: str = "患者",
|
patient_name: str = "患者",
|
||||||
|
patient_case: Mapping[str, Any] | None = None,
|
||||||
|
on_open_diagnosis: Callable[[], None] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Normalize a ticket and open a call with the requested backend."""
|
"""Normalize a ticket and open a call with the requested backend."""
|
||||||
|
|
||||||
@@ -427,4 +433,6 @@ def launch_video_call(
|
|||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
|
patient_case=patient_case,
|
||||||
|
on_open_diagnosis=on_open_diagnosis,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
lifecycle_factory: Callable[[], OrderedCallLifecycle],
|
lifecycle_factory: Callable[[], OrderedCallLifecycle],
|
||||||
open_im: bool = False,
|
open_im: bool = False,
|
||||||
patient_name: str = "患者",
|
patient_name: str = "患者",
|
||||||
|
patient_case: Mapping[str, Any] | None = None,
|
||||||
|
on_open_diagnosis: Callable[[], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.request = request
|
self.request = request
|
||||||
@@ -306,6 +308,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.open_im = bool(open_im)
|
self.open_im = bool(open_im)
|
||||||
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
||||||
|
self.patient_case = dict(patient_case or {})
|
||||||
|
self._on_open_diagnosis = on_open_diagnosis
|
||||||
try:
|
try:
|
||||||
self._policy = TrustedDocumentPolicy.from_url(
|
self._policy = TrustedDocumentPolicy.from_url(
|
||||||
location.url,
|
location.url,
|
||||||
@@ -451,6 +455,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
config = {
|
config = {
|
||||||
**self.request.to_web_config(),
|
**self.request.to_web_config(),
|
||||||
"patientName": self.patient_name,
|
"patientName": self.patient_name,
|
||||||
|
"patientCase": self.patient_case,
|
||||||
"mode": "chat" if self.open_im else "video",
|
"mode": "chat" if self.open_im else "video",
|
||||||
}
|
}
|
||||||
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
|
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
|
||||||
@@ -499,6 +504,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
event = str(message.get("event", ""))
|
event = str(message.get("event", ""))
|
||||||
|
if event == "open-diagnosis-request":
|
||||||
|
if self._on_open_diagnosis is not None:
|
||||||
|
QTimer.singleShot(0, self._open_diagnosis_safely)
|
||||||
|
return
|
||||||
if event == "call-start-request":
|
if event == "call-start-request":
|
||||||
self._start_call_cycle()
|
self._start_call_cycle()
|
||||||
return
|
return
|
||||||
@@ -580,6 +589,17 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
if not self.open_im or self._shutdown_requested:
|
if not self.open_im or self._shutdown_requested:
|
||||||
self._close_from_companion("companion-error")
|
self._close_from_companion("companion-error")
|
||||||
|
|
||||||
|
def _open_diagnosis_safely(self) -> None:
|
||||||
|
if self._closing or self._on_open_diagnosis is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._on_open_diagnosis()
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(
|
||||||
|
"diagnosis drawer could not be opened from video companion",
|
||||||
|
extra={"video_call": self.request.safe_log_context()},
|
||||||
|
)
|
||||||
|
|
||||||
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
|
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
|
||||||
try:
|
try:
|
||||||
succeeded = bool(future.result())
|
succeeded = bool(future.result())
|
||||||
@@ -1214,6 +1234,8 @@ class VideoCallWindow:
|
|||||||
browser_opener: Callable[[str], bool] | None = None,
|
browser_opener: Callable[[str], bool] | None = None,
|
||||||
open_im: bool = False,
|
open_im: bool = False,
|
||||||
patient_name: str = "患者",
|
patient_name: str = "患者",
|
||||||
|
patient_case: Mapping[str, Any] | None = None,
|
||||||
|
on_open_diagnosis: Callable[[], None] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
del browser_opener # Reserved for a future authenticated handoff implementation.
|
del browser_opener # Reserved for a future authenticated handoff implementation.
|
||||||
try:
|
try:
|
||||||
@@ -1231,6 +1253,8 @@ class VideoCallWindow:
|
|||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.open_im = bool(open_im)
|
self.open_im = bool(open_im)
|
||||||
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
||||||
|
self.patient_case = dict(patient_case or {})
|
||||||
|
self.on_open_diagnosis = on_open_diagnosis
|
||||||
self.logger = logger or _LOGGER
|
self.logger = logger or _LOGGER
|
||||||
self.location = resolve_companion_location(
|
self.location = resolve_companion_location(
|
||||||
local_dist=local_dist,
|
local_dist=local_dist,
|
||||||
@@ -1259,6 +1283,8 @@ class VideoCallWindow:
|
|||||||
lifecycle_factory=self._new_lifecycle,
|
lifecycle_factory=self._new_lifecycle,
|
||||||
open_im=self.open_im,
|
open_im=self.open_im,
|
||||||
patient_name=self.patient_name,
|
patient_name=self.patient_name,
|
||||||
|
patient_case=self.patient_case,
|
||||||
|
on_open_diagnosis=self.on_open_diagnosis,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.lifecycle.end("window-open-failed")
|
self.lifecycle.end("window-open-failed")
|
||||||
@@ -1302,6 +1328,8 @@ def open_video_call(
|
|||||||
browser_opener: Callable[[str], bool] | None = None,
|
browser_opener: Callable[[str], bool] | None = None,
|
||||||
open_im: bool = False,
|
open_im: bool = False,
|
||||||
patient_name: str = "患者",
|
patient_name: str = "患者",
|
||||||
|
patient_case: Mapping[str, Any] | None = None,
|
||||||
|
on_open_diagnosis: Callable[[], None] | None = None,
|
||||||
) -> VideoCallWindow:
|
) -> VideoCallWindow:
|
||||||
"""Create and immediately open a trusted embedded video window."""
|
"""Create and immediately open a trusted embedded video window."""
|
||||||
|
|
||||||
@@ -1316,6 +1344,8 @@ def open_video_call(
|
|||||||
browser_opener=browser_opener,
|
browser_opener=browser_opener,
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
|
patient_case=patient_case,
|
||||||
|
on_open_diagnosis=on_open_diagnosis,
|
||||||
).open()
|
).open()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ from PySide6.QtCore import Qt
|
|||||||
from PySide6.QtTest import QTest
|
from PySide6.QtTest import QTest
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QTextBrowser,
|
QTextBrowser,
|
||||||
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1291,3 +1293,367 @@ def test_ask_sends_only_question_and_relies_on_server_full_context(
|
|||||||
assert any("请结合资料分析当前证候" in text for text in all_bubble_texts)
|
assert any("请结合资料分析当前证候" in text for text in all_bubble_texts)
|
||||||
assert any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
|
assert any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _bubble_texts(dialog: AiConsultDialog) -> list[str]:
|
||||||
|
return [
|
||||||
|
widget.toPlainText()
|
||||||
|
for widget in dialog.findChildren(QTextBrowser)
|
||||||
|
if widget.objectName() == "AiConsultBubbleText"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _silent_dialog(monkeypatch: pytest.MonkeyPatch) -> AiConsultDialog:
|
||||||
|
"""A dialog whose stream workers are never actually started."""
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_consult_module,
|
||||||
|
"QThreadPool",
|
||||||
|
SimpleNamespace(
|
||||||
|
globalInstance=lambda: SimpleNamespace(start=lambda worker: None)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dialog = AiConsultDialog(
|
||||||
|
DemoDoctorRepository(),
|
||||||
|
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||||||
|
)
|
||||||
|
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_context_notice_is_shown_once_per_conversation(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
# 每轮问答都重复同一句全量上下文说明会把真正的回答挤出可视区。
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
notice = "服务端将按当前诊单实时附带患者全部纵向资料"
|
||||||
|
counts = []
|
||||||
|
for question in ("第一个问题", "第二个问题", "第三个问题"):
|
||||||
|
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]
|
||||||
|
|
||||||
|
# 换患者视为新会话,需要重新提示一次。
|
||||||
|
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
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
# 服务端既不发 done 也不报错时,占位气泡会永远停在空白状态。
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
dialog._ask("请评估当前用药是否合理")
|
||||||
|
assert dialog._stream_bubble is not None
|
||||||
|
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||||||
|
assert any(
|
||||||
|
ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in _bubble_texts(dialog)
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog._stream_finished(
|
||||||
|
dialog._generation, dialog._stream_generation, dialog._stream_worker
|
||||||
|
)
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog._stream_text == ai_consult_module.AI_STREAM_SILENT_TEXT
|
||||||
|
assert any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in _bubble_texts(dialog))
|
||||||
|
assert dialog.send_button.isEnabled()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_answered_stream_replaces_the_pending_placeholder(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
dialog._ask("请总结当前病情")
|
||||||
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
|
||||||
|
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
|
||||||
|
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert dialog._stream_text == "证候:脾肾两虚"
|
||||||
|
texts = _bubble_texts(dialog)
|
||||||
|
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
|
||||||
|
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_consult_window_can_be_maximized(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
# 这个窗口信息密度很高,必须允许医生放大到整屏。
|
||||||
|
dialog = _silent_dialog(monkeypatch)
|
||||||
|
flags = dialog.windowFlags()
|
||||||
|
assert flags & Qt.WindowType.WindowMaximizeButtonHint
|
||||||
|
assert flags & Qt.WindowType.WindowMinimizeButtonHint
|
||||||
|
assert dialog.isSizeGripEnabled()
|
||||||
|
dialog.showMaximized()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.isMaximized()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_reply_gets_section_hierarchy_and_breathing_room(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""QTextDocument's markdown importer ignores setDefaultStyleSheet.
|
||||||
|
|
||||||
|
Spacing therefore has to be applied to the parsed document; without it every
|
||||||
|
block renders at one size with 6px margins and the reply reads as a wall.
|
||||||
|
"""
|
||||||
|
|
||||||
|
browser = ai_consult_module._RichMessage("ai")
|
||||||
|
browser.resize(660, 400)
|
||||||
|
browser.set_payload(
|
||||||
|
"概述段落。\n\n"
|
||||||
|
"1. **症状演变与疗效评估**\n"
|
||||||
|
" - **麻木症状:** 服药十四天后是否缓解?\n"
|
||||||
|
" - **皮肤瘙痒:** 目前是否仍有发作?\n"
|
||||||
|
"2. **血糖控制与监测细节**\n"
|
||||||
|
" - **监测习惯:** 是否规律监测餐后血糖?\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
document = browser.document()
|
||||||
|
base_px = browser.font().pixelSize()
|
||||||
|
seen: dict[str, list] = {"section": [], "item": [], "paragraph": []}
|
||||||
|
block = document.begin()
|
||||||
|
while block.isValid():
|
||||||
|
text_list = block.textList()
|
||||||
|
indent = text_list.format().indent() if text_list is not None else 0
|
||||||
|
kind = "item" if indent >= 2 else "section" if indent == 1 else "paragraph"
|
||||||
|
seen[kind].append(block)
|
||||||
|
block = block.next()
|
||||||
|
|
||||||
|
assert seen["section"] and seen["item"], "both list levels must be present"
|
||||||
|
|
||||||
|
# 小节标题比正文更大更重,否则四个小节无法一眼分辨。
|
||||||
|
section = seen["section"][0]
|
||||||
|
section_size = section.begin().fragment().charFormat().font().pixelSize()
|
||||||
|
assert section_size > base_px
|
||||||
|
|
||||||
|
# 小节之间的留白必须大于同一小节内要点之间的留白。
|
||||||
|
section_top = seen["section"][0].blockFormat().topMargin()
|
||||||
|
item_top = seen["item"][0].blockFormat().topMargin()
|
||||||
|
assert section_top > item_top > 0
|
||||||
|
|
||||||
|
# 一条要点的折行必须比两条要点之间更紧,否则整段会散成碎片。
|
||||||
|
item_line = seen["item"][0].blockFormat().lineHeight()
|
||||||
|
assert 0 < item_line < 170
|
||||||
|
|
||||||
|
# 首块不带上边距,避免气泡顶部出现一段空白。
|
||||||
|
assert document.begin().blockFormat().topMargin() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_reply_is_not_over_spaced(application: QApplication) -> None:
|
||||||
|
browser = ai_consult_module._RichMessage("ai")
|
||||||
|
browser.resize(660, 200)
|
||||||
|
browser.set_payload("血糖控制尚可,暂无需调整降糖方案。")
|
||||||
|
block = browser.document().begin()
|
||||||
|
assert block.blockFormat().topMargin() == 0
|
||||||
|
assert block.next().isValid() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sectioned_reply_becomes_a_structured_report(application: QApplication) -> None:
|
||||||
|
"""The clinical panel only knows four fixed section names.
|
||||||
|
|
||||||
|
Real answers are sectioned as 症状演变 / 血糖控制 / 用药依从性 …, which matched
|
||||||
|
none of them and therefore fell back to a plain wall of text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
reply = (
|
||||||
|
"以下是针对该患者当前情况,建议向患者确认的关键问诊问题,用于补充现有病历中的信息缺口:\n\n"
|
||||||
|
"1. **症状演变与疗效评估**\n"
|
||||||
|
" - **麻木症状:** 服药十四天后四肢麻木是否有所缓解?\n"
|
||||||
|
" - **皮肤瘙痒:** 目前是否仍有发作?是否与血糖波动有关?\n"
|
||||||
|
"2. **血糖控制与监测细节**\n"
|
||||||
|
" - **空腹血糖波动:** 近期是否有反复的低血糖发作?\n"
|
||||||
|
"3. **用药依从性与生活方式**\n"
|
||||||
|
" - **西药服用情况:** 近期是否有漏服或自行调整剂量?\n\n"
|
||||||
|
"**提示:** 以上问题基于现有脱敏病例资料梳理,需由执业医师复核后确定。\n"
|
||||||
|
)
|
||||||
|
parsed = ai_consult_module.parse_structured_report(reply)
|
||||||
|
assert parsed is not None
|
||||||
|
intro, sections, disclaimer = parsed
|
||||||
|
assert [section.title for section in sections] == [
|
||||||
|
"症状演变与疗效评估",
|
||||||
|
"血糖控制与监测细节",
|
||||||
|
"用药依从性与生活方式",
|
||||||
|
]
|
||||||
|
assert [len(section.items) for section in sections] == [2, 1, 1]
|
||||||
|
assert "信息缺口" in intro
|
||||||
|
assert "执业医师" in disclaimer
|
||||||
|
|
||||||
|
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 11:01")
|
||||||
|
assert bubble.finalize_clinical_analysis() is True
|
||||||
|
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
|
||||||
|
assert panel is not None
|
||||||
|
titles = [
|
||||||
|
label.text()
|
||||||
|
for label in panel.findChildren(QLabel)
|
||||||
|
if label.objectName() == "AiConsultReportSectionTitle"
|
||||||
|
]
|
||||||
|
assert titles == ["症状演变与疗效评估", "血糖控制与监测细节", "用药依从性与生活方式"]
|
||||||
|
# 要点不再每句一个方框,而是「小标题 + 正文」两级文字。
|
||||||
|
assert panel.findChildren(ai_consult_module.QFrame, "AiConsultGapRow") == []
|
||||||
|
leads = [
|
||||||
|
label.text()
|
||||||
|
for label in panel.findChildren(QLabel)
|
||||||
|
if label.objectName() == "AiConsultReportLead"
|
||||||
|
]
|
||||||
|
assert leads == ["麻木症状", "皮肤瘙痒", "空腹血糖波动", "西药服用情况"]
|
||||||
|
bubble.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reply",
|
||||||
|
[
|
||||||
|
"血糖控制尚可,暂无需调整降糖方案。",
|
||||||
|
"1. **只有一个小节**\n - 一条要点\n",
|
||||||
|
'{"summary": "结构化 JSON 走既有解析路径"}',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unsectioned_replies_stay_plain_text(reply: str, application: QApplication) -> None:
|
||||||
|
assert ai_consult_module.parse_structured_report(reply) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_points_split_into_a_scannable_label_and_body() -> None:
|
||||||
|
split = ai_consult_module.split_report_lead
|
||||||
|
|
||||||
|
assert split("糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖为 8.5 mmol/L。") == (
|
||||||
|
"糖尿病管理缺失",
|
||||||
|
"患者确诊糖尿病3年,空腹血糖为 8.5 mmol/L。",
|
||||||
|
)
|
||||||
|
assert split("结论:由于患者当前未使用任何药物,无需复核。")[0] == "结论"
|
||||||
|
# 冒号前是一整句话,或正文太短,都按普通要点整段显示。
|
||||||
|
assert split("尽管无需复核用药,但基于患者病史,以下临床风险点需重点关注。以下为要点:细节")[0] == ""
|
||||||
|
assert split("空腹血糖: 8.5") == ("", "空腹血糖: 8.5")
|
||||||
|
assert split("没有冒号的一条要点") == ("", "没有冒号的一条要点")
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_section_titles_drop_the_number_the_chip_already_shows() -> None:
|
||||||
|
strip = ai_consult_module._strip_leading_ordinal
|
||||||
|
|
||||||
|
assert strip("1. 当前用药状态评估") == "当前用药状态评估"
|
||||||
|
assert strip("二、临床风险与干预提示") == "临床风险与干预提示"
|
||||||
|
assert strip("建议下一步行动") == "建议下一步行动"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_body_escapes_markup_and_carries_reading_rhythm() -> None:
|
||||||
|
html = ai_consult_module._reading_html('血糖 <7.0 mmol/L 且 "达标" & 稳定')
|
||||||
|
|
||||||
|
assert "line-height" in html
|
||||||
|
assert "<7.0" in html
|
||||||
|
assert "&" in html
|
||||||
|
assert "<7.0" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_report_uses_a_readable_column_width(application: QApplication) -> None:
|
||||||
|
reply = (
|
||||||
|
"针对该患者的用药复核评估如下:\n\n"
|
||||||
|
"### 1. 当前用药状态评估\n"
|
||||||
|
"- 无当前处方药物: 病例数据中明确记录患者目前没有服药,系统内也没有有效处方记录。\n"
|
||||||
|
"- 结论: 患者当前未使用任何药物,不存在药物相互作用或配伍禁忌风险。\n"
|
||||||
|
"### 2. 临床风险与干预提示\n"
|
||||||
|
"- 糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖高于一般控制目标且未接受药物治疗。\n"
|
||||||
|
"重要提示: 本分析不能替代执业医师的面诊与完整病历评估。\n"
|
||||||
|
)
|
||||||
|
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 16:32")
|
||||||
|
|
||||||
|
assert bubble.finalize_clinical_analysis() is True
|
||||||
|
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
|
||||||
|
assert panel is not None
|
||||||
|
# 报告收窄到易读行宽,而不是继续用多栏面板的 1080。
|
||||||
|
assert panel.PREFERRED_MAX_WIDTH == 880
|
||||||
|
assert bubble._bubble_frame.maximumWidth() == 880
|
||||||
|
sections = panel.findChildren(ai_consult_module.QFrame, "AiConsultReportSection")
|
||||||
|
assert len(sections) == 2
|
||||||
|
assert panel.findChildren(ai_consult_module.QFrame, "AiConsultGapRow") == []
|
||||||
|
bubble.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def _fitted_bubble(reply: str, width: int = 900) -> Any:
|
||||||
|
host = QDialog()
|
||||||
|
host.setObjectName("AiConsultDialog")
|
||||||
|
host.setStyleSheet(ai_consult_module.AI_CONSULT_QSS)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
layout.setContentsMargins(12, 12, 12, 12)
|
||||||
|
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 16:32")
|
||||||
|
assert bubble.finalize_clinical_analysis() is True
|
||||||
|
layout.addWidget(bubble)
|
||||||
|
layout.addStretch(1)
|
||||||
|
host.setFixedWidth(width)
|
||||||
|
host.show()
|
||||||
|
for _ in range(12):
|
||||||
|
QApplication.processEvents()
|
||||||
|
host.adjustSize()
|
||||||
|
for _ in range(6):
|
||||||
|
QApplication.processEvents()
|
||||||
|
return host, bubble
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reply",
|
||||||
|
[
|
||||||
|
LONG_CLINICAL_REPLY,
|
||||||
|
(
|
||||||
|
"针对该患者的用药复核评估如下:\n\n"
|
||||||
|
"### 1. 当前用药状态评估\n"
|
||||||
|
"- 无当前处方药物: 病例数据中明确记录患者目前没有服药,系统内也没有有效处方记录,"
|
||||||
|
"因此不存在药物相互作用或配伍禁忌风险,无需再做安全性复核。\n"
|
||||||
|
"### 2. 临床风险与干预提示\n"
|
||||||
|
"- 糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖高于一般控制目标且未接受任何药物治疗,"
|
||||||
|
"存在长期高血糖导致微血管及大血管并发症的风险,需要尽快评估。\n"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["clinical", "structured"],
|
||||||
|
)
|
||||||
|
def test_report_bubbles_report_the_height_they_actually_paint(
|
||||||
|
reply: str,
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""否则聊天区会按高估的高度撑出滚动空白,打开就是一片空白要往上滑。"""
|
||||||
|
|
||||||
|
host, bubble = _fitted_bubble(reply)
|
||||||
|
|
||||||
|
assert bubble.height() > 0
|
||||||
|
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_block_uses_the_red_alert_palette() -> None:
|
||||||
|
qss = ai_consult_module.AI_CONSULT_QSS
|
||||||
|
|
||||||
|
risk_card = qss.split("QFrame#AiConsultRiskCard {", 1)[1].split("}", 1)[0]
|
||||||
|
assert "#FEF3F2" in risk_card
|
||||||
|
assert "#F1B35C" not in risk_card # 旧的橙色描边
|
||||||
|
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
|
||||||
|
assert "#C0392B" in marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
|
||||||
|
qss = ai_consult_module.AI_CONSULT_QSS
|
||||||
|
|
||||||
|
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1)
|
||||||
|
assert len(body) == 2 or "font-size: 13px" in qss
|
||||||
|
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
|
||||||
|
assert "font-size: 13px" in block
|
||||||
|
assert "font-size: 11px" not in block
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ def application() -> QApplication:
|
|||||||
return QApplication.instance() or QApplication([])
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def offline_thumbnails(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""离线传输:缩略图停在 loading 状态,工作站用例永不真正联网。"""
|
||||||
|
|
||||||
|
def hold(self: Any, source: str) -> None:
|
||||||
|
self._source = str(source).strip()
|
||||||
|
self._invalidate_request()
|
||||||
|
self.setToolTip(self._source)
|
||||||
|
self._show_loading()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_consult_module._RemoteImageButton, "load_url", hold)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
def run_immediately(
|
def run_immediately(
|
||||||
@@ -468,17 +481,33 @@ def test_patient_report_response_owner_must_match_exactly(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_exam_tab_filters_foreign_attachments_and_blocks_file_urls(
|
def test_exam_tab_previews_images_in_app_and_blocks_file_urls(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
opened: list[str] = []
|
opened: list[str] = []
|
||||||
|
previews: list[tuple[tuple[str, ...], int]] = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
ai_consult_module,
|
ai_consult_module,
|
||||||
"open_safe_http_url",
|
"open_safe_http_url",
|
||||||
lambda target: opened.append(target) or True,
|
lambda target: opened.append(target) or True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class RecordingPreviewDialog(QWidget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sources: Any,
|
||||||
|
*,
|
||||||
|
index: int = 0,
|
||||||
|
names: Any = None,
|
||||||
|
title: str = "",
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
previews.append((tuple(sources), int(index)))
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_consult_module, "ImagePreviewDialog", RecordingPreviewDialog)
|
||||||
dialog = _open_dialog(application, WorkspaceRepository())
|
dialog = _open_dialog(application, WorkspaceRepository())
|
||||||
pane = dialog.records["检查检验"]
|
pane = dialog.records["检查检验"]
|
||||||
dialog.tabs.setCurrentIndex(2)
|
dialog.tabs.setCurrentIndex(2)
|
||||||
@@ -498,20 +527,187 @@ def test_exam_tab_filters_foreign_attachments_and_blocks_file_urls(
|
|||||||
assert len(thumbnails) == 1
|
assert len(thumbnails) == 1
|
||||||
assert thumbnails[0].isEnabled()
|
assert thumbnails[0].isEnabled()
|
||||||
assert thumbnails[0].accessibleName() == "舌苔图片点击查看"
|
assert thumbnails[0].accessibleName() == "舌苔图片点击查看"
|
||||||
assert thumbnails[0].property("loadState") == "blocked"
|
assert thumbnails[0].property("loadState") == "loading"
|
||||||
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
|
||||||
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
|
||||||
|
image_button = next(button for button in buttons if "甲舌苔照片.jpg" in button.text())
|
||||||
|
assert image_button.text().endswith("· 预览")
|
||||||
|
report_button = next(button for button in buttons if "甲血糖报告.pdf" in button.text())
|
||||||
|
assert report_button.text().endswith("· 打开")
|
||||||
unsafe = next(button for button in buttons if "本地危险附件" in button.text())
|
unsafe = next(button for button in buttons if "本地危险附件" in button.text())
|
||||||
assert not unsafe.isEnabled()
|
assert not unsafe.isEnabled()
|
||||||
|
|
||||||
for button in buttons:
|
for button in buttons:
|
||||||
button.click()
|
button.click()
|
||||||
assert len(opened) == 2
|
thumbnails[0].click()
|
||||||
assert all(target.startswith(("http://", "https://")) for target in opened)
|
# 图片留在工作站内预览,只有非图片附件才交给系统打开。
|
||||||
assert all(not target.startswith("file:") for target in opened)
|
assert opened == ["https://media.example.invalid/甲/report.pdf"]
|
||||||
|
assert previews == [(("https://media.example.invalid/甲/tongue.jpg",), 0)] * 2
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_tongue_thumbnail_auto_get_requires_configured_https_origin(
|
class ProductionShapeRepository(WorkspaceRepository):
|
||||||
|
"""按线上 readonlyDetail 的真实返回构造:既有 code,也有后端补的 *_text。"""
|
||||||
|
|
||||||
|
def get_diagnosis_detail(
|
||||||
|
self,
|
||||||
|
diagnosis_id: int,
|
||||||
|
*,
|
||||||
|
readonly: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
detail = dict(super().get_diagnosis_detail(diagnosis_id, readonly=readonly))
|
||||||
|
diagnosis = dict(detail["diagnosis"])
|
||||||
|
diagnosis.update(
|
||||||
|
{
|
||||||
|
"gender": 1,
|
||||||
|
"gender_text": "男",
|
||||||
|
"diagnosis_type": "follow_up",
|
||||||
|
"diagnosis_type_text": "复诊",
|
||||||
|
"eye_condition": "blurred,dry",
|
||||||
|
"eye_condition_text": "模糊、干涩",
|
||||||
|
"skin_condition": "dry,itching",
|
||||||
|
"skin_condition_text": "干燥、瘙痒",
|
||||||
|
"urine_condition": "yellow_urine",
|
||||||
|
"urine_condition_text": "尿黄",
|
||||||
|
"fatty_liver_degree": "mild",
|
||||||
|
"fatty_liver_degree_text": "轻度",
|
||||||
|
"past_history": "hypertension",
|
||||||
|
"past_history_text": "高血压",
|
||||||
|
"trauma_history": 0,
|
||||||
|
"trauma_history_text": "无",
|
||||||
|
# 诊单表里的技术列:医生页面不应出现这些英文列名。
|
||||||
|
"status": 1,
|
||||||
|
"show_card": 1,
|
||||||
|
"revisit_slot_start_offset": 0,
|
||||||
|
"delete_time": None,
|
||||||
|
"assistant_id": 131,
|
||||||
|
"assign_read_at": 1787882294,
|
||||||
|
"shipped_non_er_assistant_cleared_at": 0,
|
||||||
|
"external_userid": "",
|
||||||
|
"is_view": 0,
|
||||||
|
"create_time": 1787882294,
|
||||||
|
"update_time": 1787882303,
|
||||||
|
"tongue_images": [
|
||||||
|
"https://media.example.invalid/11702/a.jpg",
|
||||||
|
"https://media.example.invalid/11702/b.jpg",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
detail["diagnosis"] = diagnosis
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_tab_hides_raw_columns_and_never_shows_text_mirror_fields(
|
||||||
|
application: QApplication,
|
||||||
|
immediate_async: None,
|
||||||
|
) -> None:
|
||||||
|
dialog = _open_dialog(application, ProductionShapeRepository())
|
||||||
|
dialog.tabs.setCurrentIndex(1)
|
||||||
|
application.processEvents()
|
||||||
|
text = _pane_text(dialog.records["病历资料"])
|
||||||
|
|
||||||
|
# 后端补的 *_text 只用来取值,不再作为独立英文字段列出来。
|
||||||
|
for mirror in (
|
||||||
|
"diagnosis type text",
|
||||||
|
"eye condition text",
|
||||||
|
"gender text",
|
||||||
|
"past history text",
|
||||||
|
):
|
||||||
|
assert mirror not in text
|
||||||
|
# 技术列不再泄漏英文列名。
|
||||||
|
for internal in (
|
||||||
|
"assign read at",
|
||||||
|
"shipped non er assistant cleared at",
|
||||||
|
"external userid",
|
||||||
|
"is view",
|
||||||
|
"assistant id",
|
||||||
|
"show card",
|
||||||
|
"revisit slot start offset",
|
||||||
|
"delete time",
|
||||||
|
):
|
||||||
|
assert internal not in text
|
||||||
|
# 有中文名的字段照常显示,取的是后端翻译过的值。
|
||||||
|
assert "复诊" in text
|
||||||
|
assert "模糊、干涩" in text
|
||||||
|
assert "1787882294" not in text
|
||||||
|
assert "https://media.example.invalid/11702/a.jpg" not in text
|
||||||
|
assert len(dialog.records["病历资料"].findChildren(QPushButton, "AiConsultTongueThumb")) == 2
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
class RawCodeRepository(WorkspaceRepository):
|
||||||
|
"""只读接口偶尔缺少 `*_text`(历史数据 / 快照),此时必须自己翻译字典 code。"""
|
||||||
|
|
||||||
|
def get_diagnosis_detail(
|
||||||
|
self,
|
||||||
|
diagnosis_id: int,
|
||||||
|
*,
|
||||||
|
readonly: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
detail = dict(super().get_diagnosis_detail(diagnosis_id, readonly=readonly))
|
||||||
|
diagnosis = dict(detail["diagnosis"])
|
||||||
|
diagnosis.update(
|
||||||
|
{
|
||||||
|
"gender": 1,
|
||||||
|
"diagnosis_type": "follow_up",
|
||||||
|
"appetite": "dry,bitter",
|
||||||
|
"weight_change": "lose_10_jin",
|
||||||
|
"fatty_liver_degree": "mild",
|
||||||
|
"allergy_history": 0,
|
||||||
|
"status": 1,
|
||||||
|
"show_card": 1,
|
||||||
|
"revisit_slot_start_offset": 0,
|
||||||
|
"delete_time": None,
|
||||||
|
"create_source": "admin",
|
||||||
|
"create_time": 1783838927,
|
||||||
|
"tongue_images": ["https://media.example.invalid/501/tongue-raw.jpg"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
detail["diagnosis"] = diagnosis
|
||||||
|
patient = dict(detail.get("patient") or {})
|
||||||
|
patient.update({"gender": 1, "marital_status": 1})
|
||||||
|
detail["patient"] = patient
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_and_health_tabs_translate_codes_and_hide_internal_columns(
|
||||||
|
application: QApplication,
|
||||||
|
immediate_async: None,
|
||||||
|
) -> None:
|
||||||
|
dialog = _open_dialog(application, RawCodeRepository())
|
||||||
|
dialog.tabs.setCurrentIndex(1)
|
||||||
|
application.processEvents()
|
||||||
|
case_text = _pane_text(dialog.records["病历资料"])
|
||||||
|
|
||||||
|
# 字典 code、性别与是否类枚举、时间戳都按后台只读页的口径显示。
|
||||||
|
assert "干、苦" in case_text
|
||||||
|
assert "瘦10斤" in case_text
|
||||||
|
assert "轻度" in case_text
|
||||||
|
assert "复诊" in case_text
|
||||||
|
assert "后台创建" in case_text
|
||||||
|
assert "dry,bitter" not in case_text
|
||||||
|
assert "lose_10_jin" not in case_text
|
||||||
|
assert "follow_up" not in case_text
|
||||||
|
assert "1783838927" not in case_text
|
||||||
|
# 纯内部列不再泄漏给医生。
|
||||||
|
for internal in ("show card", "revisit slot start offset", "delete time"):
|
||||||
|
assert internal not in case_text
|
||||||
|
# 舌象附件渲染成缩略图,不再是一长串 URL 文本。
|
||||||
|
assert "https://media.example.invalid/501/tongue-raw.jpg" not in case_text
|
||||||
|
thumbnails = dialog.records["病历资料"].findChildren(QPushButton, "AiConsultTongueThumb")
|
||||||
|
assert len(thumbnails) == 1
|
||||||
|
|
||||||
|
dialog.tabs.setCurrentIndex(4)
|
||||||
|
application.processEvents()
|
||||||
|
health_text = _pane_text(dialog.records["健康档案"])
|
||||||
|
assert "性别\n男" in health_text
|
||||||
|
assert "过敏史\n无" in health_text
|
||||||
|
assert "162 cm" in health_text
|
||||||
|
assert dialog.records["健康档案"].findChildren(QPushButton, "AiConsultTongueThumb")
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tongue_thumbnails_load_safe_http_sources_and_skip_unsafe_schemes(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -533,23 +729,22 @@ def test_tongue_thumbnail_auto_get_requires_configured_https_origin(
|
|||||||
RecordingRemoteImageButton,
|
RecordingRemoteImageButton,
|
||||||
)
|
)
|
||||||
|
|
||||||
untrusted = _open_dialog(application, WorkspaceRepository())
|
dialog = _open_dialog(application, WorkspaceRepository())
|
||||||
assert requested == []
|
# 舌象照片按字段直接渲染,PDF 报告与 file:// 附件不会发起任何图片请求。
|
||||||
untrusted.close()
|
|
||||||
|
|
||||||
trusted_repository = WorkspaceRepository()
|
|
||||||
trusted_repository.trusted_media_domains = ["media.example.invalid"]
|
|
||||||
assert not ai_consult_module._trusted_thumbnail_url(
|
|
||||||
trusted_repository,
|
|
||||||
"http://media.example.invalid/甲/tongue.jpg",
|
|
||||||
)
|
|
||||||
assert not ai_consult_module._trusted_thumbnail_url(
|
|
||||||
trusted_repository,
|
|
||||||
"https://sub.media.example.invalid/甲/tongue.jpg",
|
|
||||||
)
|
|
||||||
trusted = _open_dialog(application, trusted_repository)
|
|
||||||
assert requested == ["https://media.example.invalid/甲/tongue.jpg"]
|
assert requested == ["https://media.example.invalid/甲/tongue.jpg"]
|
||||||
trusted.close()
|
assert not ai_consult_module._previewable_attachment(
|
||||||
|
"tongue_images",
|
||||||
|
"file:///C:/private/tongue.jpg",
|
||||||
|
)
|
||||||
|
assert not ai_consult_module._previewable_attachment(
|
||||||
|
"report_files",
|
||||||
|
"https://media.example.invalid/甲/report.pdf",
|
||||||
|
)
|
||||||
|
assert ai_consult_module._previewable_attachment(
|
||||||
|
"report_files",
|
||||||
|
"https://media.example.invalid/甲/report.PNG",
|
||||||
|
)
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_three_prescription_cards_open_exact_details_and_reject_wrong_or_late_ids(
|
def test_three_prescription_cards_open_exact_details_and_reject_wrong_or_late_ids(
|
||||||
|
|||||||
+536
-357
@@ -1,94 +1,124 @@
|
|||||||
"""Desktop auto-update check, download and payload discovery."""
|
"""Desktop auto-update check, download and payload discovery."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from doctor_workstation.services import app_update
|
from doctor_workstation.services import app_update
|
||||||
from doctor_workstation.services.api_client import ApiClient
|
from doctor_workstation.services.api_client import ApiClient
|
||||||
from doctor_workstation.services.app_update import (
|
from doctor_workstation.services.app_update import (
|
||||||
PACKAGE_TYPE_ARCHIVE,
|
PACKAGE_TYPE_ARCHIVE,
|
||||||
PACKAGE_TYPE_INNO_SETUP,
|
PACKAGE_TYPE_INNO_SETUP,
|
||||||
AppUpdateError,
|
AppUpdateError,
|
||||||
UpdatePackage,
|
UpdatePackage,
|
||||||
apply_extracted_update,
|
apply_extracted_update,
|
||||||
apply_inno_setup_update,
|
apply_inno_setup_update,
|
||||||
compare_version,
|
compare_version,
|
||||||
discover_payload,
|
discover_payload,
|
||||||
download_package,
|
download_package,
|
||||||
fetch_update_offer,
|
fetch_update_offer,
|
||||||
normalize_version,
|
normalize_version,
|
||||||
package_filename,
|
package_filename,
|
||||||
parse_update_offer,
|
parse_update_offer,
|
||||||
safe_extract_zip,
|
safe_extract_zip,
|
||||||
validate_installer_download_policy,
|
validate_installer_download_policy,
|
||||||
validate_windows_installer,
|
validate_windows_installer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_and_compare_versions() -> None:
|
def test_normalize_and_compare_versions() -> None:
|
||||||
assert normalize_version("0.2") == "0.2.0"
|
assert normalize_version("0.2") == "0.2.0"
|
||||||
assert normalize_version("1.2.3.4") == "1.2.3"
|
assert normalize_version("1.2.3.4") == "1.2.3"
|
||||||
assert normalize_version("nope") == ""
|
assert normalize_version("nope") == ""
|
||||||
assert compare_version("0.1.0", "0.2.0") < 0
|
assert compare_version("0.1.0", "0.2.0") < 0
|
||||||
assert compare_version("0.2.0", "0.2.0") == 0
|
assert compare_version("0.2.0", "0.2.0") == 0
|
||||||
assert compare_version("1.0.0", "0.9.9") > 0
|
assert compare_version("1.0.0", "0.9.9") > 0
|
||||||
|
|
||||||
|
|
||||||
def test_parse_offer_requires_hash_before_install() -> None:
|
def test_parse_offer_requires_hash_before_install() -> None:
|
||||||
offer = parse_update_offer(
|
offer = parse_update_offer(
|
||||||
{
|
{
|
||||||
"has_update": True,
|
"has_update": True,
|
||||||
"force": True,
|
"force": True,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"latest_version": "0.2.0",
|
"latest_version": "0.2.0",
|
||||||
"package": {
|
"package": {
|
||||||
"url": "https://cdn.example.com/app.zip",
|
"url": "https://cdn.example.com/app.zip",
|
||||||
"sha256": "",
|
"sha256": "",
|
||||||
"size": 12,
|
"size": 12,
|
||||||
"filename": "app.zip",
|
"filename": "app.zip",
|
||||||
},
|
},
|
||||||
"can_install": True,
|
"can_install": True,
|
||||||
},
|
},
|
||||||
current_version="0.1.0",
|
current_version="0.1.0",
|
||||||
)
|
)
|
||||||
assert offer.has_update is True
|
assert offer.has_update is True
|
||||||
assert offer.can_install is False
|
assert offer.can_install is False
|
||||||
assert offer.force is False
|
assert offer.force is False
|
||||||
assert offer.package is None
|
assert offer.package is None
|
||||||
|
|
||||||
|
|
||||||
def test_parse_offer_accepts_explicit_inno_setup_type() -> None:
|
def test_parse_offer_accepts_explicit_inno_setup_type() -> None:
|
||||||
offer = parse_update_offer(
|
offer = parse_update_offer(
|
||||||
{
|
{
|
||||||
"has_update": True,
|
"has_update": True,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"latest_version": "0.2.0",
|
"latest_version": "0.2.0",
|
||||||
"platform": "windows",
|
"platform": "windows",
|
||||||
"arch": "x64",
|
"arch": "x64",
|
||||||
"package": {
|
"package": {
|
||||||
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
|
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
|
||||||
"sha256": "a" * 64,
|
"sha256": "a" * 64,
|
||||||
"size": 123,
|
"size": 123,
|
||||||
"filename": "DoctorWorkstation-Setup.exe",
|
"filename": "DoctorWorkstation-Setup.exe",
|
||||||
"type": "inno_setup",
|
"type": "inno_setup",
|
||||||
},
|
},
|
||||||
"can_install": True,
|
"can_install": True,
|
||||||
},
|
},
|
||||||
current_version="0.1.0",
|
current_version="0.1.0",
|
||||||
platform_name="windows",
|
platform_name="windows",
|
||||||
arch="x64",
|
arch="x64",
|
||||||
)
|
)
|
||||||
assert offer.can_install is True
|
assert offer.can_install is True
|
||||||
assert offer.package is not None
|
assert offer.package is not None
|
||||||
assert offer.package.type == PACKAGE_TYPE_INNO_SETUP
|
assert offer.package.type == PACKAGE_TYPE_INNO_SETUP
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_offer_rejects_package_version_mismatch() -> None:
|
||||||
|
offer = parse_update_offer(
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"has_update": True,
|
||||||
|
"force": True,
|
||||||
|
"can_install": True,
|
||||||
|
"latest_version": "1.3.0",
|
||||||
|
"platform": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"package": {
|
||||||
|
"type": PACKAGE_TYPE_INNO_SETUP,
|
||||||
|
"url": "https://cdn.example.com/DoctorWorkstation-Setup-1.1.0.exe",
|
||||||
|
"filename": "DoctorWorkstation-Setup-1.1.0.exe",
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"size": 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
current_version="1.2.0",
|
||||||
|
platform_name="windows",
|
||||||
|
arch="x64",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert offer.has_update is True
|
||||||
|
assert offer.can_install is False
|
||||||
|
assert offer.force is False
|
||||||
|
assert offer.package is None
|
||||||
|
assert "安装包版本 1.1.0 与发布版本 1.3.0 不一致" in offer.install_unavailable_reason
|
||||||
|
|
||||||
|
|
||||||
def test_parse_offer_disables_insecure_inno_setup_transport() -> None:
|
def test_parse_offer_disables_insecure_inno_setup_transport() -> None:
|
||||||
offer = parse_update_offer(
|
offer = parse_update_offer(
|
||||||
{
|
{
|
||||||
@@ -113,235 +143,236 @@ def test_parse_offer_disables_insecure_inno_setup_transport() -> None:
|
|||||||
assert offer.can_install is False
|
assert offer.can_install is False
|
||||||
assert offer.force is False
|
assert offer.force is False
|
||||||
assert offer.package is None
|
assert offer.package is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
|
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
|
||||||
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
|
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
|
||||||
offer = parse_update_offer(
|
offer = parse_update_offer(
|
||||||
{
|
{
|
||||||
"has_update": True,
|
"has_update": True,
|
||||||
"latest_version": "0.2.0",
|
"latest_version": "0.2.0",
|
||||||
"platform": "windows",
|
"platform": "windows",
|
||||||
"arch": "x64",
|
"arch": "x64",
|
||||||
"package": {
|
"package": {
|
||||||
"url": "https://cdn.example.com/update.bin",
|
"url": "https://cdn.example.com/update.bin",
|
||||||
"sha256": "a" * 64,
|
"sha256": "a" * 64,
|
||||||
"type": package_type,
|
"type": package_type,
|
||||||
},
|
},
|
||||||
"can_install": True,
|
"can_install": True,
|
||||||
},
|
},
|
||||||
current_version="0.1.0",
|
current_version="0.1.0",
|
||||||
platform_name="windows",
|
platform_name="windows",
|
||||||
arch="x64",
|
arch="x64",
|
||||||
)
|
)
|
||||||
assert offer.can_install is False
|
assert offer.can_install is False
|
||||||
assert offer.force is False
|
assert offer.force is False
|
||||||
assert offer.package is None
|
assert offer.package is None
|
||||||
|
|
||||||
|
|
||||||
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
|
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
|
||||||
base = {
|
base = {
|
||||||
"has_update": True,
|
"has_update": True,
|
||||||
"latest_version": "0.1.0",
|
"latest_version": "0.1.0",
|
||||||
"platform": "windows",
|
"platform": "windows",
|
||||||
"arch": "x64",
|
"arch": "x64",
|
||||||
"can_install": False,
|
"can_install": False,
|
||||||
}
|
}
|
||||||
stale = parse_update_offer(
|
stale = parse_update_offer(
|
||||||
base,
|
base,
|
||||||
current_version="0.1.0",
|
current_version="0.1.0",
|
||||||
platform_name="windows",
|
platform_name="windows",
|
||||||
arch="x64",
|
arch="x64",
|
||||||
)
|
)
|
||||||
wrong_platform = parse_update_offer(
|
wrong_platform = parse_update_offer(
|
||||||
{**base, "latest_version": "0.2.0", "platform": "macos"},
|
{**base, "latest_version": "0.2.0", "platform": "macos"},
|
||||||
current_version="0.1.0",
|
current_version="0.1.0",
|
||||||
platform_name="windows",
|
platform_name="windows",
|
||||||
arch="x64",
|
arch="x64",
|
||||||
)
|
)
|
||||||
assert stale.has_update is False
|
assert stale.has_update is False
|
||||||
assert wrong_platform.has_update is False
|
assert wrong_platform.has_update is False
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_update_offer_uses_check_endpoint() -> None:
|
def test_fetch_update_offer_uses_check_endpoint() -> None:
|
||||||
requests: list[httpx.Request] = []
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
requests.append(request)
|
requests.append(request)
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
json={
|
json={
|
||||||
"code": 1,
|
"code": 1,
|
||||||
"data": {
|
"data": {
|
||||||
"has_update": True,
|
"has_update": True,
|
||||||
"force": True,
|
"force": True,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"latest_version": "0.2.0",
|
"latest_version": "0.2.0",
|
||||||
"title": "医生工作站 0.2.0",
|
"title": "医生工作站 0.2.0",
|
||||||
"notes": "修复登录",
|
"notes": "修复登录",
|
||||||
"package": {
|
"package": {
|
||||||
"url": "https://cdn.example.com/DoctorWorkstation.zip",
|
"url": "https://cdn.example.com/DoctorWorkstation.zip",
|
||||||
"sha256": "a" * 64,
|
"sha256": "a" * 64,
|
||||||
"size": 2048,
|
"size": 2048,
|
||||||
"filename": "DoctorWorkstation.zip",
|
"filename": "DoctorWorkstation.zip",
|
||||||
},
|
},
|
||||||
"can_install": True,
|
"can_install": True,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client:
|
with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client:
|
||||||
offer = fetch_update_offer(
|
offer = fetch_update_offer(
|
||||||
client,
|
client,
|
||||||
current_version="0.1.0",
|
current_version="0.1.0",
|
||||||
platform_name="windows",
|
platform_name="windows",
|
||||||
arch="x64",
|
arch="x64",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert offer.has_update is True
|
assert offer.has_update is True
|
||||||
assert offer.force is True
|
assert offer.force is True
|
||||||
assert offer.can_install is True
|
assert offer.can_install is True
|
||||||
assert offer.package is not None
|
assert offer.package is not None
|
||||||
assert "setting.desktop_workstation/check" in str(requests[0].url)
|
assert "setting.desktop_workstation/check" in str(requests[0].url)
|
||||||
assert "current_version=0.1.0" in str(requests[0].url)
|
assert "current_version=0.1.0" in str(requests[0].url)
|
||||||
assert "platform=windows" in str(requests[0].url)
|
assert "platform=windows" in str(requests[0].url)
|
||||||
|
|
||||||
|
|
||||||
def test_safe_extract_rejects_zip_slip(tmp_path: Path) -> None:
|
def test_safe_extract_rejects_zip_slip(tmp_path: Path) -> None:
|
||||||
archive = tmp_path / "evil.zip"
|
archive = tmp_path / "evil.zip"
|
||||||
with zipfile.ZipFile(archive, "w") as bundle:
|
with zipfile.ZipFile(archive, "w") as bundle:
|
||||||
bundle.writestr("../outside.txt", "nope")
|
bundle.writestr("../outside.txt", "nope")
|
||||||
with pytest.raises(AppUpdateError, match="非法路径"):
|
with pytest.raises(AppUpdateError, match="非法路径"):
|
||||||
safe_extract_zip(archive, tmp_path / "out")
|
safe_extract_zip(archive, tmp_path / "out")
|
||||||
|
|
||||||
|
|
||||||
def test_discover_windows_payload_prefers_internal_onedir(tmp_path: Path) -> None:
|
def test_discover_windows_payload_prefers_internal_onedir(tmp_path: Path) -> None:
|
||||||
wrapped = tmp_path / "DoctorWorkstation"
|
wrapped = tmp_path / "DoctorWorkstation"
|
||||||
wrapped.mkdir()
|
wrapped.mkdir()
|
||||||
(wrapped / "_internal").mkdir()
|
(wrapped / "_internal").mkdir()
|
||||||
(wrapped / "DoctorWorkstation.exe").write_bytes(b"mz")
|
(wrapped / "DoctorWorkstation.exe").write_bytes(b"mz")
|
||||||
(tmp_path / "Start_DoctorWorkstation.bat").write_text("start", encoding="utf-8")
|
(tmp_path / "Start_DoctorWorkstation.bat").write_text("start", encoding="utf-8")
|
||||||
assert discover_payload(tmp_path, platform_name="windows") == wrapped
|
assert discover_payload(tmp_path, platform_name="windows") == wrapped
|
||||||
|
|
||||||
|
|
||||||
def test_discover_macos_payload_finds_app_bundle(tmp_path: Path) -> None:
|
def test_discover_macos_payload_finds_app_bundle(tmp_path: Path) -> None:
|
||||||
app = tmp_path / "DoctorWorkstation.app"
|
app = tmp_path / "DoctorWorkstation.app"
|
||||||
macos = app / "Contents" / "MacOS"
|
macos = app / "Contents" / "MacOS"
|
||||||
macos.mkdir(parents=True)
|
macos.mkdir(parents=True)
|
||||||
(macos / "DoctorWorkstation").write_text("bin", encoding="utf-8")
|
(macos / "DoctorWorkstation").write_text("bin", encoding="utf-8")
|
||||||
assert discover_payload(tmp_path, platform_name="macos") == app
|
assert discover_payload(tmp_path, platform_name="macos") == app
|
||||||
|
|
||||||
|
|
||||||
def test_download_package_verifies_sha256_and_reports_progress(tmp_path: Path) -> None:
|
def test_download_package_verifies_sha256_and_reports_progress(tmp_path: Path) -> None:
|
||||||
payload = b"doctor-workstation-zip"
|
payload = b"doctor-workstation-zip"
|
||||||
digest = hashlib.sha256(payload).hexdigest()
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
progress: list[tuple[int, int]] = []
|
progress: list[tuple[int, int]] = []
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
del request
|
del request
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
content=payload,
|
content=payload,
|
||||||
headers={"content-length": str(len(payload))},
|
headers={"content-length": str(len(payload))},
|
||||||
)
|
)
|
||||||
|
|
||||||
destination = tmp_path / "pkg.zip"
|
destination = tmp_path / "pkg.zip"
|
||||||
download_package(
|
download_package(
|
||||||
"https://cdn.example.com/pkg.zip",
|
"https://cdn.example.com/pkg.zip",
|
||||||
destination,
|
destination,
|
||||||
sha256=digest,
|
sha256=digest,
|
||||||
progress=lambda received, total: progress.append((received, total)),
|
progress=lambda received, total: progress.append((received, total)),
|
||||||
transport=httpx.MockTransport(handler),
|
transport=httpx.MockTransport(handler),
|
||||||
)
|
)
|
||||||
assert destination.read_bytes() == payload
|
assert destination.read_bytes() == payload
|
||||||
assert progress[-1][0] == len(payload)
|
assert progress[-1][0] == len(payload)
|
||||||
|
|
||||||
|
|
||||||
def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
|
def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
del request
|
del request
|
||||||
return httpx.Response(200, content=b"tampered")
|
return httpx.Response(200, content=b"tampered")
|
||||||
|
|
||||||
destination = tmp_path / "pkg.zip"
|
destination = tmp_path / "pkg.zip"
|
||||||
with pytest.raises(AppUpdateError, match="校验失败"):
|
with pytest.raises(AppUpdateError, match="校验失败"):
|
||||||
download_package(
|
download_package(
|
||||||
"https://cdn.example.com/pkg.zip",
|
"https://cdn.example.com/pkg.zip",
|
||||||
destination,
|
destination,
|
||||||
sha256="b" * 64,
|
sha256="b" * 64,
|
||||||
transport=httpx.MockTransport(handler),
|
transport=httpx.MockTransport(handler),
|
||||||
)
|
)
|
||||||
assert not destination.exists()
|
assert not destination.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
|
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
|
||||||
payload = b"short"
|
payload = b"short"
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
del request
|
del request
|
||||||
return httpx.Response(200, content=payload)
|
return httpx.Response(200, content=payload)
|
||||||
|
|
||||||
destination = tmp_path / "pkg.exe"
|
destination = tmp_path / "pkg.exe"
|
||||||
with pytest.raises(AppUpdateError, match="文件大小"):
|
with pytest.raises(AppUpdateError, match="文件大小"):
|
||||||
download_package(
|
download_package(
|
||||||
"https://cdn.example.com/pkg.exe",
|
"https://cdn.example.com/pkg.exe",
|
||||||
destination,
|
destination,
|
||||||
sha256=hashlib.sha256(payload).hexdigest(),
|
sha256=hashlib.sha256(payload).hexdigest(),
|
||||||
expected_size=len(payload) + 1,
|
expected_size=len(payload) + 1,
|
||||||
transport=httpx.MockTransport(handler),
|
transport=httpx.MockTransport(handler),
|
||||||
)
|
)
|
||||||
assert not destination.exists()
|
assert not destination.exists()
|
||||||
assert not (tmp_path / "pkg.exe.part").exists()
|
assert not (tmp_path / "pkg.exe.part").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_windows_installer_download_policy_requires_verified_https() -> None:
|
def test_windows_installer_download_policy_requires_verified_https() -> None:
|
||||||
with pytest.raises(AppUpdateError, match="HTTPS"):
|
with pytest.raises(AppUpdateError, match="HTTPS"):
|
||||||
validate_installer_download_policy(
|
validate_installer_download_policy(
|
||||||
"http://cdn.example.com/setup.exe",
|
"http://cdn.example.com/setup.exe",
|
||||||
verify_ssl=True,
|
verify_ssl=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(AppUpdateError, match="证书校验"):
|
with pytest.raises(AppUpdateError, match="证书校验"):
|
||||||
validate_installer_download_policy(
|
validate_installer_download_policy(
|
||||||
"https://cdn.example.com/setup.exe",
|
"https://cdn.example.com/setup.exe",
|
||||||
verify_ssl=False,
|
verify_ssl=False,
|
||||||
)
|
)
|
||||||
validate_installer_download_policy(
|
validate_installer_download_policy(
|
||||||
"http://127.0.0.1/setup.exe",
|
"http://127.0.0.1/setup.exe",
|
||||||
verify_ssl=True,
|
verify_ssl=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
|
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
|
||||||
installer = tmp_path / "Setup.exe"
|
installer = tmp_path / "Setup.exe"
|
||||||
installer.write_bytes(b"MZ" + b"\0" * 32)
|
installer.write_bytes(b"MZ" + b"\0" * 32)
|
||||||
assert validate_windows_installer(installer) == installer.resolve()
|
assert validate_windows_installer(installer) == installer.resolve()
|
||||||
|
|
||||||
invalid = tmp_path / "invalid.exe"
|
invalid = tmp_path / "invalid.exe"
|
||||||
invalid.write_bytes(b"PK")
|
invalid.write_bytes(b"PK")
|
||||||
with pytest.raises(AppUpdateError, match="PE"):
|
with pytest.raises(AppUpdateError, match="PE"):
|
||||||
validate_windows_installer(invalid)
|
validate_windows_installer(invalid)
|
||||||
|
|
||||||
|
|
||||||
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
|
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
install_root = tmp_path / "installed"
|
install_root = tmp_path / "installed"
|
||||||
install_root.mkdir()
|
install_root.mkdir()
|
||||||
installed_exe = install_root / "DoctorWorkstation.exe"
|
installed_exe = install_root / "DoctorWorkstation.exe"
|
||||||
installed_exe.write_bytes(b"MZ")
|
installed_exe.write_bytes(b"MZ")
|
||||||
installer = tmp_path / "DoctorWorkstation-Setup.exe"
|
installer = tmp_path / "DoctorWorkstation-Setup.exe"
|
||||||
installer.write_bytes(b"MZ" + b"\0" * 32)
|
installer.write_bytes(b"MZ" + b"\0" * 32)
|
||||||
spawned: dict[str, Path] = {}
|
spawned: dict[str, Path] = {}
|
||||||
|
|
||||||
monkeypatch.setattr(app_update.sys, "platform", "win32")
|
monkeypatch.setattr(app_update.sys, "platform", "win32")
|
||||||
|
|
||||||
def capture_spawn(
|
def capture_spawn(
|
||||||
script: Path,
|
script: Path,
|
||||||
*,
|
*,
|
||||||
installer: Path,
|
installer: Path,
|
||||||
restart_exe: Path,
|
restart_exe: Path,
|
||||||
helper_log_file: Path,
|
helper_log_file: Path,
|
||||||
installer_log_file: Path,
|
installer_log_file: Path,
|
||||||
|
ready_file: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
spawned.update(
|
spawned.update(
|
||||||
script=script,
|
script=script,
|
||||||
@@ -349,44 +380,192 @@ def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
|
|||||||
restart_exe=restart_exe,
|
restart_exe=restart_exe,
|
||||||
helper_log_file=helper_log_file,
|
helper_log_file=helper_log_file,
|
||||||
installer_log_file=installer_log_file,
|
installer_log_file=installer_log_file,
|
||||||
|
ready_file=ready_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
|
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
|
||||||
apply_inno_setup_update(installer, install_root=install_root)
|
apply_inno_setup_update(installer, install_root=install_root)
|
||||||
|
|
||||||
script_text = spawned["script"].read_text(encoding="utf-8-sig")
|
script_text = spawned["script"].read_text(encoding="utf-8-sig")
|
||||||
assert spawned["installer"] == installer.resolve()
|
assert spawned["installer"] == installer.resolve()
|
||||||
assert spawned["restart_exe"] == installed_exe
|
assert spawned["restart_exe"] == installed_exe
|
||||||
assert "/VERYSILENT" in script_text
|
assert "/VERYSILENT" in script_text
|
||||||
assert "/RESTARTEXITCODE=3010" in script_text
|
assert "/RESTARTEXITCODE=3010" in script_text
|
||||||
assert "/NOFORCECLOSEAPPLICATIONS" in script_text
|
assert "/NOFORCECLOSEAPPLICATIONS" in script_text
|
||||||
assert "$HelperLogFile" in script_text
|
assert "$HelperLogFile" in script_text
|
||||||
assert "$InstallerLogFile" in script_text
|
assert "$InstallerLogFile" in script_text
|
||||||
|
assert "$ReadyFile" in script_text
|
||||||
|
assert "helper ready" in script_text
|
||||||
assert "Restart-Application" in script_text
|
assert "Restart-Application" in script_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_inno_helper_uses_runnable_flags_and_waits_for_ready(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
script = tmp_path / "install_update.ps1"
|
||||||
|
script.write_text("", encoding="utf-8")
|
||||||
|
installer = tmp_path / "Setup.exe"
|
||||||
|
restart_exe = tmp_path / "DoctorWorkstation.exe"
|
||||||
|
helper_log = tmp_path / "helper.log"
|
||||||
|
installer_log = tmp_path / "inno.log"
|
||||||
|
ready_file = tmp_path / "helper.ready"
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def poll(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_popen(args: list[str], **kwargs: object) -> FakeProcess:
|
||||||
|
captured["args"] = args
|
||||||
|
captured.update(kwargs)
|
||||||
|
ready_file.write_text("ready", encoding="utf-8")
|
||||||
|
return FakeProcess()
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_update.subprocess, "DETACHED_PROCESS", 8, raising=False)
|
||||||
|
monkeypatch.setattr(app_update.subprocess, "CREATE_NEW_PROCESS_GROUP", 512, raising=False)
|
||||||
|
monkeypatch.setattr(app_update.subprocess, "CREATE_NO_WINDOW", 134217728, raising=False)
|
||||||
|
monkeypatch.setattr(app_update.subprocess, "Popen", fake_popen)
|
||||||
|
|
||||||
|
app_update._spawn_inno_setup_applier(
|
||||||
|
script,
|
||||||
|
installer=installer,
|
||||||
|
restart_exe=restart_exe,
|
||||||
|
helper_log_file=helper_log,
|
||||||
|
installer_log_file=installer_log,
|
||||||
|
ready_file=ready_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
flags = int(captured["creationflags"])
|
||||||
|
detached = int(getattr(app_update.subprocess, "DETACHED_PROCESS", 0))
|
||||||
|
assert not detached or flags & detached == 0
|
||||||
|
assert flags & int(getattr(app_update.subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
|
||||||
|
assert flags & int(getattr(app_update.subprocess, "CREATE_NO_WINDOW", 0))
|
||||||
|
assert "-ReadyFile" in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_inno_helper_reports_exit_before_ready(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
script = tmp_path / "install_update.ps1"
|
||||||
|
script.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
class ExitedProcess:
|
||||||
|
def poll(self) -> int:
|
||||||
|
return 23
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_update.subprocess, "Popen", lambda *args, **kwargs: ExitedProcess())
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="提前退出(代码 23)"):
|
||||||
|
app_update._spawn_inno_setup_applier(
|
||||||
|
script,
|
||||||
|
installer=tmp_path / "Setup.exe",
|
||||||
|
restart_exe=tmp_path / "DoctorWorkstation.exe",
|
||||||
|
helper_log_file=tmp_path / "helper.log",
|
||||||
|
installer_log_file=tmp_path / "inno.log",
|
||||||
|
ready_file=tmp_path / "helper.ready",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(app_update.sys.platform != "win32", reason="Windows helper contract")
|
||||||
|
def test_inno_helper_executes_bootstrap_with_production_flags(tmp_path: Path) -> None:
|
||||||
|
script = tmp_path / "helper probe.ps1"
|
||||||
|
script.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"param(",
|
||||||
|
"[int]$TargetPid, [string]$Installer, [string]$RestartExe,",
|
||||||
|
"[string]$HelperLogFile, [string]$InstallerLogFile, [string]$ReadyFile",
|
||||||
|
")",
|
||||||
|
'Set-Content -LiteralPath $ReadyFile -Value "ready" -Encoding UTF8',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
ready_file = tmp_path / "helper.ready"
|
||||||
|
|
||||||
|
app_update._spawn_inno_setup_applier(
|
||||||
|
script,
|
||||||
|
installer=tmp_path / "Setup.exe",
|
||||||
|
restart_exe=tmp_path / "DoctorWorkstation.exe",
|
||||||
|
helper_log_file=tmp_path / "helper.log",
|
||||||
|
installer_log_file=tmp_path / "inno.log",
|
||||||
|
ready_file=ready_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ready_file.read_text(encoding="utf-8-sig").strip() == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(app_update.sys.platform != "win32", reason="Windows helper contract")
|
||||||
|
def test_inno_helper_survives_launcher_process_exit(tmp_path: Path) -> None:
|
||||||
|
script = tmp_path / "helper parent-exit probe.ps1"
|
||||||
|
script.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"param(",
|
||||||
|
"[int]$TargetPid, [string]$Installer, [string]$RestartExe,",
|
||||||
|
"[string]$HelperLogFile, [string]$InstallerLogFile, [string]$ReadyFile",
|
||||||
|
")",
|
||||||
|
'Set-Content -LiteralPath $ReadyFile -Value "ready" -Encoding UTF8',
|
||||||
|
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) {",
|
||||||
|
" Start-Sleep -Milliseconds 50",
|
||||||
|
"}",
|
||||||
|
'Set-Content -LiteralPath $HelperLogFile -Value "parent-exited" -Encoding UTF8',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8-sig",
|
||||||
|
)
|
||||||
|
helper_log = tmp_path / "helper.log"
|
||||||
|
ready_file = tmp_path / "helper.ready"
|
||||||
|
launcher = (
|
||||||
|
"from pathlib import Path; import sys; "
|
||||||
|
"from doctor_workstation.services.app_update import _spawn_inno_setup_applier; "
|
||||||
|
"root=Path(sys.argv[1]); "
|
||||||
|
"_spawn_inno_setup_applier(root/'helper parent-exit probe.ps1', "
|
||||||
|
"installer=root/'Setup.exe', restart_exe=root/'DoctorWorkstation.exe', "
|
||||||
|
"helper_log_file=root/'helper.log', installer_log_file=root/'inno.log', "
|
||||||
|
"ready_file=root/'helper.ready')"
|
||||||
|
)
|
||||||
|
|
||||||
|
launched = app_update.subprocess.run(
|
||||||
|
[app_update.sys.executable, "-c", launcher, str(tmp_path)],
|
||||||
|
cwd=str(Path.cwd()),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert launched.returncode == 0, launched.stderr
|
||||||
|
deadline = app_update.time.monotonic() + 5.0
|
||||||
|
while not helper_log.is_file() and app_update.time.monotonic() < deadline:
|
||||||
|
app_update.time.sleep(0.05)
|
||||||
|
assert ready_file.is_file()
|
||||||
|
assert helper_log.read_text(encoding="utf-8-sig").strip() == "parent-exited"
|
||||||
|
|
||||||
|
|
||||||
def test_archive_applier_restarts_from_install_root(
|
def test_archive_applier_restarts_from_install_root(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
payload = tmp_path / "payload"
|
payload = tmp_path / "payload"
|
||||||
payload.mkdir()
|
payload.mkdir()
|
||||||
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
|
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
|
||||||
install_root = tmp_path / "installed"
|
install_root = tmp_path / "installed"
|
||||||
install_root.mkdir()
|
install_root.mkdir()
|
||||||
installed_exe = install_root / "DoctorWorkstation.exe"
|
installed_exe = install_root / "DoctorWorkstation.exe"
|
||||||
installed_exe.write_bytes(b"MZ")
|
installed_exe.write_bytes(b"MZ")
|
||||||
captured: dict[str, Path] = {}
|
captured: dict[str, Path] = {}
|
||||||
script = tmp_path / "apply.ps1"
|
script = tmp_path / "apply.ps1"
|
||||||
script.write_text("", encoding="utf-8")
|
script.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
def capture_script(**kwargs: Path) -> Path:
|
def capture_script(**kwargs: Path) -> Path:
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
return script
|
return script
|
||||||
|
|
||||||
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
|
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
|
||||||
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
|
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
|
||||||
apply_extracted_update(payload, install_root=install_root)
|
apply_extracted_update(payload, install_root=install_root)
|
||||||
assert captured["restart_exe"] == installed_exe
|
assert captured["restart_exe"] == installed_exe
|
||||||
|
|
||||||
|
|
||||||
@@ -409,17 +588,17 @@ def test_inno_setup_applier_reports_helper_start_failure(
|
|||||||
|
|
||||||
with pytest.raises(AppUpdateError, match="无法启动 Windows 更新助手"):
|
with pytest.raises(AppUpdateError, match="无法启动 Windows 更新助手"):
|
||||||
apply_inno_setup_update(installer, install_root=install_root)
|
apply_inno_setup_update(installer, install_root=install_root)
|
||||||
|
|
||||||
|
|
||||||
def test_package_filename_defaults_match_package_type() -> None:
|
def test_package_filename_defaults_match_package_type() -> None:
|
||||||
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
|
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
|
||||||
installer = UpdatePackage(
|
installer = UpdatePackage(
|
||||||
"https://cdn.example.com/",
|
"https://cdn.example.com/",
|
||||||
"a" * 64,
|
"a" * 64,
|
||||||
0,
|
0,
|
||||||
"",
|
"",
|
||||||
type=PACKAGE_TYPE_INNO_SETUP,
|
type=PACKAGE_TYPE_INNO_SETUP,
|
||||||
)
|
)
|
||||||
assert package_filename(archive, "0.2.0").endswith(".zip")
|
assert package_filename(archive, "0.2.0").endswith(".zip")
|
||||||
assert package_filename(installer, "0.2.0").endswith(".exe")
|
assert package_filename(installer, "0.2.0").endswith(".exe")
|
||||||
assert archive.type == PACKAGE_TYPE_ARCHIVE
|
assert archive.type == PACKAGE_TYPE_ARCHIVE
|
||||||
|
|||||||
+250
-70
@@ -1,70 +1,250 @@
|
|||||||
"""Update dialog contract for optional and forced desktop upgrades."""
|
"""Update dialog contract for optional and forced desktop upgrades."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import replace
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from doctor_workstation.services.app_update import UpdateOffer, UpdatePackage
|
from PySide6.QtCore import QObject, Qt
|
||||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog
|
from PySide6.QtTest import QTest
|
||||||
from doctor_workstation.ui.theme import apply_theme
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.services.app_update import (
|
||||||
def _offer(*, force: bool, can_install: bool = True) -> UpdateOffer:
|
PACKAGE_TYPE_INNO_SETUP,
|
||||||
package = (
|
UpdateOffer,
|
||||||
UpdatePackage(
|
UpdatePackage,
|
||||||
url="https://cdn.example.com/DoctorWorkstation.zip",
|
)
|
||||||
sha256="a" * 64,
|
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog, AppUpdateSession
|
||||||
size=1024,
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
filename="DoctorWorkstation.zip",
|
|
||||||
)
|
|
||||||
if can_install
|
def _offer(
|
||||||
else None
|
*,
|
||||||
)
|
force: bool,
|
||||||
return UpdateOffer(
|
can_install: bool = True,
|
||||||
has_update=True,
|
install_unavailable_reason: str = "",
|
||||||
force=force,
|
) -> UpdateOffer:
|
||||||
enabled=True,
|
package = (
|
||||||
current_version="0.1.0",
|
UpdatePackage(
|
||||||
latest_version="0.2.0",
|
url="https://cdn.example.com/DoctorWorkstation.zip",
|
||||||
min_version="",
|
sha256="a" * 64,
|
||||||
title="医生工作站 0.2.0",
|
size=1024,
|
||||||
notes="修复若干问题",
|
filename="DoctorWorkstation.zip",
|
||||||
platform="windows",
|
)
|
||||||
arch="x64",
|
if can_install
|
||||||
package=package,
|
else None
|
||||||
can_install=can_install,
|
)
|
||||||
)
|
return UpdateOffer(
|
||||||
|
has_update=True,
|
||||||
|
force=force,
|
||||||
def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None:
|
enabled=True,
|
||||||
app = application or QApplication.instance() or QApplication([])
|
current_version="0.1.0",
|
||||||
apply_theme(app)
|
latest_version="0.2.0",
|
||||||
dialog = AppUpdateDialog(_offer(force=False))
|
min_version="",
|
||||||
dialog.show()
|
title="医生工作站 0.2.0",
|
||||||
app.processEvents()
|
notes="修复若干问题",
|
||||||
assert dialog.later_button.isVisible()
|
platform="windows",
|
||||||
assert dialog.update_button.text() == "立即更新"
|
arch="x64",
|
||||||
assert dialog.notes.toPlainText() == "修复若干问题"
|
package=package,
|
||||||
dialog.close()
|
can_install=can_install,
|
||||||
|
install_unavailable_reason=install_unavailable_reason,
|
||||||
|
)
|
||||||
def test_forced_update_dialog_hides_defer_and_blocks_escape(
|
|
||||||
application: QApplication | None = None,
|
|
||||||
) -> None:
|
def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None:
|
||||||
app = application or QApplication.instance() or QApplication([])
|
app = application or QApplication.instance() or QApplication([])
|
||||||
apply_theme(app)
|
apply_theme(app)
|
||||||
dialog = AppUpdateDialog(_offer(force=True))
|
dialog = AppUpdateDialog(_offer(force=False))
|
||||||
dialog.show()
|
dialog.show()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
assert not dialog.later_button.isVisible()
|
deferred: list[bool] = []
|
||||||
assert "必须更新" in dialog.badge.text()
|
dialog.update_deferred.connect(lambda: deferred.append(True))
|
||||||
dialog.close()
|
assert dialog.later_button.isVisible()
|
||||||
app.processEvents()
|
assert dialog.later_button.isEnabled()
|
||||||
assert dialog.isVisible()
|
assert dialog.later_button.text() == "稍后提醒"
|
||||||
dialog.offer = _offer(force=False)
|
assert not dialog.exit_button.isVisible()
|
||||||
dialog._busy = False
|
assert dialog.update_button.text() == "立即更新"
|
||||||
dialog.close()
|
assert dialog.notes.toPlainText() == "修复若干问题"
|
||||||
|
dialog.later_button.click()
|
||||||
|
assert deferred == [True]
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_forced_update_dialog_has_explicit_exit_and_blocks_implicit_close(
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
dialog = AppUpdateDialog(_offer(force=True))
|
||||||
|
dialog.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert not dialog.later_button.isVisible()
|
||||||
|
assert dialog.exit_button.isVisible()
|
||||||
|
assert dialog.exit_button.isEnabled()
|
||||||
|
assert dialog.exit_button.text() == "退出软件"
|
||||||
|
assert "必须更新" in dialog.badge.text()
|
||||||
|
|
||||||
|
dialog.close()
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.isVisible()
|
||||||
|
|
||||||
|
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.isVisible()
|
||||||
|
|
||||||
|
dialog.set_busy(True)
|
||||||
|
dialog.show_download_progress(256, 1024)
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.exit_button.isVisible()
|
||||||
|
assert dialog.exit_button.isEnabled()
|
||||||
|
assert not dialog.update_button.isEnabled()
|
||||||
|
assert not dialog.cancel_button.isVisible()
|
||||||
|
|
||||||
|
dialog.close()
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.isVisible()
|
||||||
|
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.isVisible()
|
||||||
|
|
||||||
|
dialog.allow_application_exit()
|
||||||
|
dialog.close()
|
||||||
|
app.processEvents()
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_forced_update_exit_button_emits_dedicated_request(
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
dialog = AppUpdateDialog(_offer(force=True))
|
||||||
|
requests: list[bool] = []
|
||||||
|
dialog.exit_requested.connect(lambda: requests.append(True))
|
||||||
|
dialog.show()
|
||||||
|
dialog.set_busy(True)
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
dialog.exit_button.click()
|
||||||
|
|
||||||
|
assert requests == [True]
|
||||||
|
assert dialog.isVisible()
|
||||||
|
dialog.hide()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_quits_immediately_when_update_has_not_started(
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
host = QObject()
|
||||||
|
quit_requests: list[bool] = []
|
||||||
|
host.request_quit = lambda: quit_requests.append(True) # type: ignore[attr-defined]
|
||||||
|
session = AppUpdateSession(host)
|
||||||
|
dialog = AppUpdateDialog(_offer(force=True))
|
||||||
|
session.dialog = dialog
|
||||||
|
dialog.show()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
session._request_exit(dialog)
|
||||||
|
|
||||||
|
assert session._cancel_event.is_set()
|
||||||
|
assert quit_requests == [True]
|
||||||
|
assert not dialog.exit_button.isEnabled()
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
|
||||||
|
dialog.hide()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_waits_for_update_worker_before_quitting(
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
host = QObject()
|
||||||
|
quit_requests: list[bool] = []
|
||||||
|
host.request_quit = lambda: quit_requests.append(True) # type: ignore[attr-defined]
|
||||||
|
session = AppUpdateSession(host)
|
||||||
|
dialog = AppUpdateDialog(_offer(force=True))
|
||||||
|
session.dialog = dialog
|
||||||
|
active_signals = QObject()
|
||||||
|
session._signals = active_signals # type: ignore[assignment]
|
||||||
|
session._active_install_signals = active_signals # type: ignore[assignment]
|
||||||
|
dialog.show()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
session._request_exit(dialog)
|
||||||
|
|
||||||
|
assert session._cancel_event.is_set()
|
||||||
|
assert quit_requests == []
|
||||||
|
assert not dialog.exit_button.isEnabled()
|
||||||
|
assert "退出软件" in dialog.status_label.text()
|
||||||
|
assert dialog.isVisible()
|
||||||
|
|
||||||
|
session._finish_install(active_signals, dialog, object()) # type: ignore[arg-type]
|
||||||
|
assert quit_requests == []
|
||||||
|
|
||||||
|
session._on_install_finished(active_signals) # type: ignore[arg-type]
|
||||||
|
assert quit_requests == [True]
|
||||||
|
assert session._active_install_signals is None
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
|
||||||
|
dialog.hide()
|
||||||
|
dialog.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unavailable_update_dialog_shows_policy_reason(
|
||||||
|
application: QApplication | None = None,
|
||||||
|
) -> None:
|
||||||
|
app = application or QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
reason = "无法自动安装:自动安装 Windows 更新必须开启 HTTPS 证书校验。"
|
||||||
|
dialog = AppUpdateDialog(
|
||||||
|
_offer(
|
||||||
|
force=False,
|
||||||
|
can_install=False,
|
||||||
|
install_unavailable_reason=reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dialog.show()
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.status_label.text() == reason
|
||||||
|
assert not dialog.update_button.isEnabled()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_explains_disabled_certificate_verification() -> None:
|
||||||
|
host = QObject()
|
||||||
|
host.config = SimpleNamespace(verify_ssl=False) # type: ignore[attr-defined]
|
||||||
|
session = AppUpdateSession(host)
|
||||||
|
session._generation = 1
|
||||||
|
presented: list[UpdateOffer] = []
|
||||||
|
session._present = presented.append # type: ignore[method-assign]
|
||||||
|
offer = replace(
|
||||||
|
_offer(force=True),
|
||||||
|
package=UpdatePackage(
|
||||||
|
url="https://cdn.example.com/DoctorWorkstation-Setup.exe",
|
||||||
|
sha256="a" * 64,
|
||||||
|
size=1024,
|
||||||
|
filename="DoctorWorkstation-Setup.exe",
|
||||||
|
type=PACKAGE_TYPE_INNO_SETUP,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
session._on_offer(offer, interactive=True, generation=1)
|
||||||
|
|
||||||
|
assert len(presented) == 1
|
||||||
|
assert presented[0].can_install is False
|
||||||
|
assert presented[0].force is False
|
||||||
|
assert presented[0].package is None
|
||||||
|
assert "开启 HTTPS 证书校验" in presented[0].install_unavailable_reason
|
||||||
|
assert "取消勾选“信任自签名证书" in presented[0].install_unavailable_reason
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.dialogs.appointment_complete import (
|
||||||
|
COMPLETION_NOTE_LIMIT,
|
||||||
|
AppointmentCompleteDialog,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.pages import reception as reception_module
|
||||||
|
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
class CompletionRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[Any, ...]] = []
|
||||||
|
self.fail_complete = False
|
||||||
|
self.fail_note = False
|
||||||
|
self.detail: dict[str, Any] = {
|
||||||
|
"appointment": {"id": 51, "patient_id": 251, "status": 1},
|
||||||
|
"diagnosis": {"id": 251, "patient_id": 151},
|
||||||
|
"patient": {"id": 151},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||||
|
self.calls.append(("revalidate", appointment_id))
|
||||||
|
return self.detail
|
||||||
|
|
||||||
|
def complete_appointment(self, appointment_id: int) -> dict[str, bool]:
|
||||||
|
self.calls.append(("complete", appointment_id))
|
||||||
|
if self.fail_complete:
|
||||||
|
raise RuntimeError("完成接口失败")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def add_doctor_note(self, diagnosis_id: int, content: str) -> dict[str, bool]:
|
||||||
|
self.calls.append(("note", diagnosis_id, content))
|
||||||
|
if self.fail_note:
|
||||||
|
raise RuntimeError("备注接口失败")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def harness(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
jobs: list[dict[str, Any]] = []
|
||||||
|
toasts: list[tuple[str, str]] = []
|
||||||
|
refreshes: list[bool] = []
|
||||||
|
|
||||||
|
def queue(function: Any, **options: Any) -> object:
|
||||||
|
jobs.append({"function": function, **options})
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(reception_module, "run_async", queue)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reception_module,
|
||||||
|
"show_toast",
|
||||||
|
lambda _parent, text, kind, *_args: toasts.append((text, kind)),
|
||||||
|
)
|
||||||
|
repository = CompletionRepository()
|
||||||
|
page = ReceptionPage(
|
||||||
|
repository,
|
||||||
|
PermissionSet(["doctor.appointment/complete", "doctor.appointment/addDoctorNote"]),
|
||||||
|
)
|
||||||
|
page._selected_appointment_id = 51
|
||||||
|
page._selected_record = dict(repository.detail["appointment"])
|
||||||
|
page._selected_detail = repository.detail
|
||||||
|
page._update_action_state(repository.detail["appointment"], repository.detail["diagnosis"])
|
||||||
|
monkeypatch.setattr(page, "refresh", lambda *, silent=False: refreshes.append(silent))
|
||||||
|
yield page, repository, jobs, toasts, refreshes
|
||||||
|
if page._completion_dialog is not None:
|
||||||
|
page._completion_dialog.set_busy(False)
|
||||||
|
page._completion_dialog.reject()
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def finish_job(job: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
result = job["function"]()
|
||||||
|
except Exception as error:
|
||||||
|
job["on_error"](error)
|
||||||
|
else:
|
||||||
|
job["on_success"](result)
|
||||||
|
finally:
|
||||||
|
job["on_finished"]()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("can_note", [True, False])
|
||||||
|
def test_completion_dialog_optional_note_limit_and_busy_state(
|
||||||
|
application: QApplication, can_note: bool
|
||||||
|
) -> None:
|
||||||
|
dialog = AppointmentCompleteDialog(can_note=can_note)
|
||||||
|
submitted: list[str] = []
|
||||||
|
dialog.submitted.connect(submitted.append)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
assert dialog.windowTitle() == "完成问诊"
|
||||||
|
assert dialog.note_edit.isVisible() is can_note
|
||||||
|
assert dialog.note_counter.isVisible() is can_note
|
||||||
|
assert dialog.note_counter.text() == "0 / 500"
|
||||||
|
dialog.note_edit.setPlainText("字" * 501)
|
||||||
|
assert dialog.note_edit.toPlainText() == "字" * COMPLETION_NOTE_LIMIT
|
||||||
|
assert dialog.note_counter.text() == "500 / 500"
|
||||||
|
dialog.note_edit.insertPlainText("额外内容")
|
||||||
|
assert len(dialog.note_edit.toPlainText()) == COMPLETION_NOTE_LIMIT
|
||||||
|
dialog.note_edit.setPlainText(" 测试备注\n第二行 ")
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
assert submitted == ["测试备注\n第二行" if can_note else ""]
|
||||||
|
dialog.set_busy(True)
|
||||||
|
assert dialog.note_edit.isReadOnly()
|
||||||
|
assert not dialog.cancel_button.isEnabled()
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
dialog.reject()
|
||||||
|
dialog.close()
|
||||||
|
assert len(submitted) == 1
|
||||||
|
assert dialog.isVisible()
|
||||||
|
dialog.show_error("提交失败,请重试")
|
||||||
|
assert dialog.note_edit.toPlainText() == " 测试备注\n第二行 "
|
||||||
|
assert dialog.confirm_button.isEnabled()
|
||||||
|
dialog.cancel_button.click()
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
finally:
|
||||||
|
dialog.set_busy(False)
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_completion_does_not_make_requests(harness: Any) -> None:
|
||||||
|
page, repository, jobs, _toasts, _refreshes = harness
|
||||||
|
page.complete_button.click()
|
||||||
|
dialog = page._completion_dialog
|
||||||
|
assert isinstance(dialog, AppointmentCompleteDialog)
|
||||||
|
page._complete_appointment()
|
||||||
|
assert page._completion_dialog is dialog
|
||||||
|
assert jobs == []
|
||||||
|
dialog.note_edit.setPlainText("取消后不应保存")
|
||||||
|
dialog.cancel_button.click()
|
||||||
|
assert page._completion_dialog is None
|
||||||
|
assert repository.calls == []
|
||||||
|
assert jobs == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("note", ["", " \n ", " 测试备注\n补充内容 ", "字" * 500])
|
||||||
|
def test_complete_then_append_note_and_refresh_without_duplicate_submission(
|
||||||
|
harness: Any, note: str
|
||||||
|
) -> None:
|
||||||
|
page, repository, jobs, toasts, refreshes = harness
|
||||||
|
page.complete_button.click()
|
||||||
|
dialog = page._completion_dialog
|
||||||
|
dialog.note_edit.setPlainText(note)
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
assert page._completion_pending
|
||||||
|
assert not page.complete_button.isEnabled()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
# Polling and direct handler calls cannot enable/dispatch a second request.
|
||||||
|
page._update_action_state(repository.detail["appointment"], repository.detail["diagnosis"])
|
||||||
|
assert not page.complete_button.isEnabled()
|
||||||
|
page._complete_appointment()
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
finish_job(jobs.pop())
|
||||||
|
expected = [("revalidate", 51), ("complete", 51)]
|
||||||
|
if note.strip():
|
||||||
|
expected.append(("note", 251, note.strip()))
|
||||||
|
assert repository.calls == expected
|
||||||
|
assert page._completion_dialog is None
|
||||||
|
assert not page._completion_pending
|
||||||
|
assert page._selected_appointment_id is None
|
||||||
|
assert toasts[-1] == ("接诊已完成。", "success")
|
||||||
|
assert refreshes == [True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_without_note_permission_does_not_submit_hidden_note(harness: Any) -> None:
|
||||||
|
page, repository, jobs, _toasts, _refreshes = harness
|
||||||
|
page._can_note = False
|
||||||
|
page._complete_appointment()
|
||||||
|
dialog = page._completion_dialog
|
||||||
|
assert dialog.note_edit.isHidden()
|
||||||
|
dialog.note_edit.setPlainText("不可提交")
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
finish_job(jobs.pop())
|
||||||
|
assert repository.calls == [("revalidate", 51), ("complete", 51)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_failure_preserves_note_and_allows_explicit_retry(harness: Any) -> None:
|
||||||
|
page, repository, jobs, toasts, refreshes = harness
|
||||||
|
repository.fail_complete = True
|
||||||
|
page._complete_appointment()
|
||||||
|
dialog = page._completion_dialog
|
||||||
|
dialog.note_edit.setPlainText("需要保留的备注")
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
finish_job(jobs.pop())
|
||||||
|
assert repository.calls == [("revalidate", 51), ("complete", 51)]
|
||||||
|
assert page._completion_dialog is dialog
|
||||||
|
assert dialog.isVisible()
|
||||||
|
assert dialog.note_edit.toPlainText() == "需要保留的备注"
|
||||||
|
assert dialog.confirm_button.isEnabled()
|
||||||
|
assert "完成接口失败" in dialog.banner.label.text()
|
||||||
|
assert not page._completion_pending
|
||||||
|
assert page.complete_button.isEnabled()
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
assert toasts == []
|
||||||
|
assert refreshes == []
|
||||||
|
repository.fail_complete = False
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
finish_job(jobs.pop())
|
||||||
|
assert repository.calls[-1] == ("note", 251, "需要保留的备注")
|
||||||
|
assert page._completion_dialog is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("missing_diagnosis", [True, False])
|
||||||
|
def test_note_failure_is_partial_success_and_keeps_note_for_copying(
|
||||||
|
harness: Any, missing_diagnosis: bool
|
||||||
|
) -> None:
|
||||||
|
page, repository, jobs, toasts, refreshes = harness
|
||||||
|
if missing_diagnosis:
|
||||||
|
repository.detail["diagnosis"] = {}
|
||||||
|
else:
|
||||||
|
repository.fail_note = True
|
||||||
|
page._complete_appointment()
|
||||||
|
dialog = page._completion_dialog
|
||||||
|
dialog.note_edit.setPlainText("备注不能丢失")
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
finish_job(jobs.pop())
|
||||||
|
assert [call[0] for call in repository.calls] == (
|
||||||
|
["revalidate", "complete"] if missing_diagnosis else ["revalidate", "complete", "note"]
|
||||||
|
)
|
||||||
|
assert page._selected_appointment_id is None
|
||||||
|
assert refreshes == [True]
|
||||||
|
assert toasts[-1][1] == "warning"
|
||||||
|
assert "问诊已完成" in dialog.banner.label.text()
|
||||||
|
assert "备注未保存" in dialog.banner.label.text()
|
||||||
|
assert dialog.note_edit.toPlainText() == "备注不能丢失"
|
||||||
|
assert dialog.note_edit.isReadOnly()
|
||||||
|
assert dialog.confirm_button.isHidden()
|
||||||
|
assert not dialog.confirm_button.isEnabled()
|
||||||
|
assert dialog.cancel_button.text() == "关闭"
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
assert jobs == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("changed_id", [True, False])
|
||||||
|
def test_completion_revalidation_rejects_changed_record_before_any_write(
|
||||||
|
harness: Any, changed_id: bool
|
||||||
|
) -> None:
|
||||||
|
page, repository, _jobs, _toasts, _refreshes = harness
|
||||||
|
if changed_id:
|
||||||
|
repository.detail["appointment"]["id"] = 52
|
||||||
|
else:
|
||||||
|
repository.detail["appointment"]["status"] = 3
|
||||||
|
with pytest.raises(ValueError, match="不一致|状态已变化"):
|
||||||
|
page._complete_after_revalidation(51, "测试备注")
|
||||||
|
assert repository.calls == [("revalidate", 51)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_checks_permissions_and_note_length_before_requests(harness: Any) -> None:
|
||||||
|
page, repository, jobs, _toasts, _refreshes = harness
|
||||||
|
with pytest.raises(ValueError, match="500"):
|
||||||
|
page._complete_after_revalidation(51, "字" * 501)
|
||||||
|
page._can_note = False
|
||||||
|
with pytest.raises(ValueError, match="备注权限"):
|
||||||
|
page._complete_after_revalidation(51, "测试备注")
|
||||||
|
page._can_complete = False
|
||||||
|
page._complete_appointment()
|
||||||
|
assert page._completion_dialog is None
|
||||||
|
with pytest.raises(ValueError, match="完成接诊权限"):
|
||||||
|
page._complete_after_revalidation(51)
|
||||||
|
assert repository.calls == []
|
||||||
|
assert jobs == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("switch_before_confirm", [True, False])
|
||||||
|
def test_completion_does_not_mutate_or_clear_a_new_selection(
|
||||||
|
harness: Any, switch_before_confirm: bool
|
||||||
|
) -> None:
|
||||||
|
page, repository, jobs, _toasts, _refreshes = harness
|
||||||
|
page._complete_appointment()
|
||||||
|
dialog = page._completion_dialog
|
||||||
|
dialog.note_edit.setPlainText("原患者的备注")
|
||||||
|
if not switch_before_confirm:
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
page._selected_appointment_id = 52
|
||||||
|
page._selected_record = {"id": 52, "status": 1}
|
||||||
|
page._selected_detail = {"appointment": page._selected_record, "diagnosis": {"id": 252}}
|
||||||
|
page._detail_generation += 1
|
||||||
|
page._update_action_state(page._selected_record, {"id": 252})
|
||||||
|
if switch_before_confirm:
|
||||||
|
dialog.confirm_button.click()
|
||||||
|
assert jobs == []
|
||||||
|
assert repository.calls == []
|
||||||
|
assert "已切换" in dialog.banner.label.text()
|
||||||
|
else:
|
||||||
|
finish_job(jobs.pop())
|
||||||
|
assert repository.calls[-1] == ("note", 251, "原患者的备注")
|
||||||
|
assert page.complete_button.isEnabled()
|
||||||
|
assert page._selected_appointment_id == 52
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""登录后聊天通知的契约:轮询、卡片、点击去向。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||||
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
|
from doctor_workstation.ui import chat_notifications as chat_module
|
||||||
|
from doctor_workstation.ui.chat_notifications import (
|
||||||
|
CONSULTATION_COMPLETE,
|
||||||
|
PATIENT_LEFT_CHAT,
|
||||||
|
PATIENT_OPENED_CHAT,
|
||||||
|
ChatNotificationCenter,
|
||||||
|
parse_notification,
|
||||||
|
relative_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def run_immediately(
|
||||||
|
function: Callable[..., Any],
|
||||||
|
*args: Any,
|
||||||
|
on_success: Callable[[Any], Any] | None = None,
|
||||||
|
on_error: Callable[[Exception], Any] | None = None,
|
||||||
|
on_finished: Callable[[], Any] | None = None,
|
||||||
|
**_kwargs: Any,
|
||||||
|
) -> object:
|
||||||
|
try:
|
||||||
|
result = function(*args)
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_module, "run_async", run_immediately)
|
||||||
|
|
||||||
|
|
||||||
|
class _NotifyRepository:
|
||||||
|
def __init__(self, *batches: list[dict[str, Any]]) -> None:
|
||||||
|
self.batches = list(batches)
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||||
|
self.calls += 1
|
||||||
|
# 服务端取一次即消费,这里同样只发一次。
|
||||||
|
return self.batches.pop(0) if self.batches else []
|
||||||
|
|
||||||
|
|
||||||
|
def _row(identifier: str, kind: str = PATIENT_OPENED_CHAT, **extra: Any) -> dict[str, Any]:
|
||||||
|
row = {
|
||||||
|
"id": identifier,
|
||||||
|
"type": kind,
|
||||||
|
"doctor_id": 7,
|
||||||
|
"patient_id": "11676",
|
||||||
|
"patient_name": "甘先生",
|
||||||
|
"created_at": 1787882294,
|
||||||
|
}
|
||||||
|
row.update(extra)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_normalize_into_admin_equivalent_cards() -> None:
|
||||||
|
opened = parse_notification(_row("a1"))
|
||||||
|
assert opened is not None
|
||||||
|
assert opened.title == "患者打开会话"
|
||||||
|
assert opened.description == "甘先生 已打开与您的会话,请及时查看"
|
||||||
|
assert opened.action_text == "去接诊台"
|
||||||
|
|
||||||
|
left = parse_notification(_row("a2", PATIENT_LEFT_CHAT))
|
||||||
|
assert left is not None
|
||||||
|
assert left.description == "甘先生 已离开问诊会话页面"
|
||||||
|
|
||||||
|
complete = parse_notification(
|
||||||
|
_row("a3", CONSULTATION_COMPLETE, doctor_name="陈医生", diagnosis_id="8169")
|
||||||
|
)
|
||||||
|
assert complete is not None
|
||||||
|
assert complete.diagnosis_id == 8169
|
||||||
|
assert complete.description == "甘先生 的面诊已由 陈医生 完成,请及时跟进"
|
||||||
|
|
||||||
|
# 缺 id、未知 type、非映射行都不该变成卡片。
|
||||||
|
assert parse_notification(_row("", PATIENT_OPENED_CHAT)) is None
|
||||||
|
assert parse_notification(_row("a4", "unknown_business")) is None
|
||||||
|
assert parse_notification("not-a-row") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_time_matches_the_admin_wording() -> None:
|
||||||
|
now = 1787882294 + 0.0
|
||||||
|
assert relative_time(1787882294, now=now) == "刚刚"
|
||||||
|
assert relative_time(1787882294 - 120, now=now) == "2 分钟前"
|
||||||
|
assert relative_time(1787882294 - 7200, now=now) == "2 小时前"
|
||||||
|
assert relative_time(0, now=now) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_center_polls_once_per_tick_and_never_repeats_a_card(
|
||||||
|
application: QApplication,
|
||||||
|
immediate_async: None,
|
||||||
|
) -> None:
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(1280, 800)
|
||||||
|
repository = _NotifyRepository([_row("a1"), _row("a1")], [_row("a2", PATIENT_LEFT_CHAT)])
|
||||||
|
center = ChatNotificationCenter(repository, host)
|
||||||
|
|
||||||
|
center.poll()
|
||||||
|
assert [item.id for item in center.pending] == ["a1"]
|
||||||
|
center.poll()
|
||||||
|
# 同一条通知重复下发也只留一张卡片,新的排在最前面。
|
||||||
|
assert [item.id for item in center.pending] == ["a2", "a1"]
|
||||||
|
assert repository.calls == 2
|
||||||
|
assert center.isVisible() is False or len(center.pending) == 2
|
||||||
|
|
||||||
|
center.dismiss("a1")
|
||||||
|
assert [item.id for item in center.pending] == ["a2"]
|
||||||
|
center.clear()
|
||||||
|
assert center.pending == []
|
||||||
|
host.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_center_keeps_only_the_newest_five_cards(
|
||||||
|
application: QApplication,
|
||||||
|
immediate_async: None,
|
||||||
|
) -> None:
|
||||||
|
host = QWidget()
|
||||||
|
repository = _NotifyRepository([_row(f"n{index}") for index in range(8)])
|
||||||
|
center = ChatNotificationCenter(repository, host)
|
||||||
|
|
||||||
|
center.poll()
|
||||||
|
|
||||||
|
assert [item.id for item in center.pending] == ["n7", "n6", "n5", "n4", "n3"]
|
||||||
|
host.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_activating_a_card_emits_it_once_and_removes_it(
|
||||||
|
application: QApplication,
|
||||||
|
immediate_async: None,
|
||||||
|
) -> None:
|
||||||
|
host = QWidget()
|
||||||
|
repository = _NotifyRepository([_row("a1", CONSULTATION_COMPLETE, diagnosis_id=8169)])
|
||||||
|
center = ChatNotificationCenter(repository, host)
|
||||||
|
activated: list[Any] = []
|
||||||
|
center.notification_activated.connect(activated.append)
|
||||||
|
|
||||||
|
center.poll()
|
||||||
|
card = next(iter(center._cards.values()))
|
||||||
|
card.open_button.click()
|
||||||
|
|
||||||
|
assert [item.diagnosis_id for item in activated] == [8169]
|
||||||
|
assert center.pending == []
|
||||||
|
host.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_center_stays_silent_when_the_source_cannot_answer(
|
||||||
|
application: QApplication,
|
||||||
|
immediate_async: None,
|
||||||
|
) -> None:
|
||||||
|
class _Failing:
|
||||||
|
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||||
|
raise RuntimeError("服务暂时不可用")
|
||||||
|
|
||||||
|
host = QWidget()
|
||||||
|
center = ChatNotificationCenter(_Failing(), host)
|
||||||
|
center.poll()
|
||||||
|
assert center.pending == []
|
||||||
|
|
||||||
|
# 演示仓储与不支持该接口的数据源都不应该报错。
|
||||||
|
ChatNotificationCenter(DemoDoctorRepository(), host).poll()
|
||||||
|
ChatNotificationCenter(object(), host).poll()
|
||||||
|
host.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingClient:
|
||||||
|
def __init__(self, payload: Any) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.get_calls.append((endpoint, dict(params or {})))
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_consumes_the_same_admin_endpoint() -> None:
|
||||||
|
client = _RecordingClient([_row("a1")])
|
||||||
|
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
rows = repository.list_chat_notifications()
|
||||||
|
|
||||||
|
assert client.get_calls == [("chat/notifications", {})]
|
||||||
|
assert [row["id"] for row in rows] == ["a1"]
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from doctor_workstation import config as config_module
|
||||||
from doctor_workstation.config import AppConfig, normalize_api_base_url
|
from doctor_workstation.config import AppConfig, normalize_api_base_url
|
||||||
|
|
||||||
|
|
||||||
@@ -36,8 +38,86 @@ def test_config_update_validates_video_mode() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_config_update_normalizes_ssl_boolean_strings() -> None:
|
def test_config_update_normalizes_ssl_boolean_strings() -> None:
|
||||||
assert AppConfig().with_updates(verify_ssl="false").verify_ssl is False
|
assert AppConfig(debug_mode=True).with_updates(verify_ssl="false").verify_ssl is False
|
||||||
assert AppConfig(verify_ssl=False).with_updates(verify_ssl="true").verify_ssl is True
|
assert (
|
||||||
|
AppConfig(debug_mode=True, verify_ssl=False)
|
||||||
|
.with_updates(verify_ssl="true")
|
||||||
|
.verify_ssl
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_config_locks_online_server_and_disables_demo(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
config_dir = tmp_path / "config"
|
||||||
|
config_dir.mkdir()
|
||||||
|
(config_dir / "preferences.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"api_base_url": "https://stale.example.test/adminapi",
|
||||||
|
"demo_mode": True,
|
||||||
|
"verify_ssl": False,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(config_module, "DEBUG_MODE", False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
config_module,
|
||||||
|
"ONLINE_API_BASE_URL",
|
||||||
|
"https://prod.example.test",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(config_dir))
|
||||||
|
monkeypatch.setenv("DOCTOR_API_BASE_URL", "https://env.example.test")
|
||||||
|
monkeypatch.setenv("DOCTOR_DEMO_MODE", "true")
|
||||||
|
monkeypatch.setenv("DOCTOR_VERIFY_SSL", "false")
|
||||||
|
|
||||||
|
config = AppConfig.load()
|
||||||
|
|
||||||
|
assert config.debug_mode is False
|
||||||
|
assert config.api_base_url == "https://prod.example.test/adminapi"
|
||||||
|
assert config.demo_mode is False
|
||||||
|
assert config.verify_ssl is True
|
||||||
|
updated = config.with_updates(
|
||||||
|
api_base_url="https://changed.example.test",
|
||||||
|
demo_mode=True,
|
||||||
|
verify_ssl=False,
|
||||||
|
)
|
||||||
|
assert updated.api_base_url == config.api_base_url
|
||||||
|
assert updated.demo_mode is False
|
||||||
|
assert updated.verify_ssl is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_config_keeps_environment_server_controls(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(config_module, "DEBUG_MODE", True)
|
||||||
|
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(tmp_path / "config"))
|
||||||
|
monkeypatch.setenv("DOCTOR_API_BASE_URL", "http://127.0.0.1:8080")
|
||||||
|
monkeypatch.setenv("DOCTOR_DEMO_MODE", "true")
|
||||||
|
monkeypatch.setenv("DOCTOR_VERIFY_SSL", "false")
|
||||||
|
|
||||||
|
config = AppConfig.load()
|
||||||
|
|
||||||
|
assert config.debug_mode is True
|
||||||
|
assert config.api_base_url == "http://127.0.0.1:8080/adminapi"
|
||||||
|
assert config.demo_mode is True
|
||||||
|
assert config.verify_ssl is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_config_rejects_empty_online_domain(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(config_module, "DEBUG_MODE", False)
|
||||||
|
monkeypatch.setattr(config_module, "ONLINE_API_BASE_URL", "")
|
||||||
|
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(tmp_path / "config"))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="ONLINE_API_BASE_URL 不能为空"):
|
||||||
|
AppConfig.load()
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_directories_can_be_isolated_without_replacing_user_home(
|
def test_runtime_directories_can_be_isolated_without_replacing_user_home(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtCore import QPoint, Qt
|
from PySide6.QtCore import QPoint, Qt
|
||||||
from PySide6.QtGui import QImage
|
from PySide6.QtGui import QImage, QTextCursor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -29,6 +29,7 @@ from doctor_workstation.ui.diagnosis_drawer import (
|
|||||||
DiagnosisLineEdit,
|
DiagnosisLineEdit,
|
||||||
DiagnosisNumberEdit,
|
DiagnosisNumberEdit,
|
||||||
DiagnosisSwitch,
|
DiagnosisSwitch,
|
||||||
|
ExpandableDiagnosisTextEdit,
|
||||||
SaveStateButton,
|
SaveStateButton,
|
||||||
)
|
)
|
||||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||||
@@ -638,6 +639,23 @@ def test_readonly_is_an_independent_vertical_page_flow(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_scroll_keeps_close_controls_reachable(application: QApplication) -> None:
|
||||||
|
dialog = _open_dialog(application, (760, 520), mode="readonly")
|
||||||
|
header_before = dialog.readonly_header.geometry()
|
||||||
|
scroll_bar = dialog.readonly_scroll.verticalScrollBar()
|
||||||
|
|
||||||
|
assert scroll_bar.maximum() > 0
|
||||||
|
assert dialog.readonly_close_button.isVisibleTo(dialog)
|
||||||
|
scroll_bar.setValue(scroll_bar.maximum())
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert dialog.readonly_header.geometry() == header_before
|
||||||
|
assert dialog.readonly_close_button.isVisibleTo(dialog)
|
||||||
|
dialog.readonly_close_button.click()
|
||||||
|
application.processEvents()
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||||
@pytest.mark.parametrize("mode", ["edit", "viewOnly"])
|
@pytest.mark.parametrize("mode", ["edit", "viewOnly"])
|
||||||
def test_drawer_is_full_height_rtl_and_sixty_percent_wide(
|
def test_drawer_is_full_height_rtl_and_sixty_percent_wide(
|
||||||
@@ -751,6 +769,186 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
|
|||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _settle_text_layout(application: QApplication) -> None:
|
||||||
|
# Editor measurement and the containing scroll area's layout are deferred.
|
||||||
|
for _ in range(12):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||||
|
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text",
|
||||||
|
[
|
||||||
|
"这是一段用于验证自动换行的测试记录。" * 40,
|
||||||
|
"\n".join(f"第{index + 1}条:测试记录,保留原始换行。" for index in range(18)),
|
||||||
|
"LongUnbrokenTestValue" * 80,
|
||||||
|
],
|
||||||
|
ids=["wrapped-chinese", "multiline", "unbroken-text"],
|
||||||
|
)
|
||||||
|
def test_long_diagnosis_fields_expand_inline_without_inner_scrolling(
|
||||||
|
application: QApplication, key: str, size: tuple[int, int], text: str
|
||||||
|
) -> None:
|
||||||
|
repository = VisualRepository()
|
||||||
|
repository.detail["diagnosis"][key] = text
|
||||||
|
dialog = _open_dialog(application, size, mode="edit", repository=repository)
|
||||||
|
try:
|
||||||
|
_settle_text_layout(application)
|
||||||
|
editor = dialog.edit_fields[key]
|
||||||
|
assert isinstance(editor, ExpandableDiagnosisTextEdit)
|
||||||
|
assert editor.height() == 72
|
||||||
|
assert editor.expand_button.isVisibleTo(dialog)
|
||||||
|
assert not editor.expand_button.autoDefault()
|
||||||
|
assert editor.expand_button.text() == "展开全部"
|
||||||
|
body = dialog.findChild(QScrollArea, "DiagnosisDrawerBody")
|
||||||
|
footer = dialog.findChild(QFrame, "DiagnosisDrawerFooter")
|
||||||
|
footer_before = footer.geometry()
|
||||||
|
outer_range_before = body.verticalScrollBar().maximum()
|
||||||
|
horizontal_range_before = body.horizontalScrollBar().maximum()
|
||||||
|
editor.verticalScrollBar().setValue(editor.verticalScrollBar().maximum())
|
||||||
|
|
||||||
|
editor.expand_button.click()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.expand_button.text() == "收起"
|
||||||
|
assert editor.height() > 72
|
||||||
|
assert editor.sizeHint().height() == editor.height()
|
||||||
|
assert not editor._height_sync.isActive()
|
||||||
|
assert editor.verticalScrollBar().maximum() == 0
|
||||||
|
assert editor.horizontalScrollBar().maximum() == 0
|
||||||
|
assert not editor.verticalScrollBar().isVisibleTo(dialog)
|
||||||
|
last_block = editor.blockBoundingGeometry(editor.document().lastBlock())
|
||||||
|
assert last_block.translated(editor.contentOffset()).bottom() <= editor.viewport().height()
|
||||||
|
assert body.verticalScrollBar().maximum() > outer_range_before
|
||||||
|
# The existing narrow form has a small minimum-width overflow; expanding
|
||||||
|
# these fields must not increase it or clip their right edge.
|
||||||
|
assert body.horizontalScrollBar().maximum() == horizontal_range_before
|
||||||
|
assert editor.mapTo(body.viewport(), QPoint(editor.width(), 0)).x() <= body.viewport().width()
|
||||||
|
assert footer.geometry() == footer_before
|
||||||
|
assert editor.toPlainText() == text
|
||||||
|
assert not repository.updates
|
||||||
|
|
||||||
|
editor.expand_button.click()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.height() == 72
|
||||||
|
assert editor.toPlainText() == text
|
||||||
|
assert editor.expand_button.text() == "展开全部"
|
||||||
|
assert not editor._height_sync.isActive()
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||||
|
def test_expanded_diagnosis_text_reflows_on_resize_and_keeps_edit_save_contract(
|
||||||
|
application: QApplication, key: str
|
||||||
|
) -> None:
|
||||||
|
repository = VisualRepository()
|
||||||
|
original = "自动换行测试内容,保持完整文本。" * 30
|
||||||
|
repository.detail["diagnosis"][key] = original
|
||||||
|
dialog = _open_dialog(application, (1440, 900), mode="edit", repository=repository)
|
||||||
|
try:
|
||||||
|
_settle_text_layout(application)
|
||||||
|
editor = dialog.edit_fields[key]
|
||||||
|
editor.expand_button.click()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
wide_height = editor.height()
|
||||||
|
dialog.resize(1024, 640)
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.height() > wide_height
|
||||||
|
assert editor.verticalScrollBar().maximum() == 0
|
||||||
|
dialog.resize(1440, 900)
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.height() == wide_height
|
||||||
|
|
||||||
|
editor.moveCursor(QTextCursor.MoveOperation.End)
|
||||||
|
added = "\n新增测试记录,保存时不能截断。" * 12
|
||||||
|
editor.insertPlainText(added)
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.height() > wide_height
|
||||||
|
assert editor.verticalScrollBar().maximum() == 0
|
||||||
|
cursor_position = editor.textCursor().position()
|
||||||
|
editor.expand_button.click()
|
||||||
|
editor.expand_button.click()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.textCursor().position() == cursor_position
|
||||||
|
editor.undo()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.toPlainText() == original
|
||||||
|
assert editor.height() == wide_height
|
||||||
|
editor.redo()
|
||||||
|
editor.expand_button.click()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
dialog._save()
|
||||||
|
assert repository.updates[-1][key] == original + added
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||||
|
def test_short_and_cleared_diagnosis_text_keep_compact_layout(
|
||||||
|
application: QApplication, key: str
|
||||||
|
) -> None:
|
||||||
|
dialog = _open_dialog(application, (1024, 640), mode="edit")
|
||||||
|
try:
|
||||||
|
editor = dialog.edit_fields[key]
|
||||||
|
for text in ("", "简短测试记录"):
|
||||||
|
editor.setPlainText(text)
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.height() == 72
|
||||||
|
assert not editor.expand_button.isVisibleTo(dialog)
|
||||||
|
editor.insertPlainText("\n很长的测试记录" * 20)
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.expand_button.isVisibleTo(dialog)
|
||||||
|
editor.expand_button.click()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
editor.selectAll()
|
||||||
|
editor.insertPlainText("")
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.toPlainText() == ""
|
||||||
|
assert editor.height() == 72
|
||||||
|
assert editor.expand_button.text() == "收起"
|
||||||
|
editor.expand_button.click()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert not editor.expand_button.isVisibleTo(dialog)
|
||||||
|
assert not isinstance(dialog.edit_fields["remark"], ExpandableDiagnosisTextEdit)
|
||||||
|
assert not isinstance(dialog.edit_fields["present_illness"], ExpandableDiagnosisTextEdit)
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["symptoms", "current_medications"])
|
||||||
|
def test_readonly_diagnosis_text_can_expand_and_reopen_starts_collapsed(
|
||||||
|
application: QApplication, key: str
|
||||||
|
) -> None:
|
||||||
|
repository = VisualRepository()
|
||||||
|
text = "只读长文本测试\n" * 20
|
||||||
|
repository.detail["diagnosis"][key] = text
|
||||||
|
dialog = _open_dialog(application, (1024, 640), mode="viewOnly", repository=repository)
|
||||||
|
try:
|
||||||
|
_settle_text_layout(application)
|
||||||
|
editor = dialog.edit_fields[key]
|
||||||
|
assert editor.isReadOnly()
|
||||||
|
assert editor.expand_button.isEnabled()
|
||||||
|
editor.expand_button.click()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.height() > 72
|
||||||
|
assert editor.verticalScrollBar().maximum() == 0
|
||||||
|
assert editor.isReadOnly()
|
||||||
|
assert not dialog.save_button.isVisibleTo(dialog)
|
||||||
|
assert not repository.updates
|
||||||
|
dialog.close()
|
||||||
|
dialog.open_view_only(501)
|
||||||
|
_settle_text_layout(application)
|
||||||
|
assert editor.height() == 72
|
||||||
|
assert not editor.expand_button.isChecked()
|
||||||
|
assert editor.toPlainText() == text
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
_settle_text_layout(application)
|
||||||
|
|
||||||
|
|
||||||
def test_hpi_choice_chips_are_visible_after_dictionary_load(
|
def test_hpi_choice_chips_are_visible_after_dictionary_load(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from doctor_workstation.ui.diagnosis_drawer import (
|
|||||||
NotesTimeline,
|
NotesTimeline,
|
||||||
_RemoteImageButton,
|
_RemoteImageButton,
|
||||||
)
|
)
|
||||||
|
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
@@ -112,7 +113,6 @@ def test_remote_image_request_is_thread_owned_and_rejects_stale_results(
|
|||||||
object_name="DiagnosisTongueThumb",
|
object_name="DiagnosisTongueThumb",
|
||||||
parent=owner,
|
parent=owner,
|
||||||
)
|
)
|
||||||
button._manager.deleteLater()
|
|
||||||
manager = _FakeManager(button)
|
manager = _FakeManager(button)
|
||||||
button._manager = manager
|
button._manager = manager
|
||||||
manager.queue(_png_bytes(180, 90, "#DC2626"))
|
manager.queue(_png_bytes(180, 90, "#DC2626"))
|
||||||
@@ -160,7 +160,6 @@ def test_remote_image_uses_text_only_after_request_or_decode_failure(
|
|||||||
object_name="DiagnosisChatImage",
|
object_name="DiagnosisChatImage",
|
||||||
parent=owner,
|
parent=owner,
|
||||||
)
|
)
|
||||||
button._manager.deleteLater()
|
|
||||||
manager = _FakeManager(button)
|
manager = _FakeManager(button)
|
||||||
button._manager = manager
|
button._manager = manager
|
||||||
manager.queue(b"not-an-image")
|
manager.queue(b"not-an-image")
|
||||||
@@ -193,7 +192,6 @@ def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_downloa
|
|||||||
object_name="DiagnosisTongueThumb",
|
object_name="DiagnosisTongueThumb",
|
||||||
parent=owner,
|
parent=owner,
|
||||||
)
|
)
|
||||||
button._manager.deleteLater()
|
|
||||||
manager = _FakeManager(button)
|
manager = _FakeManager(button)
|
||||||
button._manager = manager
|
button._manager = manager
|
||||||
manager.queue(b"must-not-be-read")
|
manager.queue(b"must-not-be-read")
|
||||||
@@ -218,6 +216,110 @@ def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_downloa
|
|||||||
assert application.thread() == button.thread()
|
assert application.thread() == button.thread()
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_with_offline_transport(
|
||||||
|
sources: list[str],
|
||||||
|
*,
|
||||||
|
index: int = 0,
|
||||||
|
names: list[str] | None = None,
|
||||||
|
) -> tuple[ImagePreviewDialog, _FakeManager]:
|
||||||
|
"""Build a preview window whose downloads are driven by the test, not the network."""
|
||||||
|
|
||||||
|
original_request = ImagePreviewDialog._request
|
||||||
|
ImagePreviewDialog._request = lambda self, target: None # type: ignore[method-assign]
|
||||||
|
try:
|
||||||
|
dialog = ImagePreviewDialog(sources, index=index, names=names)
|
||||||
|
finally:
|
||||||
|
ImagePreviewDialog._request = original_request # type: ignore[method-assign]
|
||||||
|
manager = _FakeManager(dialog)
|
||||||
|
dialog._manager = manager
|
||||||
|
return dialog, manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_preview_pages_the_group_in_app_and_reuses_decoded_images(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
dialog, manager = _preview_with_offline_transport(
|
||||||
|
[
|
||||||
|
"https://media.example.invalid/tongue-1.jpg",
|
||||||
|
"file:///C:/private/tongue.jpg",
|
||||||
|
"https://media.example.invalid/tongue-2.jpg",
|
||||||
|
],
|
||||||
|
index=2,
|
||||||
|
names=["舌象附件 1", "本地危险附件", "舌象附件 2"],
|
||||||
|
)
|
||||||
|
# file:// 附件既不进入分组,也不会发起任何请求。
|
||||||
|
assert dialog.sources == [
|
||||||
|
"https://media.example.invalid/tongue-1.jpg",
|
||||||
|
"https://media.example.invalid/tongue-2.jpg",
|
||||||
|
]
|
||||||
|
assert dialog.current_source == "https://media.example.invalid/tongue-2.jpg"
|
||||||
|
assert dialog.counter.text() == "第 2 / 2 张"
|
||||||
|
assert dialog.name_label.text() == "舌象附件 2"
|
||||||
|
|
||||||
|
manager.queue(_png_bytes(320, 200, "#DC2626"))
|
||||||
|
dialog.reload_current()
|
||||||
|
request = manager.request_objects[-1]
|
||||||
|
assert request.attribute(QNetworkRequest.Attribute.RedirectPolicyAttribute) == (
|
||||||
|
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy
|
||||||
|
)
|
||||||
|
manager.replies[-1].finished.emit()
|
||||||
|
assert dialog.canvas.text() == ""
|
||||||
|
assert not dialog.canvas.pixmap().isNull()
|
||||||
|
|
||||||
|
manager.queue(_png_bytes(120, 90, "#16A34A"))
|
||||||
|
dialog.step(1)
|
||||||
|
assert dialog.current_source == "https://media.example.invalid/tongue-1.jpg"
|
||||||
|
manager.replies[-1].finished.emit()
|
||||||
|
assert not dialog.canvas.pixmap().isNull()
|
||||||
|
|
||||||
|
dialog.step(1)
|
||||||
|
assert dialog.current_source == "https://media.example.invalid/tongue-2.jpg"
|
||||||
|
assert manager.requests == [
|
||||||
|
"https://media.example.invalid/tongue-2.jpg",
|
||||||
|
"https://media.example.invalid/tongue-1.jpg",
|
||||||
|
]
|
||||||
|
assert not dialog.canvas.pixmap().isNull()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_preview_aborts_oversize_and_falls_back_on_undecodable_payload(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
dialog, manager = _preview_with_offline_transport(
|
||||||
|
["https://media.example.invalid/tongue.jpg"]
|
||||||
|
)
|
||||||
|
manager.queue(b"must-not-be-read")
|
||||||
|
dialog.reload_current()
|
||||||
|
reply = manager.replies[-1]
|
||||||
|
reply.downloadProgress.emit(dialog._MAX_IMAGE_BYTES, -1)
|
||||||
|
assert reply.aborted is False
|
||||||
|
reply.downloadProgress.emit(dialog._MAX_IMAGE_BYTES + 1, -1)
|
||||||
|
assert reply.aborted is True
|
||||||
|
reply.finished.emit()
|
||||||
|
assert reply.read_all_calls == 0
|
||||||
|
assert "12 MB" in dialog.canvas.text()
|
||||||
|
|
||||||
|
manager.queue(b"not-an-image")
|
||||||
|
dialog.reload_current()
|
||||||
|
manager.replies[-1].finished.emit()
|
||||||
|
assert dialog.canvas.pixmap().isNull()
|
||||||
|
assert "无法在工作站内预览" in dialog.canvas.text()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_preview_refuses_a_group_without_any_safe_http_source(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
dialog = ImagePreviewDialog(["file:///C:/private/tongue.jpg", ""])
|
||||||
|
assert dialog.has_images() is False
|
||||||
|
assert dialog.sources == []
|
||||||
|
assert dialog.current_source == ""
|
||||||
|
assert not dialog.external_button.isEnabled()
|
||||||
|
assert not dialog.next_button.isEnabled()
|
||||||
|
assert "HTTP(S)" in dialog.canvas.text()
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""诊单字典 / 枚举 / 时间戳翻译的契约。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||||
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
|
from doctor_workstation.ui.diagnosis_terms import (
|
||||||
|
DICTIONARY_TYPES,
|
||||||
|
MULTI_VALUE_DICTIONARIES,
|
||||||
|
SINGLE_VALUE_DICTIONARIES,
|
||||||
|
TermIndex,
|
||||||
|
format_timestamp,
|
||||||
|
unit_suffix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_dictionary_translates_codes_admin_shows_in_chinese() -> None:
|
||||||
|
terms = TermIndex()
|
||||||
|
|
||||||
|
assert terms.dictionary_label("appetite", "dry,bitter") == "干、苦"
|
||||||
|
assert terms.dictionary_label("appetite", ["dry", "greasy"]) == "干、腻"
|
||||||
|
assert terms.dictionary_label("weight_change", "lose_10_jin") == "瘦10斤"
|
||||||
|
assert terms.dictionary_label("fatty_liver_degree", "mild") == "轻度"
|
||||||
|
assert terms.dictionary_label("past_history", "hypertension、diabetes") == "高血压、糖尿病"
|
||||||
|
# 同一个 code 在不同字典里含义不同,翻译必须按字段所属字典走。
|
||||||
|
assert terms.dictionary_label("skin_condition", "dry") == "干燥"
|
||||||
|
assert terms.dictionary_label("eye_condition", "dry") == "干涩"
|
||||||
|
# 不在字典里的自定义值回显原值,不会被吞掉。
|
||||||
|
assert terms.dictionary_label("appetite", "自定义症状") == "自定义症状"
|
||||||
|
assert terms.dictionary_label("remark", "任意文本") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_text_field_wins_over_dictionary_and_raw_value() -> None:
|
||||||
|
terms = TermIndex()
|
||||||
|
|
||||||
|
assert terms.display({"appetite": "dry", "appetite_text": "口干"}, "appetite") == "口干"
|
||||||
|
assert terms.display({"appetite": "dry"}, "appetite") == "干"
|
||||||
|
assert terms.display({}, "appetite", default="未记录") == "未记录"
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_dictionary_overrides_the_bundled_seed() -> None:
|
||||||
|
terms = TermIndex()
|
||||||
|
terms.merge({"appetite": [{"name": "口干", "value": "dry"}]})
|
||||||
|
|
||||||
|
assert terms.dictionary_label("appetite", "dry") == "口干"
|
||||||
|
# 实时字典没覆盖到的条目继续用种子。
|
||||||
|
assert terms.dictionary_label("appetite", "bitter") == "苦"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_and_timestamp_fields_render_like_the_admin_readonly_page() -> None:
|
||||||
|
terms = TermIndex()
|
||||||
|
|
||||||
|
assert terms.value_label("gender", 1) == "男"
|
||||||
|
assert terms.value_label("gender", "0") == "女"
|
||||||
|
assert terms.value_label("marital_status", "1") == "已婚"
|
||||||
|
assert terms.value_label("allergy_history", "0") == "无"
|
||||||
|
assert terms.value_label("family_history", 1) == "有"
|
||||||
|
assert terms.value_label("diagnosis_type", "follow_up") == "复诊"
|
||||||
|
assert terms.value_label("create_source", "admin") == "后台创建"
|
||||||
|
assert terms.value_label("source", "1") == "患者自录"
|
||||||
|
assert terms.value_label("create_time", 1783838927) == format_timestamp(1783838927)
|
||||||
|
assert format_timestamp(1783838927) is not None
|
||||||
|
assert format_timestamp("2026-08-18 09:20") is None
|
||||||
|
assert format_timestamp(0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_only_decorate_numeric_readonly_values() -> None:
|
||||||
|
assert unit_suffix("height", "162") == " cm"
|
||||||
|
assert unit_suffix("fasting_blood_sugar", "8.2") == " mmol/L"
|
||||||
|
assert unit_suffix("diabetes_discovery_year", "6") == "年"
|
||||||
|
# 自由文本("17多"、"五年")不补单位,避免拼出错误的读数。
|
||||||
|
assert unit_suffix("fasting_blood_sugar", "17多") == ""
|
||||||
|
assert unit_suffix("diabetes_discovery_year", "五年") == ""
|
||||||
|
assert unit_suffix("remark", "123") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_dictionary_types_cover_every_field_the_backend_translates() -> None:
|
||||||
|
# 与 AppointmentLogic::enrichDiagnosisLabels 的字段表保持同步。
|
||||||
|
assert set(SINGLE_VALUE_DICTIONARIES) == {
|
||||||
|
"diagnosis_type",
|
||||||
|
"syndrome_type",
|
||||||
|
"diabetes_type",
|
||||||
|
"water_intake",
|
||||||
|
"weight_change",
|
||||||
|
"fatty_liver_degree",
|
||||||
|
}
|
||||||
|
assert set(MULTI_VALUE_DICTIONARIES) == {
|
||||||
|
"past_history",
|
||||||
|
"appetite",
|
||||||
|
"diet_condition",
|
||||||
|
"body_feeling",
|
||||||
|
"sleep_condition",
|
||||||
|
"eye_condition",
|
||||||
|
"head_feeling",
|
||||||
|
"sweat_condition",
|
||||||
|
"skin_condition",
|
||||||
|
"urine_condition",
|
||||||
|
"stool_condition",
|
||||||
|
"kidney_condition",
|
||||||
|
}
|
||||||
|
assert set(DICTIONARY_TYPES) == set(SINGLE_VALUE_DICTIONARIES.values()) | set(
|
||||||
|
MULTI_VALUE_DICTIONARIES.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _RecordingClient:
|
||||||
|
def __init__(self, payload: Any) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.get_calls.append((endpoint, dict(params or {})))
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_batches_every_dictionary_into_one_request() -> None:
|
||||||
|
client = _RecordingClient(
|
||||||
|
{
|
||||||
|
"appetite": [{"name": "口干", "value": "dry"}],
|
||||||
|
"weight_change": [{"name": "瘦10斤", "value": "lose_10_jin"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
dictionaries = repository.get_dictionaries(["appetite", "weight_change", "appetite", ""])
|
||||||
|
|
||||||
|
assert client.get_calls == [("config/dict", {"type": "appetite,weight_change"})]
|
||||||
|
assert list(dictionaries) == ["appetite", "weight_change"]
|
||||||
|
terms = TermIndex()
|
||||||
|
terms.merge(dictionaries)
|
||||||
|
assert terms.dictionary_label("appetite", "dry") == "口干"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("dictionary_type", DICTIONARY_TYPES)
|
||||||
|
def test_demo_repository_answers_the_batch_dictionary_contract(dictionary_type: str) -> None:
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
|
||||||
|
dictionaries = repository.get_dictionaries(DICTIONARY_TYPES)
|
||||||
|
|
||||||
|
assert dictionary_type in dictionaries
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from doctor_workstation import __version__
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +12,30 @@ def read(relative_path: str) -> str:
|
|||||||
return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
|
return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_version_has_one_source() -> None:
|
||||||
|
pyproject = tomllib.loads(read("pyproject.toml"))
|
||||||
|
project = pyproject["project"]
|
||||||
|
|
||||||
|
assert "version" not in project
|
||||||
|
assert "version" in project["dynamic"]
|
||||||
|
assert pyproject["tool"]["hatch"]["version"]["path"] == (
|
||||||
|
"src/doctor_workstation/__init__.py"
|
||||||
|
)
|
||||||
|
assert __version__
|
||||||
|
|
||||||
|
version_template = read("packaging/windows/version_info.template.txt")
|
||||||
|
assert "@VERSION_TUPLE@" in version_template
|
||||||
|
assert "@VERSION_STRING@" in version_template
|
||||||
|
assert "0.1.0" not in version_template
|
||||||
|
|
||||||
|
spec = read("packaging/doctor_workstation.spec")
|
||||||
|
windows_package_script = read("scripts/package_windows.ps1")
|
||||||
|
macos_package_script = read("scripts/package_macos.sh")
|
||||||
|
assert 'VERSION_SOURCE = SOURCE_ROOT / "doctor_workstation" / "__init__.py"' in spec
|
||||||
|
assert 'src\\doctor_workstation\\__init__.py' in windows_package_script
|
||||||
|
assert 'src/doctor_workstation/__init__.py' in macos_package_script
|
||||||
|
|
||||||
|
|
||||||
def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
|
def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
|
||||||
for name in (
|
for name in (
|
||||||
"Run_DoctorWorkstation.bat",
|
"Run_DoctorWorkstation.bat",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtWidgets import QApplication, QLabel
|
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
||||||
|
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet
|
||||||
from doctor_workstation.core.errors import ApiTimeoutError
|
from doctor_workstation.core.errors import ApiTimeoutError
|
||||||
@@ -385,7 +385,10 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
|
|||||||
task: str,
|
task: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
calls.append({"diagnosis_id": diagnosis_id, "prompt": prompt, "task": task})
|
calls.append({"diagnosis_id": diagnosis_id, "prompt": prompt, "task": task})
|
||||||
return {"answer": "建议复核肾功能与眼底。", "model_key": "openai"}
|
return {
|
||||||
|
"answer": "### 核心建议\n\n**重点复核**\n\n- 肾功能\n- 眼底",
|
||||||
|
"model_key": "openai",
|
||||||
|
}
|
||||||
|
|
||||||
dialog = DiagnosisAiAssistantDialog(Repository())
|
dialog = DiagnosisAiAssistantDialog(Repository())
|
||||||
dialog.open_for(501, "并发症筛查", task="complication_risk")
|
dialog.open_for(501, "并发症筛查", task="complication_risk")
|
||||||
@@ -393,15 +396,57 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
|
|||||||
assert calls == [
|
assert calls == [
|
||||||
{"diagnosis_id": 501, "prompt": "并发症筛查", "task": "complication_risk"}
|
{"diagnosis_id": 501, "prompt": "并发症筛查", "task": "complication_risk"}
|
||||||
]
|
]
|
||||||
assert dialog.answer_label.text() == "建议复核肾功能与眼底。"
|
assert dialog.answer_label.toPlainText() == "核心建议\n重点复核\n肾功能\n眼底"
|
||||||
|
assert "###" not in dialog.answer_label.toPlainText()
|
||||||
|
assert "**" not in dialog.answer_label.toPlainText()
|
||||||
|
rendered_html = dialog.answer_label.toHtml().lower()
|
||||||
|
assert "<h3" in rendered_html
|
||||||
|
assert "font-weight:700" in rendered_html.replace(" ", "")
|
||||||
assert "openai" in dialog.model_label.text()
|
assert "openai" in dialog.model_label.text()
|
||||||
assert dialog.answer_scroll.widget().findChild(QLabel, "PrescriptionAiBody") is dialog.answer_label
|
assert isinstance(dialog.answer_label, QTextBrowser)
|
||||||
|
assert dialog.answer_label.objectName() == "PrescriptionAiAnswer"
|
||||||
|
assert dialog.answer_label.openLinks() is False
|
||||||
|
assert dialog.answer_label.openExternalLinks() is False
|
||||||
assert dialog.loading is False
|
assert dialog.loading is False
|
||||||
assert dialog.retry_button.isEnabled()
|
assert dialog.retry_button.isEnabled()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_assistant_markdown_disables_model_supplied_html(
|
||||||
|
application: QApplication,
|
||||||
|
immediate_async: None,
|
||||||
|
) -> None:
|
||||||
|
class Repository:
|
||||||
|
def analyze_diagnosis_ai(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"answer": "### 安全内容\n\n<img src=\"https://invalid.example/pixel\">\n\n- 建议复诊",
|
||||||
|
"model_key": "qwen",
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog = DiagnosisAiAssistantDialog(Repository())
|
||||||
|
dialog.open_for(501, "复诊建议", task="custom")
|
||||||
|
|
||||||
|
assert "安全内容" in dialog.answer_label.toPlainText()
|
||||||
|
assert "建议复诊" in dialog.answer_label.toPlainText()
|
||||||
|
assert "<img" in dialog.answer_label.toPlainText()
|
||||||
|
assert 'src="https://invalid.example/pixel"' not in dialog.answer_label.toHtml()
|
||||||
|
assert (
|
||||||
|
dialog.answer_label.loadResource(
|
||||||
|
ai_module.QTextDocument.ResourceType.ImageResource,
|
||||||
|
"https://invalid.example/pixel",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
dialog.answer_label.selectAll()
|
||||||
|
copied = dialog.answer_label.createMimeDataFromSelection()
|
||||||
|
assert copied.hasText()
|
||||||
|
assert copied.hasHtml() is False
|
||||||
|
assert set(copied.formats()) == {"text/plain"}
|
||||||
|
dialog.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_diagnosis_assistant_timeout_is_visible_and_retryable(
|
def test_diagnosis_assistant_timeout_is_visible_and_retryable(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
from PySide6.QtWidgets import QApplication, QStyleOptionViewItem
|
||||||
|
|
||||||
|
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||||
|
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage, _order_warnings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def run_immediately(
|
||||||
|
function: Any,
|
||||||
|
*args: Any,
|
||||||
|
on_success: Any = None,
|
||||||
|
on_error: Any = None,
|
||||||
|
on_finished: Any = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> object:
|
||||||
|
try:
|
||||||
|
result = function(*args, **kwargs)
|
||||||
|
except Exception as error:
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
|
||||||
|
|
||||||
|
|
||||||
|
def row(
|
||||||
|
record_id: int,
|
||||||
|
number: str,
|
||||||
|
*,
|
||||||
|
has_order: Any,
|
||||||
|
herbs: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": record_id,
|
||||||
|
"sn": number,
|
||||||
|
"patient_name": "列表提示测试患者",
|
||||||
|
"gender": 1,
|
||||||
|
"age": 45,
|
||||||
|
"prescription_type": "浓缩水丸",
|
||||||
|
"is_system_auto": 1,
|
||||||
|
"audit_status": 0,
|
||||||
|
"void_status": 0,
|
||||||
|
"has_prescription_order": has_order,
|
||||||
|
"herbs": herbs,
|
||||||
|
"doctor_name": "测试医生",
|
||||||
|
"assistant_name": "测试医助",
|
||||||
|
"create_time": "2026-09-01 09:30:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
|
||||||
|
def list_diagnosis_doctors(self) -> list[Any]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||||
|
return {"lists": self.rows, "count": len(self.rows)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_warnings_exactly_match_pc_rules() -> None:
|
||||||
|
assert _order_warnings(row(1, "RX-1", has_order=0, herbs=[])) == []
|
||||||
|
assert _order_warnings(row(2, "RX-2", has_order=1, herbs=[])) == ["请开方,当前处方药材为空白"]
|
||||||
|
assert _order_warnings(
|
||||||
|
row(3, "RX-3", has_order="1.0", herbs=[{}, {"name": ""}, {"name": " "}])
|
||||||
|
) == ["请开方,当前处方药材为空白"]
|
||||||
|
assert _order_warnings(
|
||||||
|
row(
|
||||||
|
4,
|
||||||
|
"RX-4",
|
||||||
|
has_order=1,
|
||||||
|
herbs=[{"name": "黄芪"}, {"name": " 黄 芪 "}, {"name": "党参"}],
|
||||||
|
)
|
||||||
|
) == ["已有关联业务订单,当前处方存在重复药材:黄 芪"]
|
||||||
|
# The PC implementation only reads the canonical `name` field.
|
||||||
|
assert _order_warnings(row(5, "RX-5", has_order=1, herbs=[{"medicine_name": "黄芪"}])) == [
|
||||||
|
"请开方,当前处方药材为空白"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_column_renders_sn_id_and_visible_warning_with_dynamic_height(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
rows = [
|
||||||
|
row(11, "RX-NORMAL", has_order=0, herbs=[]),
|
||||||
|
row(22, "RX-BLANK", has_order=1, herbs=[{"name": ""}]),
|
||||||
|
row(
|
||||||
|
33,
|
||||||
|
"RX-DUPLICATE",
|
||||||
|
has_order=1,
|
||||||
|
herbs=[{"name": "黄芪"}, {"name": "黄 芪"}],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
page = PrescriptionsPage(Repository(rows), {"*"}, SimpleNamespace(id=7, name="测试医生"))
|
||||||
|
page.resize(1366, 768)
|
||||||
|
page.show()
|
||||||
|
page.refresh()
|
||||||
|
for _ in range(6):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
rendered: dict[int, tuple[int, str, str]] = {}
|
||||||
|
for visual_row in range(page.table.rowCount()):
|
||||||
|
item = page.table.item(visual_row, 1)
|
||||||
|
source = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
rendered[source["id"]] = (
|
||||||
|
page.table.rowHeight(visual_row),
|
||||||
|
item.toolTip(),
|
||||||
|
item.data(Qt.ItemDataRole.AccessibleTextRole),
|
||||||
|
)
|
||||||
|
|
||||||
|
normal_height, normal_tip, normal_accessible = rendered[11]
|
||||||
|
blank_height, blank_tip, blank_accessible = rendered[22]
|
||||||
|
duplicate_height, duplicate_tip, _duplicate_accessible = rendered[33]
|
||||||
|
assert normal_tip == normal_accessible == "RX-NORMAL\nID: 11"
|
||||||
|
assert "请开方,当前处方药材为空白" in blank_tip == blank_accessible
|
||||||
|
assert "已有关联业务订单,当前处方存在重复药材:黄 芪" in duplicate_tip
|
||||||
|
assert blank_height > normal_height
|
||||||
|
assert duplicate_height > normal_height
|
||||||
|
assert page.table.columnWidth(1) >= 250
|
||||||
|
|
||||||
|
image = page.table.viewport().grab().toImage().convertToFormat(QImage.Format.Format_RGB32)
|
||||||
|
red_pixels = 0
|
||||||
|
for y in range(image.height()):
|
||||||
|
for x in range(image.width()):
|
||||||
|
color = image.pixelColor(x, y)
|
||||||
|
if color.red() > 170 and color.green() < 90 and color.blue() < 100:
|
||||||
|
red_pixels += 1
|
||||||
|
assert red_pixels > 40
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_warning_survives_sort_and_column_resize(application: QApplication) -> None:
|
||||||
|
rows = [
|
||||||
|
row(41, "RX-Z", has_order=1, herbs=[]),
|
||||||
|
row(42, "RX-A", has_order=0, herbs=[{"name": "黄芪"}]),
|
||||||
|
]
|
||||||
|
page = PrescriptionsPage(Repository(rows), {"*"}, SimpleNamespace(id=7, name="测试医生"))
|
||||||
|
page.resize(1200, 700)
|
||||||
|
page.show()
|
||||||
|
page.refresh()
|
||||||
|
page.table.sortItems(1, Qt.SortOrder.AscendingOrder)
|
||||||
|
page.table.setColumnWidth(1, 190)
|
||||||
|
for _ in range(5):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert page.table.item(0, 1).text() == "RX-A"
|
||||||
|
assert page.table.item(1, 1).text() == "RX-Z"
|
||||||
|
assert page.table.item(1, 1).data(Qt.ItemDataRole.UserRole)["id"] == 41
|
||||||
|
assert "请开方,当前处方药材为空白" in page.table.item(1, 1).toolTip()
|
||||||
|
assert page.table.rowHeight(1) > page.table.rowHeight(0)
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_html_is_escaped_but_remains_readable(application: QApplication) -> None:
|
||||||
|
unsafe_number = "RX-<b>NOT HTML</b>"
|
||||||
|
page = PrescriptionsPage(
|
||||||
|
Repository([row(51, unsafe_number, has_order=1, herbs=[])]),
|
||||||
|
{"*"},
|
||||||
|
SimpleNamespace(id=7, name="测试医生"),
|
||||||
|
)
|
||||||
|
page.refresh()
|
||||||
|
delegate = page.table.itemDelegateForColumn(1)
|
||||||
|
option = QStyleOptionViewItem()
|
||||||
|
option.initFrom(page.table)
|
||||||
|
index = page.table.model().index(0, 1)
|
||||||
|
plain_text = delegate.document(option, index, page.table.columnWidth(1)).toPlainText()
|
||||||
|
assert unsafe_number in plain_text
|
||||||
|
assert "ID: 51" in plain_text
|
||||||
|
assert "请开方,当前处方药材为空白" in plain_text
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
@@ -288,122 +288,121 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_diagnosis_order_detail_lookup_is_queued_before_repository_call(
|
def test_prescription_diagnosis_detail_uses_shared_structured_readonly_ui(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
queued: list[tuple[Any, dict[str, Any]]] = []
|
|
||||||
requested: list[int] = []
|
|
||||||
shown: list[tuple[int, str]] = []
|
|
||||||
|
|
||||||
class Repository:
|
class Repository:
|
||||||
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
|
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||||
requested.append(order_id)
|
assert diagnosis_id == 745
|
||||||
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
|
return []
|
||||||
|
|
||||||
def queue_async(function: Any, **options: Any) -> object:
|
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||||
queued.append((function, options))
|
|
||||||
return object()
|
|
||||||
|
|
||||||
def present_order_detail(
|
|
||||||
_host: Any,
|
|
||||||
order: dict[str, Any],
|
|
||||||
*,
|
|
||||||
order_id: int,
|
|
||||||
permissions: Any,
|
|
||||||
exec_: bool,
|
|
||||||
) -> None:
|
|
||||||
del permissions, exec_
|
|
||||||
shown.append((order_id, order["order_no"]))
|
|
||||||
|
|
||||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
|
||||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
|
||||||
dialog = DiagnosisDetailDialog(
|
dialog = DiagnosisDetailDialog(
|
||||||
{"orders": [{"id": 17, "order_no": "ROW-17"}]},
|
{
|
||||||
|
"diagnosis": {
|
||||||
|
"id": 745,
|
||||||
|
"patient_id": 745,
|
||||||
|
"patient_name": "庄志芳",
|
||||||
|
"phone": "13823549442",
|
||||||
|
"id_card": "440305196701011234",
|
||||||
|
"gender": 0,
|
||||||
|
"age": 59,
|
||||||
|
"chief_complaint": "睡眠不好、出汗多",
|
||||||
|
"past_history": ["高血压", "高脂血症"],
|
||||||
|
},
|
||||||
|
"patient": {"id": 745},
|
||||||
|
},
|
||||||
repository=Repository(),
|
repository=Repository(),
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
|
||||||
)
|
)
|
||||||
table = dialog._order_detail_table
|
|
||||||
button = dialog._order_detail_button
|
|
||||||
assert table is not None
|
|
||||||
assert button is not None
|
|
||||||
table.setCurrentCell(0, 0)
|
|
||||||
|
|
||||||
button.click()
|
assert isinstance(dialog, DiagnosisDialog)
|
||||||
|
assert dialog.view_stack.currentWidget() is dialog.readonly_page
|
||||||
assert len(queued) == 1
|
assert dialog.edit_fields["chief_complaint"].toPlainText() == "睡眠不好、出汗多"
|
||||||
assert requested == []
|
assert dialog.summary_fields["phone"].text() == "138****9442"
|
||||||
assert shown == []
|
assert dialog.case_grid.isVisibleTo(dialog)
|
||||||
assert not table.isEnabled()
|
assert not dialog.findChildren(dialog_module.QTextBrowser)
|
||||||
assert not button.isEnabled()
|
|
||||||
|
|
||||||
function, options = queued[0]
|
|
||||||
options["on_success"](function())
|
|
||||||
options["on_finished"]()
|
|
||||||
assert requested == [17]
|
|
||||||
assert shown == [(17, "DETAIL-17")]
|
|
||||||
assert table.isEnabled()
|
|
||||||
assert button.isEnabled()
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback(
|
@pytest.mark.parametrize("owner_width", [1024, 1440, 1710])
|
||||||
|
def test_prescription_editor_embeds_scrollable_diagnosis_beside_editable_form(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
owner_width: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
queued: list[dict[str, Any]] = []
|
class Repository:
|
||||||
shown: list[tuple[int, str]] = []
|
def get_diagnosis_detail(
|
||||||
|
self, diagnosis_id: int, *, readonly: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
assert readonly
|
||||||
|
return {
|
||||||
|
"diagnosis": {
|
||||||
|
"id": diagnosis_id,
|
||||||
|
"patient_id": 745,
|
||||||
|
"patient_name": "庄志芳",
|
||||||
|
"chief_complaint": "睡眠不好、出汗多",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def queue_async(_function: Any, **options: Any) -> object:
|
def get_doctor_notes(self, _diagnosis_id: int) -> list[dict[str, Any]]:
|
||||||
queued.append(options)
|
return []
|
||||||
return object()
|
|
||||||
|
|
||||||
def present_order_detail(
|
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||||
_host: Any,
|
owner = QDialog()
|
||||||
order: dict[str, Any],
|
owner.resize(owner_width, 720)
|
||||||
*,
|
owner.show()
|
||||||
order_id: int,
|
repository = Repository()
|
||||||
permissions: Any,
|
editor = PrescriptionEditorDialog(
|
||||||
exec_: bool,
|
repository,
|
||||||
) -> None:
|
{"diagnosis_id": 745, "patient_name": "庄志芳"},
|
||||||
del permissions, exec_
|
mode="edit",
|
||||||
shown.append((order_id, order["order_no"]))
|
current_user=SimpleNamespace(id=9, name="周医生"),
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
|
||||||
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
|
parent=owner,
|
||||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
|
||||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
|
||||||
dialog = DiagnosisDetailDialog(
|
|
||||||
{
|
|
||||||
"orders": [
|
|
||||||
{"id": 21, "order_no": "ROW-21"},
|
|
||||||
{"id": 22, "order_no": "ROW-22"},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
repository=repository,
|
|
||||||
)
|
)
|
||||||
table = dialog._order_detail_table
|
editor.show()
|
||||||
button = dialog._order_detail_button
|
application.processEvents()
|
||||||
assert table is not None
|
assert QApplication.activeModalWidget() is editor
|
||||||
assert button is not None
|
assert editor.width() == editor.DRAWER_WIDTH
|
||||||
|
|
||||||
table.setCurrentCell(0, 0)
|
editor.diagnosis_button.click()
|
||||||
dialog._open_selected_order()
|
application.processEvents()
|
||||||
table.setCurrentCell(1, 0)
|
|
||||||
dialog._open_selected_order()
|
|
||||||
assert len(queued) == 2
|
|
||||||
|
|
||||||
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
|
detail = editor._diagnosis_view
|
||||||
queued[0]["on_finished"]()
|
assert detail is not None
|
||||||
assert shown == []
|
assert QApplication.activeModalWidget() is editor
|
||||||
assert not table.isEnabled()
|
assert not detail.isWindow()
|
||||||
assert not button.isEnabled()
|
assert detail.parentWidget() is editor.diagnosis_host
|
||||||
|
assert editor.diagnosis_host.isVisibleTo(editor)
|
||||||
|
assert editor.drawer_surface.isVisibleTo(editor)
|
||||||
|
assert editor.width() == owner.width()
|
||||||
|
assert editor.diagnosis_host.geometry().right() < editor.drawer_surface.geometry().left()
|
||||||
|
scroll_bar = detail.readonly_scroll.verticalScrollBar()
|
||||||
|
assert scroll_bar.maximum() > 0
|
||||||
|
scroll_bar.setValue(scroll_bar.maximum())
|
||||||
|
assert scroll_bar.value() == scroll_bar.maximum()
|
||||||
|
|
||||||
queued[1]["on_error"](RuntimeError("detail unavailable"))
|
editor.patient_name.setText("庄志芳(已核对)")
|
||||||
queued[1]["on_finished"]()
|
assert editor.patient_name.isEnabled()
|
||||||
assert shown == [(22, "ROW-22")]
|
assert editor.payload()["patient_name"] == "庄志芳(已核对)"
|
||||||
assert table.isEnabled()
|
|
||||||
assert button.isEnabled()
|
detail.readonly_close_button.click()
|
||||||
dialog.close()
|
application.processEvents()
|
||||||
|
assert editor.isVisible()
|
||||||
|
assert not editor.diagnosis_host.isVisible()
|
||||||
|
assert editor.width() == editor.DRAWER_WIDTH
|
||||||
|
assert editor.patient_name.text() == "庄志芳(已核对)"
|
||||||
|
editor.diagnosis_button.click()
|
||||||
|
application.processEvents()
|
||||||
|
assert editor.diagnosis_host.isVisibleTo(editor)
|
||||||
|
editor.reject()
|
||||||
|
application.processEvents()
|
||||||
|
assert not editor.isVisible()
|
||||||
|
assert not detail.isVisible()
|
||||||
|
owner.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1018,6 +1018,35 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_record_translates_snapshot_dictionary_codes_to_chinese() -> None:
|
||||||
|
# 处方快照存的是开方当时的原始 code,没有后端补的 *_text。
|
||||||
|
prescription = {
|
||||||
|
"id": 21,
|
||||||
|
"diagnosis_id": 9,
|
||||||
|
"case_record": {
|
||||||
|
"diagnosis": {
|
||||||
|
"appetite": "dry,bitter",
|
||||||
|
"water_intake": "one_bottle",
|
||||||
|
"weight_change": "lose_10_jin",
|
||||||
|
"fatty_liver_degree": "mild",
|
||||||
|
"past_history": "hypertension,diabetes",
|
||||||
|
"sleep_condition": "many_dreams",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
case_html = dialog_module.render_case_record_html(prescription)
|
||||||
|
|
||||||
|
assert "干、苦" in case_html
|
||||||
|
assert "1瓶矿泉水" in case_html
|
||||||
|
assert "瘦10斤" in case_html
|
||||||
|
assert "轻度" in case_html
|
||||||
|
assert "高血压、糖尿病" in case_html
|
||||||
|
assert "多梦" in case_html
|
||||||
|
for code in ("lose_10_jin", "one_bottle", "many_dreams"):
|
||||||
|
assert code not in case_html
|
||||||
|
|
||||||
|
|
||||||
def test_case_record_tab_exports_case_record_as_a3_pdf(
|
def test_case_record_tab_exports_case_record_as_a3_pdf(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
tmp_path: Any,
|
tmp_path: Any,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
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, QHBoxLayout, QPushButton, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.pages.reception import RECEPTION_QSS, _ReceptionCompleteButton
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controls():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
old_stylesheet, old_font, old_palette = app.styleSheet(), app.font(), app.palette()
|
||||||
|
old_style = app.style().objectName()
|
||||||
|
apply_theme(app)
|
||||||
|
host = QWidget()
|
||||||
|
host.setObjectName("ReceptionPage")
|
||||||
|
host.setStyleSheet(RECEPTION_QSS)
|
||||||
|
layout = QHBoxLayout(host)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
button = _ReceptionCompleteButton(host)
|
||||||
|
neighbor = QPushButton("通知医助", host)
|
||||||
|
neighbor.setObjectName("ReceptionNotifyButton")
|
||||||
|
layout.addWidget(button)
|
||||||
|
layout.addWidget(neighbor)
|
||||||
|
host.show()
|
||||||
|
host.activateWindow()
|
||||||
|
button.clearFocus()
|
||||||
|
neighbor.clearFocus()
|
||||||
|
QTest.mouseMove(host, QPoint(1, 1))
|
||||||
|
app.processEvents()
|
||||||
|
yield app, host, button, neighbor
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
app.processEvents()
|
||||||
|
app.setStyle(old_style)
|
||||||
|
app.setFont(old_font)
|
||||||
|
app.setPalette(old_palette)
|
||||||
|
app.setStyleSheet(old_stylesheet)
|
||||||
|
|
||||||
|
|
||||||
|
def surface_color(button: QPushButton, *, border: bool = False) -> str:
|
||||||
|
image = button.grab().toImage()
|
||||||
|
scale = image.devicePixelRatio()
|
||||||
|
return image.pixelColor(
|
||||||
|
round((0 if border else 7) * scale), round(button.height() / 2 * scale)
|
||||||
|
).name()
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(controls):
|
||||||
|
app, host, button, neighbor = controls
|
||||||
|
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
|
||||||
|
clicked: list[bool] = []
|
||||||
|
button.clicked.connect(lambda: clicked.append(True))
|
||||||
|
assert surface_color(button) == "#fff7f8"
|
||||||
|
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||||
|
|
||||||
|
QTest.mouseMove(button, button.rect().center())
|
||||||
|
app.processEvents()
|
||||||
|
assert button.underMouse()
|
||||||
|
assert surface_color(button) == "#fff0f2"
|
||||||
|
assert button.geometry() == geometry
|
||||||
|
assert neighbor.geometry() == neighbor_geometry
|
||||||
|
|
||||||
|
QTest.mousePress(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||||
|
app.processEvents()
|
||||||
|
assert surface_color(button) == "#ffe4e8"
|
||||||
|
assert button.geometry() == geometry
|
||||||
|
assert neighbor.geometry() == neighbor_geometry
|
||||||
|
# Dragging outside and releasing must not activate the completion action.
|
||||||
|
QTest.mouseMove(button, QPoint(-5, -5))
|
||||||
|
QTest.mouseRelease(button, Qt.MouseButton.LeftButton, pos=QPoint(-5, -5))
|
||||||
|
QTest.mouseMove(host, QPoint(1, 1))
|
||||||
|
button.clearFocus()
|
||||||
|
app.processEvents()
|
||||||
|
assert clicked == []
|
||||||
|
assert surface_color(button) == "#fff7f8"
|
||||||
|
assert button.geometry() == geometry
|
||||||
|
assert neighbor.geometry() == neighbor_geometry
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_disabled_hover_does_not_look_or_act_enabled(controls):
|
||||||
|
app, _host, button, _neighbor = controls
|
||||||
|
clicked: list[bool] = []
|
||||||
|
button.clicked.connect(lambda: clicked.append(True))
|
||||||
|
button.setEnabled(False)
|
||||||
|
QTest.mouseMove(button, button.rect().center())
|
||||||
|
app.processEvents()
|
||||||
|
assert surface_color(button) == "#f7f8fb"
|
||||||
|
assert button.cursor().shape() == Qt.CursorShape.ArrowCursor
|
||||||
|
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
|
||||||
|
assert clicked == []
|
||||||
|
button.setEnabled(True)
|
||||||
|
app.processEvents()
|
||||||
|
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||||
|
assert surface_color(button) != "#f7f8fb"
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
||||||
|
app, _host, button, neighbor = controls
|
||||||
|
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
|
||||||
|
neighbor.setFocus(Qt.FocusReason.TabFocusReason)
|
||||||
|
app.processEvents()
|
||||||
|
border_before = surface_color(button, border=True)
|
||||||
|
button.setFocus(Qt.FocusReason.TabFocusReason)
|
||||||
|
app.processEvents()
|
||||||
|
assert button.hasFocus()
|
||||||
|
assert surface_color(button, border=True) == "#cf4656"
|
||||||
|
assert surface_color(button, border=True) != border_before
|
||||||
|
assert button.geometry() == geometry
|
||||||
|
assert neighbor.geometry() == neighbor_geometry
|
||||||
|
QTest.mouseMove(button, button.rect().center())
|
||||||
|
app.processEvents()
|
||||||
|
assert surface_color(button) == "#fff0f2"
|
||||||
|
assert surface_color(button, border=True) == "#cf4656"
|
||||||
@@ -1436,6 +1436,102 @@ def test_im_consult_is_visible_immediately_after_history(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width", [1280, 1494])
|
||||||
|
@pytest.mark.parametrize("permissions", [[], ["*"]])
|
||||||
|
def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
|
||||||
|
application: QApplication,
|
||||||
|
queued_async: list[dict[str, Any]],
|
||||||
|
width: int,
|
||||||
|
permissions: list[str],
|
||||||
|
) -> None:
|
||||||
|
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(permissions))
|
||||||
|
page.resize(width, 760)
|
||||||
|
page.show()
|
||||||
|
try:
|
||||||
|
application.processEvents()
|
||||||
|
# The first show initiates a queue reload and clears the selection.
|
||||||
|
# Present a synthetic selected patient after that initial reset.
|
||||||
|
page.patient_name_label.setText("测试患者")
|
||||||
|
page.patient_meta_label.setText("女 · 42岁 · 138****8000 | 就诊号:31")
|
||||||
|
page.detail_stack.setCurrentIndex(1)
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
hero = page.notify_button.parentWidget()
|
||||||
|
assert hero.objectName() == "ReceptionHero"
|
||||||
|
assert page.notify_button.isVisibleTo(page)
|
||||||
|
assert page.notify_button.text() == "通知医助"
|
||||||
|
assert page.notify_button.width() >= page.notify_button.sizeHint().width()
|
||||||
|
assert page.notify_button.height() > 0
|
||||||
|
assert "通知医助" not in [action.text() for action in page.more_button.menu().actions()]
|
||||||
|
buttons = [
|
||||||
|
button
|
||||||
|
for button in (
|
||||||
|
page.complete_button,
|
||||||
|
page.notify_button,
|
||||||
|
page.history_button,
|
||||||
|
page.video_button,
|
||||||
|
page.more_button,
|
||||||
|
)
|
||||||
|
if button.isVisibleTo(page)
|
||||||
|
]
|
||||||
|
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()
|
||||||
|
for previous, following in zip(buttons, buttons[1:], strict=False):
|
||||||
|
assert previous.geometry().right() < following.geometry().left()
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_header_button_keeps_appointment_context_and_pending_state(
|
||||||
|
application: QApplication,
|
||||||
|
queued_async: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
sent: list[int] = []
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def notify_assistant(self, appointment_id: int) -> dict[str, bool]:
|
||||||
|
sent.append(appointment_id)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||||
|
try:
|
||||||
|
page._update_action_state({}, {})
|
||||||
|
assert not page.notify_button.isEnabled()
|
||||||
|
page._selected_appointment_id = 31
|
||||||
|
page._selected_record = {"id": 31, "status": 1}
|
||||||
|
page._update_action_state(page._selected_record, {})
|
||||||
|
assert page.notify_button.isEnabled()
|
||||||
|
queued_async.clear()
|
||||||
|
page.notify_button.click()
|
||||||
|
assert not page.notify_button.isEnabled()
|
||||||
|
page.notify_button.click()
|
||||||
|
assert len(queued_async) == 1
|
||||||
|
notification = queued_async.pop()
|
||||||
|
notification["function"]()
|
||||||
|
assert sent == [31]
|
||||||
|
notification["on_finished"]()
|
||||||
|
assert page.notify_button.isEnabled()
|
||||||
|
|
||||||
|
page.notify_button.click()
|
||||||
|
notification = queued_async.pop()
|
||||||
|
page._selected_appointment_id = 32
|
||||||
|
page._selected_record = {"id": 32, "status": 1}
|
||||||
|
page._detail_generation += 1
|
||||||
|
# An old request still targets its original appointment and cannot
|
||||||
|
# re-enable a pending notification for a newly selected patient.
|
||||||
|
notification["function"]()
|
||||||
|
notification["on_finished"]()
|
||||||
|
assert sent == [31, 31]
|
||||||
|
assert not page.notify_button.isEnabled()
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_reception_ai_report_button_follows_permission(
|
def test_reception_ai_report_button_follows_permission(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
immediate_async: None,
|
immediate_async: None,
|
||||||
@@ -2483,7 +2579,7 @@ def test_completion_revalidates_server_status_before_write(
|
|||||||
assert completed == []
|
assert completed == []
|
||||||
|
|
||||||
repository.status = 4
|
repository.status = 4
|
||||||
assert page._complete_after_revalidation(51) == {"ok": True}
|
assert page._complete_after_revalidation(51) == ""
|
||||||
assert completed == [51]
|
assert completed == [51]
|
||||||
page.close()
|
page.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
from threading import Event
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QCoreApplication, QEvent, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import reception as reception_module
|
||||||
|
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, Any]] = []
|
||||||
|
self.detail = {
|
||||||
|
"appointment": {"id": 51, "patient_name": "刷新测试患者", "status": 1},
|
||||||
|
"diagnosis": {"id": 251, "patient_id": 151, "symptoms": "原病历"},
|
||||||
|
"patient": {"id": 151},
|
||||||
|
}
|
||||||
|
self.rows = [deepcopy(self.detail["appointment"])]
|
||||||
|
self.reports: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def list_appointments(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(("queue", query))
|
||||||
|
return {"lists": deepcopy(self.rows), "count": len(self.rows)}
|
||||||
|
|
||||||
|
def get_reception(self, appointment_id: int) -> dict[str, Any]:
|
||||||
|
self.calls.append(("detail", appointment_id))
|
||||||
|
return deepcopy(self.detail)
|
||||||
|
|
||||||
|
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
|
||||||
|
self.calls.append(("reports", patient_id))
|
||||||
|
return {"patient_id": patient_id, "reports": deepcopy(self.reports)}
|
||||||
|
|
||||||
|
def generate_patient_ai_report(self, patient_id: int, **_options: Any) -> None:
|
||||||
|
self.calls.append(("POST", patient_id))
|
||||||
|
raise AssertionError("刷新不得生成 AI 报告")
|
||||||
|
|
||||||
|
def get_diagnosis_ai_analysis(self, diagnosis_id: int, **_options: Any) -> None:
|
||||||
|
self.calls.append(("legacy_POST", diagnosis_id))
|
||||||
|
raise AssertionError("刷新不得调用名称为 get 的旧版生成接口")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def harness(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
jobs: list[dict[str, Any]] = []
|
||||||
|
toasts: list[str] = []
|
||||||
|
|
||||||
|
def queue(function: Any, **options: Any) -> object:
|
||||||
|
jobs.append({"function": function, **options})
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(reception_module, "run_async", queue)
|
||||||
|
monkeypatch.setattr(reception_module, "_AI_AUTOMATIC_GENERATION_SETTLE_SECONDS", 0)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reception_module, "show_toast", lambda _parent, text, *_args: toasts.append(text)
|
||||||
|
)
|
||||||
|
repository = RefreshRepository()
|
||||||
|
page = ReceptionPage(
|
||||||
|
repository,
|
||||||
|
PermissionSet(
|
||||||
|
[
|
||||||
|
"doctor.appointment/addDoctorNote",
|
||||||
|
"doctor.appointment/complete",
|
||||||
|
"tcm.diagnosis/patientAiReports",
|
||||||
|
"tcm.diagnosis/generatePatientAiReport",
|
||||||
|
"tcm.diagnosis/aiAnalysis",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
page._selected_appointment_id = 51
|
||||||
|
page._selected_record = deepcopy(repository.detail["appointment"])
|
||||||
|
page._selected_detail = deepcopy(repository.detail)
|
||||||
|
page._update_action_state(repository.detail["appointment"], repository.detail["diagnosis"])
|
||||||
|
yield page, repository, jobs, toasts
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
|
||||||
|
|
||||||
|
def finish(job: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
result = job["function"]()
|
||||||
|
except Exception as error:
|
||||||
|
job["on_error"](error)
|
||||||
|
else:
|
||||||
|
job["on_success"](result)
|
||||||
|
finally:
|
||||||
|
job["on_finished"]()
|
||||||
|
|
||||||
|
|
||||||
|
def expire_cooldown(page: ReceptionPage) -> None:
|
||||||
|
page._refresh_cooldown.stop()
|
||||||
|
page._refresh_cooldown.timeout.emit()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(version: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": version,
|
||||||
|
"patient_id": 151,
|
||||||
|
"model_key": "qwen",
|
||||||
|
"version": version,
|
||||||
|
"generated_at": f"2026-08-31 10:{version:02}:00",
|
||||||
|
"report": {"diagnosis": f"测试报告第 {version} 版", "treatment_advice": "测试建议"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_refresh_supersedes_hung_queue_and_detail_independently(harness) -> None:
|
||||||
|
page, repository, jobs, _toasts = harness
|
||||||
|
page.refresh(workspace_refresh=True)
|
||||||
|
old_queue = jobs.pop()
|
||||||
|
page._load_detail(page._selected_record, clear=False)
|
||||||
|
old_detail = jobs.pop()
|
||||||
|
old_generation = page._detail_generation
|
||||||
|
|
||||||
|
page.refresh_button.click()
|
||||||
|
|
||||||
|
assert len(jobs) == 2
|
||||||
|
assert page._detail_generation > old_generation
|
||||||
|
assert page._queue_loading and page._detail_loading
|
||||||
|
# A slow queue cannot hold the new detail back.
|
||||||
|
finish(jobs[0])
|
||||||
|
assert repository.calls == [("detail", 51)]
|
||||||
|
assert not page._detail_loading
|
||||||
|
assert page._queue_loading
|
||||||
|
assert len(jobs) == 3 # exactly one fresh saved-report read
|
||||||
|
finish(jobs[1])
|
||||||
|
assert len(jobs) == 3 # queue must not duplicate detail work
|
||||||
|
finish(jobs[2])
|
||||||
|
old_queue["on_success"]({"lists": [{"id": 999, "patient_name": "迟到队列"}]})
|
||||||
|
old_queue["on_finished"]()
|
||||||
|
old_detail["on_success"]({"detail": {"appointment": {"id": 51, "patient_name": "旧详情"}}})
|
||||||
|
old_detail["on_finished"]()
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
assert page.patient_name_label.text() == "刷新测试患者"
|
||||||
|
assert not page._queue_loading and not page._detail_loading
|
||||||
|
assert [call[0] for call in repository.calls] == ["detail", "queue", "reports"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_preserves_query_loaded_pages_selection_and_drafts(harness) -> None:
|
||||||
|
page, repository, jobs, _toasts = harness
|
||||||
|
page._queue_date = "2026-08-03"
|
||||||
|
page._queue_filter_status = None
|
||||||
|
page.search_edit.blockSignals(True)
|
||||||
|
page.search_edit.setText(" 测试 ")
|
||||||
|
page.search_edit.blockSignals(False)
|
||||||
|
page._queue_page = 3
|
||||||
|
page._queue_records = [dict(id=index) for index in range(1, 46)]
|
||||||
|
page.note_edit.setPlainText("尚未保存的备注")
|
||||||
|
page._pending_tongue_images.append("draft-image.png")
|
||||||
|
page._pending_report_files.append("draft-report.pdf")
|
||||||
|
page.detail_tabs.setCurrentIndex(4)
|
||||||
|
# The current patient is no longer in this queue: don't silently select another.
|
||||||
|
repository.rows = [{"id": 52, "patient_name": "其他测试患者", "status": 1}]
|
||||||
|
page._refresh_workspace()
|
||||||
|
finish(jobs[1])
|
||||||
|
query = repository.calls[-1][1]
|
||||||
|
assert query == {
|
||||||
|
"status": None,
|
||||||
|
"start_date": "2026-08-03",
|
||||||
|
"end_date": "2026-08-03",
|
||||||
|
"patient_name": "测试",
|
||||||
|
"page_no": 1,
|
||||||
|
"page_size": 45,
|
||||||
|
"include_status_counts": 1,
|
||||||
|
}
|
||||||
|
finish(jobs[0])
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
assert page.queue_list.currentRow() == -1
|
||||||
|
assert page.note_edit.toPlainText() == "尚未保存的备注"
|
||||||
|
assert page._pending_tongue_images == ["draft-image.png"]
|
||||||
|
assert page._pending_report_files == ["draft-report.pdf"]
|
||||||
|
assert page.detail_tabs.currentIndex() == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_can_retry_while_old_requests_never_finish(harness) -> None:
|
||||||
|
page, _repository, jobs, _toasts = harness
|
||||||
|
page._refresh_workspace()
|
||||||
|
page._refresh_workspace()
|
||||||
|
assert len(jobs) == 2
|
||||||
|
assert not page.refresh_button.isEnabled()
|
||||||
|
expire_cooldown(page)
|
||||||
|
page.refresh_button.click()
|
||||||
|
assert len(jobs) == 4
|
||||||
|
# Superseded queued work never goes to the server.
|
||||||
|
assert jobs[0]["function"]() == {"cancelled": True}
|
||||||
|
assert jobs[1]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
|
||||||
|
jobs[0]["on_finished"]()
|
||||||
|
jobs[1]["on_finished"]()
|
||||||
|
assert page._queue_loading and page._detail_loading
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("empty", [True, False])
|
||||||
|
def test_automatic_polls_after_refresh_keep_missing_patient_and_draft(harness, empty) -> None:
|
||||||
|
page, repository, jobs, _toasts = harness
|
||||||
|
page.note_edit.setPlainText("不能因轮询丢失的备注")
|
||||||
|
page._pending_tongue_images.append("draft-image.png")
|
||||||
|
repository.rows = [] if empty else [{"id": 52, "patient_name": "其他患者", "status": 1}]
|
||||||
|
page._refresh_workspace()
|
||||||
|
finish(jobs[0])
|
||||||
|
finish(jobs[1])
|
||||||
|
finish(jobs[2])
|
||||||
|
for _ in range(3):
|
||||||
|
before = len(jobs)
|
||||||
|
page._poll_queue()
|
||||||
|
assert len(jobs) == before + 1
|
||||||
|
finish(jobs[-1])
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
assert page.note_edit.toPlainText() == "不能因轮询丢失的备注"
|
||||||
|
assert page._pending_tongue_images == ["draft-image.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_destroyed_page_ignores_every_late_refresh_callback(harness) -> None:
|
||||||
|
_page, repository, jobs, _toasts = harness
|
||||||
|
closed_page = ReceptionPage(repository, PermissionSet([]))
|
||||||
|
closed_page._refresh_workspace()
|
||||||
|
job = jobs[-1]
|
||||||
|
closed_page.deleteLater()
|
||||||
|
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
job["on_success"]({"lists": []})
|
||||||
|
job["on_error"](RuntimeError("迟到异常"))
|
||||||
|
job["on_finished"]()
|
||||||
|
assert job["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
|
||||||
|
assert not repository.calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("busy_flag", ["_note_busy", "_completion_pending"])
|
||||||
|
def test_refresh_does_not_disturb_business_submission(harness, busy_flag: str) -> None:
|
||||||
|
page, _repository, jobs, toasts = harness
|
||||||
|
setattr(page, busy_flag, True)
|
||||||
|
generation = page._detail_generation
|
||||||
|
page._refresh_workspace()
|
||||||
|
assert not jobs
|
||||||
|
assert page._detail_generation == generation
|
||||||
|
assert getattr(page, busy_flag)
|
||||||
|
assert "正在提交" in toasts[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_invalidates_in_flight_ai_read_and_rejects_late_result(harness) -> None:
|
||||||
|
page, repository, jobs, _toasts = harness
|
||||||
|
page._load_patient_ai_reports(151, appointment_id=51, diagnosis_id=251, force=True)
|
||||||
|
old_read = jobs.pop()
|
||||||
|
old_key = next(iter(page._patient_ai_list_requests))
|
||||||
|
page._patient_ai_list_started.add(old_key)
|
||||||
|
cancel = page._patient_ai_list_cancel_events[old_key]
|
||||||
|
repository.reports = [snapshot(2)]
|
||||||
|
page._refresh_workspace()
|
||||||
|
assert cancel.is_set()
|
||||||
|
assert old_key not in page._patient_ai_list_requests
|
||||||
|
finish(jobs[0])
|
||||||
|
finish(jobs[2])
|
||||||
|
assert page._ai_analysis_payloads["qwen"]["version"] == 2
|
||||||
|
old_read["on_success"]({"patient_id": 151, "reports": [snapshot(1)]})
|
||||||
|
old_read["on_finished"]()
|
||||||
|
assert page._ai_analysis_payloads["qwen"]["version"] == 2
|
||||||
|
assert not page._ai_analysis_loading
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_ai_read_does_not_need_automatic_slots_or_generate_missing_reports(harness) -> None:
|
||||||
|
page, repository, jobs, _toasts = harness
|
||||||
|
slots = reception_module._AI_AUTOMATIC_REQUEST_SLOTS
|
||||||
|
assert slots.acquire(blocking=False)
|
||||||
|
assert slots.acquire(blocking=False)
|
||||||
|
try:
|
||||||
|
page._refresh_workspace()
|
||||||
|
finish(jobs[0])
|
||||||
|
finish(jobs[2])
|
||||||
|
assert ("reports", 151) in repository.calls
|
||||||
|
assert page._ai_analysis_state == "missing"
|
||||||
|
assert not page._ai_analysis_loading
|
||||||
|
assert not slots.acquire(blocking=False) # GUI must not release others' slots
|
||||||
|
finally:
|
||||||
|
slots.release()
|
||||||
|
slots.release()
|
||||||
|
assert not any("POST" in call[0] for call in repository.calls)
|
||||||
|
assert page.ai_analysis_regenerate_button.isEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_keeps_started_generation_single_flight(harness) -> None:
|
||||||
|
page, repository, jobs, _toasts = harness
|
||||||
|
key = (1, 51, 151, "qwen")
|
||||||
|
page._patient_ai_generation_requests.add(key)
|
||||||
|
page._patient_ai_generation_started.add(key)
|
||||||
|
cancel = Event()
|
||||||
|
page._patient_ai_generation_cancel_events[key] = cancel
|
||||||
|
page._refresh_workspace()
|
||||||
|
finish(jobs[0])
|
||||||
|
assert len(jobs) == 2
|
||||||
|
assert key in page._patient_ai_generation_requests
|
||||||
|
assert key in page._patient_ai_generation_started
|
||||||
|
assert not cancel.is_set()
|
||||||
|
assert "不会重复生成" in page._ai_analysis_operation_error
|
||||||
|
assert not any(call[0] in {"POST", "reports"} for call in repository.calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_ai_refresh_never_invokes_post_endpoint(harness) -> None:
|
||||||
|
page, repository, jobs, _toasts = harness
|
||||||
|
page._can_patient_ai_read = False
|
||||||
|
page._refresh_workspace()
|
||||||
|
finish(jobs[0])
|
||||||
|
assert len(jobs) == 2
|
||||||
|
assert repository.calls == [("detail", 51)]
|
||||||
|
assert "无法只读刷新" in page.ai_analysis_state_label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_selection_from_manual_refresh_is_read_only(harness) -> None:
|
||||||
|
page, repository, jobs, _toasts = harness
|
||||||
|
page._clear_selection()
|
||||||
|
page._refresh_workspace()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
finish(jobs[0])
|
||||||
|
finish(jobs[1])
|
||||||
|
finish(jobs[2])
|
||||||
|
assert [call[0] for call in repository.calls] == ["queue", "detail", "reports"]
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
assert page._ai_analysis_state == "missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_refresh_cannot_switch_back_to_previous_patient(harness) -> None:
|
||||||
|
page, _repository, jobs, _toasts = harness
|
||||||
|
page._refresh_workspace()
|
||||||
|
second = {"id": 52, "patient_name": "新选择的测试患者", "status": 1}
|
||||||
|
page._select_record(second)
|
||||||
|
assert page._selected_appointment_id == 52
|
||||||
|
jobs[0]["on_success"]({"detail": {"appointment": {"id": 51, "patient_name": "旧患者"}}})
|
||||||
|
jobs[1]["on_success"]({"lists": [{"id": 51, "patient_name": "旧患者"}]})
|
||||||
|
assert page._selected_appointment_id == 52
|
||||||
|
assert page.patient_name_label.text() == "新选择的测试患者"
|
||||||
|
assert page._detail_loading # stale finished must not clear patient B's loading
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_read_timeout_is_retryable_and_cannot_install_late_snapshot(
|
||||||
|
harness, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
page, _repository, jobs, _toasts = harness
|
||||||
|
page._refresh_workspace()
|
||||||
|
monkeypatch.setattr(reception_module, "_REFRESH_TIMEOUT_MS", 10)
|
||||||
|
finish(jobs[0])
|
||||||
|
finish(jobs[1])
|
||||||
|
assert len(jobs) == 3
|
||||||
|
QTest.qWait(60)
|
||||||
|
assert not page._ai_analysis_loading
|
||||||
|
assert page._ai_analysis_state == "error"
|
||||||
|
assert page.ai_analysis_retry_button.isEnabled()
|
||||||
|
jobs[2]["on_success"]({"patient_id": 151, "reports": [snapshot(1)]})
|
||||||
|
assert not page._ai_analysis_payloads
|
||||||
|
assert not page._patient_ai_list_requests
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_timeout_stops_loading_and_ignores_late_success(harness, monkeypatch) -> None:
|
||||||
|
page, _repository, jobs, _toasts = harness
|
||||||
|
monkeypatch.setattr(reception_module, "_REFRESH_TIMEOUT_MS", 10)
|
||||||
|
monkeypatch.setattr(reception_module, "_REFRESH_COOLDOWN_MS", 10)
|
||||||
|
page._refresh_workspace()
|
||||||
|
QTest.qWait(60)
|
||||||
|
assert not page._queue_loading and not page._detail_loading
|
||||||
|
assert page.refresh_button.isEnabled()
|
||||||
|
assert "刷新超时" in page.queue_banner.label.text()
|
||||||
|
assert "刷新超时" in page.detail_banner.label.text()
|
||||||
|
jobs[0]["on_success"]({"detail": {"appointment": {"id": 51, "patient_name": "迟到详情"}}})
|
||||||
|
jobs[1]["on_success"]({"lists": []})
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
assert page.patient_name_label.text() != "迟到详情"
|
||||||
|
assert "刷新超时" in page.detail_banner.label.text()
|
||||||
|
assert jobs[0]["function"]() is reception_module._ASYNC_REQUEST_CANCELLED
|
||||||
|
page._refresh_workspace()
|
||||||
|
assert len(jobs) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_read_error_and_missing_callback_are_terminal(harness) -> None:
|
||||||
|
page, _repository, jobs, _toasts = harness
|
||||||
|
page._refresh_workspace()
|
||||||
|
jobs[0]["on_error"](RuntimeError("测试断网"))
|
||||||
|
jobs[0]["on_finished"]()
|
||||||
|
jobs[1]["on_finished"]()
|
||||||
|
assert not page._queue_loading and not page._detail_loading
|
||||||
|
assert "测试断网" in page.detail_banner.label.text()
|
||||||
|
assert "未返回有效结果" in page.queue_banner.label.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_button_visible_and_f5_uses_same_debounce(harness, application) -> None:
|
||||||
|
page, _repository, jobs, _toasts = harness
|
||||||
|
page._queue_records = [page._selected_record] # prevent showEvent's initial fetch
|
||||||
|
page.resize(1440, 1000)
|
||||||
|
page.show()
|
||||||
|
page.activateWindow()
|
||||||
|
page.poll_timer.stop()
|
||||||
|
application.processEvents()
|
||||||
|
assert page.refresh_button.isVisible()
|
||||||
|
assert page.refresh_button.text() == "刷新"
|
||||||
|
assert page.refresh_button.width() >= 50
|
||||||
|
assert page.refresh_button.geometry().right() < page.queue_date_button.geometry().left()
|
||||||
|
page.note_edit.setFocus()
|
||||||
|
application.processEvents()
|
||||||
|
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
|
||||||
|
assert len(jobs) == 2
|
||||||
|
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
|
||||||
|
assert len(jobs) == 2
|
||||||
|
assert page._refresh_cooldown.isActive()
|
||||||
|
assert page._refresh_cooldown in page.findChildren(QTimer)
|
||||||
@@ -288,6 +288,82 @@ def test_shell_ai_entry_always_opens_patient_picker_even_with_current_selection(
|
|||||||
assert shell_window.stack.currentWidget() is current
|
assert shell_window.stack.currentWidget() is current
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_global_diagnosis_entry_reuses_dialog_and_obeys_permissions(
|
||||||
|
shell_window: ShellWindow,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
opened: list[tuple[str, int]] = []
|
||||||
|
refresh_callbacks: list[Any] = []
|
||||||
|
created: list[Any] = []
|
||||||
|
|
||||||
|
class _SavedSignal:
|
||||||
|
def connect(self, callback: Any) -> None:
|
||||||
|
refresh_callbacks.append(callback)
|
||||||
|
|
||||||
|
class _DiagnosisDialogDouble:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: Any,
|
||||||
|
parent: Any,
|
||||||
|
*,
|
||||||
|
permissions: Any,
|
||||||
|
) -> None:
|
||||||
|
self.repository = repository
|
||||||
|
self.parent = parent
|
||||||
|
self.permissions = permissions
|
||||||
|
self.saved = _SavedSignal()
|
||||||
|
self.raise_count = 0
|
||||||
|
self.activate_count = 0
|
||||||
|
created.append(self)
|
||||||
|
|
||||||
|
def refresh_permissions(self, permissions: Any) -> None:
|
||||||
|
self.permissions = permissions
|
||||||
|
|
||||||
|
def open_for(
|
||||||
|
self,
|
||||||
|
diagnosis_id: int,
|
||||||
|
*,
|
||||||
|
editable: bool,
|
||||||
|
modeless: bool,
|
||||||
|
) -> None:
|
||||||
|
assert editable is True
|
||||||
|
assert modeless is True
|
||||||
|
opened.append(("edit", diagnosis_id))
|
||||||
|
|
||||||
|
def open_view_only(self, diagnosis_id: int, *, modeless: bool) -> None:
|
||||||
|
assert modeless is True
|
||||||
|
opened.append(("view", diagnosis_id))
|
||||||
|
|
||||||
|
def raise_(self) -> None:
|
||||||
|
self.raise_count += 1
|
||||||
|
|
||||||
|
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible test double
|
||||||
|
self.activate_count += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(shell_module, "DiagnosisDialog", _DiagnosisDialogDouble)
|
||||||
|
shell_window._global_diagnosis_dialog = None
|
||||||
|
|
||||||
|
shell_window.permissions = {"tcm.diagnosis/edit"}
|
||||||
|
assert shell_window.open_diagnosis_by_id(501, modeless=True) is created[0]
|
||||||
|
shell_window.permissions = {"tcm.diagnosis/readonlyDetail"}
|
||||||
|
assert shell_window.open_diagnosis_by_id("502", modeless=True) is created[0]
|
||||||
|
shell_window.permissions = {"tcm.diagnosis/*"}
|
||||||
|
assert shell_window.open_diagnosis_by_id(503, modeless=True) is created[0]
|
||||||
|
|
||||||
|
assert opened == [("edit", 501), ("view", 502), ("edit", 503)]
|
||||||
|
assert len(created) == 1
|
||||||
|
assert created[0].parent is shell_window
|
||||||
|
assert len(refresh_callbacks) == 1
|
||||||
|
assert created[0].raise_count == 3
|
||||||
|
assert created[0].activate_count == 3
|
||||||
|
|
||||||
|
shell_window.permissions = set()
|
||||||
|
assert shell_window.open_diagnosis_by_id(504, modeless=True) is None
|
||||||
|
assert shell_window.open_diagnosis_by_id(0, modeless=True) is None
|
||||||
|
assert shell_window.open_diagnosis_by_id("invalid", modeless=True) is None
|
||||||
|
assert opened == [("edit", 501), ("view", 502), ("edit", 503)]
|
||||||
|
|
||||||
|
|
||||||
def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
|
def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
shell_window: ShellWindow,
|
shell_window: ShellWindow,
|
||||||
@@ -593,3 +669,55 @@ def test_shell_directional_controls_have_no_unicode_arrow_text(
|
|||||||
if hasattr(button, "text") and callable(button.text)
|
if hasattr(button, "text") and callable(button.text)
|
||||||
for arrow in ("←", "→", "↑", "↓", "▲", "▼", "▴", "▾")
|
for arrow in ("←", "→", "↑", "↓", "▲", "▼", "▴", "▾")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_notification_takes_the_doctor_to_the_matching_workspace(
|
||||||
|
application: QApplication,
|
||||||
|
shell_window: ShellWindow,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
opened: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ShellWindow,
|
||||||
|
"open_diagnosis_by_id",
|
||||||
|
lambda self, diagnosis_id, *, modeless=False: opened.append(diagnosis_id),
|
||||||
|
)
|
||||||
|
center = shell_window.chat_notifications
|
||||||
|
assert shell_window.navigate("consultations")
|
||||||
|
|
||||||
|
center.add_notifications(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"type": "patient_opened_chat",
|
||||||
|
"patient_name": "甘先生",
|
||||||
|
"created_at": 1787882294,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
application.processEvents()
|
||||||
|
assert [item.id for item in center.pending] == ["n1"]
|
||||||
|
|
||||||
|
# 患者进入会话 → 直接落到接诊台。
|
||||||
|
next(iter(center._cards.values())).open_button.click()
|
||||||
|
application.processEvents()
|
||||||
|
assert shell_window._active_page_key == "reception"
|
||||||
|
assert center.pending == []
|
||||||
|
|
||||||
|
# 面诊结束 → 打开对应诊单。
|
||||||
|
center.add_notifications(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "n2",
|
||||||
|
"type": "consultation_complete",
|
||||||
|
"patient_name": "甘先生",
|
||||||
|
"doctor_name": "陈医生",
|
||||||
|
"diagnosis_id": 8169,
|
||||||
|
"created_at": 1787882294,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
next(iter(center._cards.values())).open_button.click()
|
||||||
|
application.processEvents()
|
||||||
|
assert opened == [8169]
|
||||||
|
assert center.pending == []
|
||||||
|
|||||||
@@ -95,7 +95,13 @@ def test_navigation_requires_each_pages_actual_list_capability() -> None:
|
|||||||
def test_video_release_does_not_remove_a_newer_call() -> None:
|
def test_video_release_does_not_remove_a_newer_call() -> None:
|
||||||
older = object()
|
older = object()
|
||||||
newer = object()
|
newer = object()
|
||||||
controller = SimpleNamespace(video_calls={"501": newer})
|
# _release_video_call also clears the preview slot, so the double needs the
|
||||||
|
# same attributes the real controller sets up in __init__.
|
||||||
|
controller = SimpleNamespace(
|
||||||
|
video_calls={"501": newer},
|
||||||
|
_video_preview_state=None,
|
||||||
|
_video_preview_generation=0,
|
||||||
|
)
|
||||||
|
|
||||||
ApplicationController._release_video_call(controller, "501", older)
|
ApplicationController._release_video_call(controller, "501", older)
|
||||||
assert controller.video_calls == {"501": newer}
|
assert controller.video_calls == {"501": newer}
|
||||||
@@ -234,6 +240,7 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
|
|||||||
api_base_url="https://127.0.0.1:9",
|
api_base_url="https://127.0.0.1:9",
|
||||||
request_timeout=30,
|
request_timeout=30,
|
||||||
demo_mode=True,
|
demo_mode=True,
|
||||||
|
debug_mode=True,
|
||||||
remembered_account="",
|
remembered_account="",
|
||||||
)
|
)
|
||||||
payloads: list[dict[str, Any]] = []
|
payloads: list[dict[str, Any]] = []
|
||||||
@@ -340,6 +347,7 @@ def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
|
|||||||
api_base_url="",
|
api_base_url="",
|
||||||
request_timeout=30,
|
request_timeout=30,
|
||||||
demo_mode=False,
|
demo_mode=False,
|
||||||
|
debug_mode=True,
|
||||||
remembered_account="",
|
remembered_account="",
|
||||||
)
|
)
|
||||||
window = LoginWindow(object(), config=config, settings=settings)
|
window = LoginWindow(object(), config=config, settings=settings)
|
||||||
@@ -369,6 +377,49 @@ def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_login_hides_and_blocks_debug_controls(tmp_path: Any) -> None:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
settings = QSettings(str(tmp_path / "production.ini"), QSettings.Format.IniFormat)
|
||||||
|
settings.setValue("server/base_url", "https://stale.example.test")
|
||||||
|
settings.setValue("server/verify_ssl", False)
|
||||||
|
remote_repository = object()
|
||||||
|
demo_repository = DemoDoctorRepository()
|
||||||
|
config = SimpleNamespace(
|
||||||
|
api_base_url="https://prod.example.test/adminapi",
|
||||||
|
request_timeout=30,
|
||||||
|
verify_ssl=True,
|
||||||
|
demo_mode=True,
|
||||||
|
debug_mode=False,
|
||||||
|
remembered_account="",
|
||||||
|
)
|
||||||
|
window = LoginWindow(
|
||||||
|
remote_repository,
|
||||||
|
config=config,
|
||||||
|
demo_repository=demo_repository,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
window.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert not window.demo_check.isVisible()
|
||||||
|
assert not window.debug_settings_section.isVisible()
|
||||||
|
assert not window.server_toggle.isVisible()
|
||||||
|
assert not window.server_panel.isVisible()
|
||||||
|
assert not window.demo_check.isChecked()
|
||||||
|
assert window.active_repository is remote_repository
|
||||||
|
assert window.server_url_edit.text() == "https://prod.example.test/adminapi"
|
||||||
|
assert window._credential_scope() == "https://prod.example.test/adminapi"
|
||||||
|
|
||||||
|
window._on_demo_toggled(True)
|
||||||
|
window._toggle_server_panel(True)
|
||||||
|
|
||||||
|
assert not window.demo_check.isChecked()
|
||||||
|
assert window.active_repository is remote_repository
|
||||||
|
assert window.server_panel.isHidden()
|
||||||
|
window.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_server_settings_can_persist_self_signed_debug_mode(tmp_path: Any) -> None:
|
def test_server_settings_can_persist_self_signed_debug_mode(tmp_path: Any) -> None:
|
||||||
application = QApplication.instance() or QApplication([])
|
application = QApplication.instance() or QApplication([])
|
||||||
settings = QSettings(str(tmp_path / "self-signed.ini"), QSettings.Format.IniFormat)
|
settings = QSettings(str(tmp_path / "self-signed.ini"), QSettings.Format.IniFormat)
|
||||||
@@ -377,6 +428,7 @@ def test_server_settings_can_persist_self_signed_debug_mode(tmp_path: Any) -> No
|
|||||||
request_timeout=30,
|
request_timeout=30,
|
||||||
verify_ssl=True,
|
verify_ssl=True,
|
||||||
demo_mode=False,
|
demo_mode=False,
|
||||||
|
debug_mode=True,
|
||||||
remembered_account="",
|
remembered_account="",
|
||||||
)
|
)
|
||||||
window = LoginWindow(object(), config=config, settings=settings)
|
window = LoginWindow(object(), config=config, settings=settings)
|
||||||
@@ -402,6 +454,7 @@ def test_login_applies_self_signed_setting_before_authentication(
|
|||||||
config = AppConfig(
|
config = AppConfig(
|
||||||
api_base_url="https://internal.example.test/adminapi",
|
api_base_url="https://internal.example.test/adminapi",
|
||||||
demo_mode=False,
|
demo_mode=False,
|
||||||
|
debug_mode=True,
|
||||||
verify_ssl=True,
|
verify_ssl=True,
|
||||||
)
|
)
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
@@ -494,6 +547,7 @@ def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
|||||||
request_timeout=30,
|
request_timeout=30,
|
||||||
verify_ssl=True,
|
verify_ssl=True,
|
||||||
demo_mode=False,
|
demo_mode=False,
|
||||||
|
debug_mode=True,
|
||||||
remembered_account="",
|
remembered_account="",
|
||||||
)
|
)
|
||||||
window = LoginWindow(object(), config=config, settings=settings)
|
window = LoginWindow(object(), config=config, settings=settings)
|
||||||
@@ -505,3 +559,62 @@ def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
|||||||
assert "信任自签名证书" in window.error_banner.label.text()
|
assert "信任自签名证书" in window.error_banner.label.text()
|
||||||
window.close()
|
window.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_certificate_error_does_not_reveal_production_server_settings(tmp_path: Any) -> None:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
settings = QSettings(str(tmp_path / "production-certificate.ini"), QSettings.Format.IniFormat)
|
||||||
|
config = SimpleNamespace(
|
||||||
|
api_base_url="https://prod.example.test/adminapi",
|
||||||
|
request_timeout=30,
|
||||||
|
verify_ssl=True,
|
||||||
|
demo_mode=False,
|
||||||
|
debug_mode=False,
|
||||||
|
remembered_account="",
|
||||||
|
)
|
||||||
|
window = LoginWindow(object(), config=config, settings=settings)
|
||||||
|
window.show()
|
||||||
|
|
||||||
|
window._on_login_error(RuntimeError("[SSL: CERTIFICATE_VERIFY_FAILED]"))
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert not window.server_toggle.isChecked()
|
||||||
|
assert not window.debug_settings_section.isVisible()
|
||||||
|
assert window.server_panel.isHidden()
|
||||||
|
assert "联系管理员" in window.error_banner.label.text()
|
||||||
|
window.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_business_dialogs_can_be_maximized_but_prompts_cannot() -> None:
|
||||||
|
"""Dense AI panels and editors were stuck at their constructed size."""
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import QApplication, QDialog, QMessageBox
|
||||||
|
|
||||||
|
from doctor_workstation.ui.theme import allow_dialog_resize
|
||||||
|
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
assert application is not None
|
||||||
|
|
||||||
|
dialog = QDialog()
|
||||||
|
dialog.resize(600, 400)
|
||||||
|
allow_dialog_resize(dialog)
|
||||||
|
flags = dialog.windowFlags()
|
||||||
|
assert flags & Qt.WindowType.WindowMaximizeButtonHint
|
||||||
|
assert flags & Qt.WindowType.WindowMinimizeButtonHint
|
||||||
|
assert dialog.isSizeGripEnabled()
|
||||||
|
dialog.deleteLater()
|
||||||
|
|
||||||
|
# Transient prompts keep their plain frame.
|
||||||
|
prompt = QMessageBox()
|
||||||
|
allow_dialog_resize(prompt)
|
||||||
|
assert not (prompt.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
|
||||||
|
prompt.deleteLater()
|
||||||
|
|
||||||
|
# A dialog that pinned itself to a fixed size keeps that decision.
|
||||||
|
fixed = QDialog()
|
||||||
|
fixed.setFixedSize(420, 300)
|
||||||
|
allow_dialog_resize(fixed)
|
||||||
|
assert not (fixed.windowFlags() & Qt.WindowType.WindowMaximizeButtonHint)
|
||||||
|
fixed.deleteLater()
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import MethodType, SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> No
|
|||||||
|
|
||||||
assert "context.createMediaStreamDestination()" in source
|
assert "context.createMediaStreamDestination()" in source
|
||||||
assert "cloud.getAudioTrack({ processed: true })" in source
|
assert "cloud.getAudioTrack({ processed: true })" in source
|
||||||
assert "userId: activeConfig.targetUserId" in source
|
assert "attachPatientAudioTrack(cloud, activeConfig.targetUserId)" in source
|
||||||
assert "new MediaRecorder(destination.stream" in source
|
assert "new MediaRecorder(destination.stream" in source
|
||||||
assert "recorder.start(1000)" in source
|
assert "recorder.start(1000)" in source
|
||||||
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
|
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
|
||||||
@@ -134,6 +135,12 @@ def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallback
|
|||||||
assert "stream.getAudioTracks()" in source
|
assert "stream.getAudioTracks()" in source
|
||||||
assert "navigator.mediaDevices.getUserMedia" in source
|
assert "navigator.mediaDevices.getUserMedia" in source
|
||||||
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
|
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
|
||||||
|
assert "if (!attached)" in source
|
||||||
|
assert "cloud.getAudioTrack(userId)" in source
|
||||||
|
assert "event.sourceTrack" in source
|
||||||
|
assert "cloud.on('remote-audio-available'" in source
|
||||||
|
assert "localAudioCloud.off('remote-audio-available'" in source
|
||||||
|
assert "!event.userId || event.userId === activeConfig?.userID" in source
|
||||||
assert "localRecordingAttachedSourceCount <= 0" in source
|
assert "localRecordingAttachedSourceCount <= 0" in source
|
||||||
assert "localRecordingBytes < 1024" in source
|
assert "localRecordingBytes < 1024" in source
|
||||||
assert "已阻止上传空文件" in source
|
assert "已阻止上传空文件" in source
|
||||||
@@ -201,6 +208,360 @@ def test_companion_shows_incremental_subtitles_but_only_persists_final_segments(
|
|||||||
assert "caption.text" in component_source
|
assert "caption.text" in component_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_companion_keeps_transcript_and_patient_case_visible_in_a_side_rail() -> None:
|
||||||
|
companion_root = PROJECT_ROOT / "video_companion" / "src"
|
||||||
|
main_source = (companion_root / "main.ts").read_text(encoding="utf-8")
|
||||||
|
component_source = (companion_root / "App.vue").read_text(encoding="utf-8")
|
||||||
|
styles = (companion_root / "style.css").read_text(encoding="utf-8")
|
||||||
|
window_source = (
|
||||||
|
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "liveCaptions.value = [...previous, caption].slice(-120)" in main_source
|
||||||
|
assert "liveCaptionClearTimer" not in main_source
|
||||||
|
assert 'aria-label="患者病例与实时对话"' in component_source
|
||||||
|
assert 'id="patient-case-title"' in component_source
|
||||||
|
assert 'id="live-transcript-title"' in component_source
|
||||||
|
assert 'aria-label="打开完整诊单"' in component_source
|
||||||
|
assert "runAction(onOpenDiagnosis)" in component_source
|
||||||
|
assert "detail.clinicalDiagnosis" in component_source
|
||||||
|
assert 'v-for="field in caseFields"' in component_source
|
||||||
|
assert "{{ field.value }}" in component_source
|
||||||
|
assert "{{ caption.time }}" in component_source
|
||||||
|
assert "'caption-entry--partial': !caption.completed" in component_source
|
||||||
|
assert ':allowed-full-screen="false"' in component_source
|
||||||
|
assert ".video-layer--with-rail" in styles
|
||||||
|
assert ".consultation-rail" in styles
|
||||||
|
assert '"patientCase": self.patient_case' in window_source
|
||||||
|
assert 'event == "open-diagnosis-request"' in window_source
|
||||||
|
assert "QTimer.singleShot(0, self._open_diagnosis_safely)" in window_source
|
||||||
|
stop_source = main_source.split("async function stopTranscription", 1)[1].split(
|
||||||
|
"function transcriptionResult", 1
|
||||||
|
)[0]
|
||||||
|
assert "clearLiveCaptions()" not in stop_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_diagnosis_entry_uses_existing_permission_scoped_drawer() -> None:
|
||||||
|
app_source = (
|
||||||
|
PROJECT_ROOT / "src" / "doctor_workstation" / "app.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
launcher_source = (
|
||||||
|
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "launcher.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
main_source = (
|
||||||
|
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
shell_source = (
|
||||||
|
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "shell.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
diagnosis_source = (
|
||||||
|
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "dialogs" / "diagnosis.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
styles = (
|
||||||
|
PROJECT_ROOT / "video_companion" / "src" / "style.css"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "shell.open_diagnosis_by_id(diagnosis_id, modeless=True)" in app_source
|
||||||
|
assert "self._show_video_preview(video_window, dialog)" in app_source
|
||||||
|
assert "WindowStaysOnTopHint" in app_source
|
||||||
|
assert "dialog.finished.connect" in app_source
|
||||||
|
assert "modeless=modeless" in shell_source
|
||||||
|
assert "not modeless and not self._standalone_readonly" in diagnosis_source
|
||||||
|
compact_styles = styles.split("@media (max-width: 700px)", 1)[1]
|
||||||
|
assert ".consultation-rail { display: none; }" in compact_styles
|
||||||
|
assert ".capture-button { display: none; }" in compact_styles
|
||||||
|
assert "on_open_diagnosis=on_open_diagnosis" in launcher_source
|
||||||
|
assert "event: 'open-diagnosis-request'" in main_source
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_preview_is_compact_and_restores_after_diagnosis_closes() -> None:
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
|
from doctor_workstation.app import ApplicationController
|
||||||
|
|
||||||
|
class _Rect:
|
||||||
|
def x(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def y(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def width(self) -> int:
|
||||||
|
return 1920
|
||||||
|
|
||||||
|
def height(self) -> int:
|
||||||
|
return 1040
|
||||||
|
|
||||||
|
class _Screen:
|
||||||
|
def availableGeometry(self) -> _Rect: # noqa: N802 - Qt-compatible double
|
||||||
|
return _Rect()
|
||||||
|
|
||||||
|
class _Window:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.original_geometry = object()
|
||||||
|
self.original_minimum = object()
|
||||||
|
self.minimum = self.original_minimum
|
||||||
|
self.geometry_value = self.original_geometry
|
||||||
|
self.size = (900, 600)
|
||||||
|
self.position = (30, 40)
|
||||||
|
self.stays_on_top = False
|
||||||
|
self.activated = 0
|
||||||
|
|
||||||
|
def geometry(self) -> object:
|
||||||
|
return self.geometry_value
|
||||||
|
|
||||||
|
def minimumSize(self) -> object: # noqa: N802 - Qt-compatible double
|
||||||
|
return self.minimum
|
||||||
|
|
||||||
|
def isMaximized(self) -> bool: # noqa: N802 - Qt-compatible double
|
||||||
|
return False
|
||||||
|
|
||||||
|
def isFullScreen(self) -> bool: # noqa: N802 - Qt-compatible double
|
||||||
|
return False
|
||||||
|
|
||||||
|
def windowFlags(self) -> Qt.WindowType: # noqa: N802 - Qt-compatible double
|
||||||
|
return Qt.WindowType.Window
|
||||||
|
|
||||||
|
def screen(self) -> _Screen:
|
||||||
|
return _Screen()
|
||||||
|
|
||||||
|
def showNormal(self) -> None: # noqa: N802 - Qt-compatible double
|
||||||
|
return None
|
||||||
|
|
||||||
|
def showMaximized(self) -> None: # noqa: N802 - Qt-compatible double
|
||||||
|
return None
|
||||||
|
|
||||||
|
def showFullScreen(self) -> None: # noqa: N802 - Qt-compatible double
|
||||||
|
return None
|
||||||
|
|
||||||
|
def setMinimumSize(self, *value: object) -> None: # noqa: N802
|
||||||
|
self.minimum = value[0] if len(value) == 1 else value
|
||||||
|
|
||||||
|
def setWindowFlag(self, _flag: Any, enabled: bool) -> None: # noqa: N802
|
||||||
|
self.stays_on_top = enabled
|
||||||
|
|
||||||
|
def resize(self, width: int, height: int) -> None:
|
||||||
|
self.size = (width, height)
|
||||||
|
|
||||||
|
def move(self, x: int, y: int) -> None:
|
||||||
|
self.position = (x, y)
|
||||||
|
|
||||||
|
def setGeometry(self, geometry: object) -> None: # noqa: N802
|
||||||
|
self.geometry_value = geometry
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def raise_(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible double
|
||||||
|
self.activated += 1
|
||||||
|
|
||||||
|
class _Signal:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.callbacks: list[Any] = []
|
||||||
|
|
||||||
|
def connect(self, callback: Any) -> None:
|
||||||
|
self.callbacks.append(callback)
|
||||||
|
|
||||||
|
class _Dialog:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.finished = _Signal()
|
||||||
|
|
||||||
|
def raise_(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def activateWindow(self) -> None: # noqa: N802 - Qt-compatible double
|
||||||
|
return None
|
||||||
|
|
||||||
|
controller = SimpleNamespace(
|
||||||
|
_video_preview_state=None,
|
||||||
|
_video_preview_generation=0,
|
||||||
|
)
|
||||||
|
controller._restore_video_preview = MethodType( # type: ignore[attr-defined]
|
||||||
|
ApplicationController._restore_video_preview,
|
||||||
|
controller,
|
||||||
|
)
|
||||||
|
window = _Window()
|
||||||
|
dialog = _Dialog()
|
||||||
|
|
||||||
|
ApplicationController._show_video_preview(controller, window, dialog)
|
||||||
|
|
||||||
|
assert window.minimum == (440, 300)
|
||||||
|
assert window.size == (540, 356)
|
||||||
|
assert window.position == (1362, 18)
|
||||||
|
assert window.stays_on_top is True
|
||||||
|
assert len(dialog.finished.callbacks) == 1
|
||||||
|
|
||||||
|
dialog.finished.callbacks[0](0)
|
||||||
|
|
||||||
|
assert window.minimum is window.original_minimum
|
||||||
|
assert window.geometry_value is window.original_geometry
|
||||||
|
assert window.stays_on_top is False
|
||||||
|
assert window.activated == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_patient_case_snapshot_is_bounded_and_clinically_useful() -> None:
|
||||||
|
from doctor_workstation.app import _build_video_patient_case
|
||||||
|
|
||||||
|
summary = _build_video_patient_case(
|
||||||
|
{
|
||||||
|
"diagnosis": {
|
||||||
|
"patient_name": "张三",
|
||||||
|
"id": 8279,
|
||||||
|
"source_patient_id": 42,
|
||||||
|
"gender": 1,
|
||||||
|
"age": 47,
|
||||||
|
"chief_complaint": "反复口渴三个月",
|
||||||
|
"present_illness": "近期空腹血糖偏高",
|
||||||
|
"allergy_history": "青霉素",
|
||||||
|
"current_medicine": ["二甲双胍", "阿卡波糖"],
|
||||||
|
"clinical_diagnosis": "2 型糖尿病",
|
||||||
|
},
|
||||||
|
"appointment": {"appointment_date": "2026-08-26"},
|
||||||
|
"internal_audit": {"token": "must-not-cross-the-bridge"},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
diagnosis_id=8279,
|
||||||
|
patient_id=42,
|
||||||
|
patient_name="患者",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["diagnosisId"] == "8279"
|
||||||
|
assert summary["name"] == "张三"
|
||||||
|
assert summary["gender"] == "男"
|
||||||
|
assert summary["age"] == "47"
|
||||||
|
assert summary["chiefComplaint"] == "反复口渴三个月"
|
||||||
|
assert summary["currentMedication"] == "二甲双胍、阿卡波糖"
|
||||||
|
assert summary["allergyHistory"] == "青霉素"
|
||||||
|
assert "internal_audit" not in summary
|
||||||
|
assert set(summary) == {
|
||||||
|
"diagnosisId",
|
||||||
|
"name",
|
||||||
|
"gender",
|
||||||
|
"age",
|
||||||
|
"height",
|
||||||
|
"weight",
|
||||||
|
"diagnosisDate",
|
||||||
|
"appointmentDate",
|
||||||
|
"clinicalDiagnosis",
|
||||||
|
"chiefComplaint",
|
||||||
|
"presentIllness",
|
||||||
|
"pastHistory",
|
||||||
|
"allergyHistory",
|
||||||
|
"personalHistory",
|
||||||
|
"familyHistory",
|
||||||
|
"currentMedication",
|
||||||
|
"tongue",
|
||||||
|
"pulse",
|
||||||
|
"prescriptionOpinion",
|
||||||
|
"remark",
|
||||||
|
}
|
||||||
|
|
||||||
|
bounded = _build_video_patient_case(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
"diagnosis_id": 8279,
|
||||||
|
"patient_id": 42,
|
||||||
|
"patient_name": "患" * 180,
|
||||||
|
"age": "4" * 40,
|
||||||
|
"remark": "病" * 2400,
|
||||||
|
},
|
||||||
|
diagnosis_id=8279,
|
||||||
|
patient_id=42,
|
||||||
|
patient_name="患者",
|
||||||
|
)
|
||||||
|
assert len(bounded["name"]) == 120
|
||||||
|
assert len(bounded["age"]) == 20
|
||||||
|
assert len(bounded["remark"]) == 2000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"detail",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 8279,
|
||||||
|
"source_patient_id": 42,
|
||||||
|
"patient_name": "张三",
|
||||||
|
"chief_complaint": "口渴",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 8279,
|
||||||
|
"source_patient_id": 42,
|
||||||
|
"patient_name": "张三",
|
||||||
|
"chief_complaint": "口渴",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"diagnosis": {
|
||||||
|
"id": 8279,
|
||||||
|
"source_patient_id": 42,
|
||||||
|
"patient_name": "张三",
|
||||||
|
"chief_complaint": "口渴",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_video_patient_case_accepts_supported_readonly_detail_shapes(detail: object) -> None:
|
||||||
|
from doctor_workstation.app import _build_video_patient_case
|
||||||
|
|
||||||
|
summary = _build_video_patient_case(
|
||||||
|
detail,
|
||||||
|
{},
|
||||||
|
diagnosis_id=8279,
|
||||||
|
patient_id=42,
|
||||||
|
patient_name="患者",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["name"] == "张三"
|
||||||
|
assert summary["chiefComplaint"] == "口渴"
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_patient_case_fails_closed_on_identity_mismatch() -> None:
|
||||||
|
from doctor_workstation.app import _build_video_patient_case
|
||||||
|
|
||||||
|
summary = _build_video_patient_case(
|
||||||
|
{
|
||||||
|
"diagnosis": {
|
||||||
|
"id": 9001,
|
||||||
|
"source_patient_id": 7,
|
||||||
|
"patient_name": "其他患者",
|
||||||
|
"chief_complaint": "不得展示",
|
||||||
|
"allergy_history": "不得展示",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"diagnosis_id": 9001,
|
||||||
|
"patient_id": 7,
|
||||||
|
"chief_complaint": "也不得展示",
|
||||||
|
},
|
||||||
|
diagnosis_id=8279,
|
||||||
|
patient_id=42,
|
||||||
|
patient_name="张三",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["name"] == "张三"
|
||||||
|
assert summary["chiefComplaint"] == ""
|
||||||
|
assert summary["allergyHistory"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_built_video_companion_contains_the_patient_case_rail() -> None:
|
||||||
|
dist_root = PROJECT_ROOT / "video_companion" / "dist"
|
||||||
|
styles = "\n".join(
|
||||||
|
path.read_text(encoding="utf-8") for path in (dist_root / "assets").glob("*.css")
|
||||||
|
)
|
||||||
|
scripts = "\n".join(
|
||||||
|
path.read_text(encoding="utf-8") for path in (dist_root / "assets").glob("*.js")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ".consultation-rail" in styles
|
||||||
|
assert ".video-layer--with-rail" in styles
|
||||||
|
assert "patientCase" in scripts
|
||||||
|
assert "患者病例与实时对话" in scripts
|
||||||
|
|
||||||
|
|
||||||
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
|
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
|
||||||
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
|
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
|
|||||||
Generated
+1
-2
@@ -746,7 +746,6 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zhenyang-doctor-workstation"
|
name = "zhenyang-doctor-workstation"
|
||||||
version = "0.1.0"
|
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
@@ -778,7 +777,7 @@ requires-dist = [
|
|||||||
{ name = "python-dotenv", specifier = ">=1.0.1,<2" },
|
{ name = "python-dotenv", specifier = ">=1.0.1,<2" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9,<1" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9,<1" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev", "build"]
|
provides-extras = ["build", "dev"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zipp"
|
name = "zipp"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+108
-108
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<meta name="color-scheme" content="light" />
|
<meta name="color-scheme" content="light" />
|
||||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||||
<title>视频面诊</title>
|
<title>视频面诊</title>
|
||||||
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
|
<script type="module" crossorigin src="./assets/index-B_ek5NUi.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+220
-57
@@ -18,9 +18,39 @@ interface LiveCaption {
|
|||||||
id: string
|
id: string
|
||||||
speaker: string
|
speaker: string
|
||||||
text: string
|
text: string
|
||||||
|
time: string
|
||||||
completed: boolean
|
completed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PatientCase {
|
||||||
|
diagnosisId: string
|
||||||
|
name: string
|
||||||
|
gender: string
|
||||||
|
age: string
|
||||||
|
height: string
|
||||||
|
weight: string
|
||||||
|
diagnosisDate: string
|
||||||
|
appointmentDate: string
|
||||||
|
clinicalDiagnosis: string
|
||||||
|
chiefComplaint: string
|
||||||
|
presentIllness: string
|
||||||
|
pastHistory: string
|
||||||
|
allergyHistory: string
|
||||||
|
personalHistory: string
|
||||||
|
familyHistory: string
|
||||||
|
currentMedication: string
|
||||||
|
tongue: string
|
||||||
|
pulse: string
|
||||||
|
prescriptionOpinion: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaseField {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
risk?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
phase: Readonly<Ref<string>>
|
phase: Readonly<Ref<string>>
|
||||||
statusText: Readonly<Ref<string>>
|
statusText: Readonly<Ref<string>>
|
||||||
@@ -34,12 +64,14 @@ const props = defineProps<{
|
|||||||
transcriptionState: Readonly<Ref<string>>
|
transcriptionState: Readonly<Ref<string>>
|
||||||
localRecordingState: Readonly<Ref<string>>
|
localRecordingState: Readonly<Ref<string>>
|
||||||
liveCaptions: Readonly<Ref<LiveCaption[]>>
|
liveCaptions: Readonly<Ref<LiveCaption[]>>
|
||||||
|
patientCase: Readonly<Ref<PatientCase>>
|
||||||
onSendText: (text: string) => Promise<void>
|
onSendText: (text: string) => Promise<void>
|
||||||
onSendAttachment: (file: File) => Promise<void>
|
onSendAttachment: (file: File) => Promise<void>
|
||||||
onLoadMore: () => Promise<void>
|
onLoadMore: () => Promise<void>
|
||||||
onReconnectChat: () => Promise<void>
|
onReconnectChat: () => Promise<void>
|
||||||
onStartVideo: () => Promise<void>
|
onStartVideo: () => Promise<void>
|
||||||
onHangup: () => Promise<void>
|
onHangup: () => Promise<void>
|
||||||
|
onOpenDiagnosis: () => Promise<void>
|
||||||
onSaveScreenshot: (dataUrl: string) => Promise<void>
|
onSaveScreenshot: (dataUrl: string) => Promise<void>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -50,6 +82,7 @@ const messageList = ref<HTMLElement | null>(null)
|
|||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const screenshotPreview = ref('')
|
const screenshotPreview = ref('')
|
||||||
const stickToMessageBottom = ref(true)
|
const stickToMessageBottom = ref(true)
|
||||||
|
const transcriptList = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const isChat = computed(() => props.mode.value === 'chat')
|
const isChat = computed(() => props.mode.value === 'chat')
|
||||||
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
|
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
|
||||||
@@ -84,6 +117,41 @@ const transcriptionStatusText = computed(() => {
|
|||||||
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
|
if (textState === 'error') return '实时转写失败,本机录音仍在运行'
|
||||||
return '录音与转写已结束'
|
return '录音与转写已结束'
|
||||||
})
|
})
|
||||||
|
const patientMetaText = computed(() => {
|
||||||
|
const detail = props.patientCase.value
|
||||||
|
return [
|
||||||
|
detail.gender,
|
||||||
|
detail.age ? `${detail.age}岁` : '',
|
||||||
|
detail.height ? `${detail.height} cm` : '',
|
||||||
|
detail.weight ? `${detail.weight} kg` : '',
|
||||||
|
].filter(Boolean).join(' · ') || '基础资料待补充'
|
||||||
|
})
|
||||||
|
const patientVisitText = computed(() => {
|
||||||
|
const detail = props.patientCase.value
|
||||||
|
if (detail.appointmentDate) return `预约 ${detail.appointmentDate}`
|
||||||
|
if (detail.diagnosisDate) return `诊断 ${detail.diagnosisDate}`
|
||||||
|
return `诊单 ${detail.diagnosisId || '—'}`
|
||||||
|
})
|
||||||
|
const caseFields = computed<CaseField[]>(() => {
|
||||||
|
const detail = props.patientCase.value
|
||||||
|
const allergyRisk = Boolean(detail.allergyHistory) && !/^(无|否|未发现|无过敏史|none|no)$/i.test(
|
||||||
|
detail.allergyHistory.trim(),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{ label: '临床诊断', value: detail.clinicalDiagnosis },
|
||||||
|
{ label: '主诉', value: detail.chiefComplaint },
|
||||||
|
{ label: '现病史', value: detail.presentIllness },
|
||||||
|
{ label: '当前用药', value: detail.currentMedication },
|
||||||
|
{ label: '过敏史', value: detail.allergyHistory, risk: allergyRisk },
|
||||||
|
{ label: '既往史', value: detail.pastHistory },
|
||||||
|
{ label: '个人史', value: detail.personalHistory },
|
||||||
|
{ label: '家族史', value: detail.familyHistory },
|
||||||
|
{ label: '舌象', value: detail.tongue },
|
||||||
|
{ label: '脉象', value: detail.pulse },
|
||||||
|
{ label: '处方意见', value: detail.prescriptionOpinion },
|
||||||
|
{ label: '病例备注', value: detail.remark },
|
||||||
|
].filter((item) => Boolean(item.value))
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.messages.value.length,
|
() => props.messages.value.length,
|
||||||
@@ -103,6 +171,17 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => {
|
||||||
|
const last = props.liveCaptions.value.at(-1)
|
||||||
|
return `${props.liveCaptions.value.length}:${last?.id || ''}:${last?.text || ''}`
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (transcriptList.value) transcriptList.value.scrollTop = transcriptList.value.scrollHeight
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
function handleMessageScroll(): void {
|
function handleMessageScroll(): void {
|
||||||
const container = messageList.value
|
const container = messageList.value
|
||||||
if (!container) return
|
if (!container) return
|
||||||
@@ -335,70 +414,154 @@ watch(
|
|||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="videoVisible" class="video-layer" :class="{ 'video-layer--overlay': isChat }">
|
<section
|
||||||
<TUICallKit
|
v-if="videoVisible"
|
||||||
class="call-kit"
|
class="video-layer"
|
||||||
:allowed-minimized="false"
|
:class="{
|
||||||
:allowed-full-screen="true"
|
'video-layer--overlay': isChat,
|
||||||
/>
|
'video-layer--with-rail': isCalling,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="video-stage">
|
||||||
|
<TUICallKit
|
||||||
|
class="call-kit"
|
||||||
|
:allowed-minimized="false"
|
||||||
|
:allowed-full-screen="false"
|
||||||
|
/>
|
||||||
|
|
||||||
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
|
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
|
||||||
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">中医视频问诊</p>
|
<p class="eyebrow">中医视频问诊</p>
|
||||||
<h2>{{ statusText.value }}</h2>
|
<h2>{{ statusText.value }}</h2>
|
||||||
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
|
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-else class="live-status" role="status">
|
||||||
|
<span class="status-dot status-dot--live" aria-hidden="true" />
|
||||||
|
{{ statusText.value }}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<div v-else class="live-status" role="status">
|
<div v-if="isCalling" class="video-actions">
|
||||||
<span class="status-dot status-dot--live" aria-hidden="true" />
|
<div
|
||||||
{{ statusText.value }}
|
v-if="canCapture"
|
||||||
</div>
|
class="recording-status"
|
||||||
|
:class="{
|
||||||
|
'recording-status--active': transcriptionActive,
|
||||||
|
'recording-status--error': transcriptionFailed,
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span class="recording-indicator" aria-hidden="true" />
|
||||||
|
{{ transcriptionStatusText }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="capture-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canCapture || actionBusy"
|
||||||
|
@click="captureScreenshot"
|
||||||
|
>
|
||||||
|
截屏预览
|
||||||
|
</button>
|
||||||
|
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
|
||||||
|
结束视频
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
|
||||||
v-if="canCapture && liveCaptions.value.length"
|
{{ localError || notice.value }}
|
||||||
class="live-captions"
|
|
||||||
role="log"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label="实时语音字幕"
|
|
||||||
>
|
|
||||||
<p v-for="caption in liveCaptions.value" :key="caption.id">
|
|
||||||
<strong>{{ caption.speaker }}</strong>
|
|
||||||
<span>{{ caption.text }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isCalling" class="video-actions">
|
|
||||||
<div
|
|
||||||
v-if="canCapture"
|
|
||||||
class="recording-status"
|
|
||||||
:class="{
|
|
||||||
'recording-status--active': transcriptionActive,
|
|
||||||
'recording-status--error': transcriptionFailed,
|
|
||||||
}"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<span class="recording-indicator" aria-hidden="true" />
|
|
||||||
{{ transcriptionStatusText }}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
class="capture-button"
|
|
||||||
type="button"
|
|
||||||
:disabled="!canCapture || actionBusy"
|
|
||||||
@click="captureScreenshot"
|
|
||||||
>
|
|
||||||
截屏预览
|
|
||||||
</button>
|
|
||||||
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
|
|
||||||
结束视频
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
|
<aside v-if="isCalling" class="consultation-rail" aria-label="患者病例与实时对话">
|
||||||
{{ localError || notice.value }}
|
<header class="consultation-rail__header">
|
||||||
</div>
|
<div class="consultation-rail__avatar" aria-hidden="true">
|
||||||
|
{{ (patientCase.value.name || patientName.value).slice(0, 1) }}
|
||||||
|
</div>
|
||||||
|
<div class="consultation-rail__identity">
|
||||||
|
<strong>{{ patientCase.value.name || patientName.value }}</strong>
|
||||||
|
<span>{{ patientMetaText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="consultation-rail__actions">
|
||||||
|
<span class="diagnosis-chip">诊单 {{ patientCase.value.diagnosisId || '—' }}</span>
|
||||||
|
<button
|
||||||
|
class="open-diagnosis-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="actionBusy"
|
||||||
|
aria-label="打开完整诊单"
|
||||||
|
@click="runAction(onOpenDiagnosis)"
|
||||||
|
>
|
||||||
|
打开诊单
|
||||||
|
<span aria-hidden="true">↗</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="case-panel" aria-labelledby="patient-case-title">
|
||||||
|
<header class="rail-section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="rail-section-heading__index">01</span>
|
||||||
|
<h2 id="patient-case-title">患者病例</h2>
|
||||||
|
</div>
|
||||||
|
<span>{{ patientVisitText }}</span>
|
||||||
|
</header>
|
||||||
|
<div class="case-panel__content">
|
||||||
|
<div v-if="caseFields.length" class="case-field-list">
|
||||||
|
<article
|
||||||
|
v-for="field in caseFields"
|
||||||
|
:key="field.label"
|
||||||
|
class="case-field"
|
||||||
|
:class="{ 'case-field--risk': field.risk }"
|
||||||
|
>
|
||||||
|
<span>{{ field.label }}</span>
|
||||||
|
<p>{{ field.value }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-else class="rail-empty rail-empty--case">
|
||||||
|
<strong>暂无已填写的病例内容</strong>
|
||||||
|
<span>可继续通话,已补充的病例会在下次打开时显示。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="transcript-panel" aria-labelledby="live-transcript-title">
|
||||||
|
<header class="rail-section-heading">
|
||||||
|
<div>
|
||||||
|
<span class="rail-section-heading__index">02</span>
|
||||||
|
<h2 id="live-transcript-title">实时对话</h2>
|
||||||
|
</div>
|
||||||
|
<span class="transcript-state" :class="{ 'transcript-state--active': transcriptionActive }">
|
||||||
|
{{ transcriptionActive ? '转写中' : '等待语音' }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
ref="transcriptList"
|
||||||
|
class="live-captions"
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="实时语音字幕"
|
||||||
|
>
|
||||||
|
<div v-if="!liveCaptions.value.length" class="rail-empty">
|
||||||
|
<strong>对话文字会显示在这里</strong>
|
||||||
|
<span>接通后自动识别医生与患者语音,并保留本次通话内容。</span>
|
||||||
|
</div>
|
||||||
|
<article
|
||||||
|
v-for="caption in liveCaptions.value"
|
||||||
|
:key="caption.id"
|
||||||
|
class="caption-entry"
|
||||||
|
:class="{ 'caption-entry--partial': !caption.completed }"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<strong>{{ caption.speaker }}</strong>
|
||||||
|
<time>{{ caption.time }}</time>
|
||||||
|
</header>
|
||||||
|
<p>{{ caption.text }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="screenshotPreview"
|
v-if="screenshotPreview"
|
||||||
|
|||||||
Vendored
+24
@@ -15,9 +15,33 @@ interface DoctorCallConfig {
|
|||||||
patientUserId?: string
|
patientUserId?: string
|
||||||
diagnosisId: number | string
|
diagnosisId: number | string
|
||||||
patientName?: string
|
patientName?: string
|
||||||
|
patientCase?: DoctorPatientCase
|
||||||
mode?: 'chat' | 'video'
|
mode?: 'chat' | 'video'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DoctorPatientCase {
|
||||||
|
diagnosisId?: number | string
|
||||||
|
name?: string
|
||||||
|
gender?: string
|
||||||
|
age?: string | number
|
||||||
|
height?: string | number
|
||||||
|
weight?: string | number
|
||||||
|
diagnosisDate?: string
|
||||||
|
appointmentDate?: string
|
||||||
|
clinicalDiagnosis?: string
|
||||||
|
chiefComplaint?: string
|
||||||
|
presentIllness?: string
|
||||||
|
pastHistory?: string
|
||||||
|
allergyHistory?: string
|
||||||
|
personalHistory?: string
|
||||||
|
familyHistory?: string
|
||||||
|
currentMedication?: string
|
||||||
|
tongue?: string
|
||||||
|
pulse?: string
|
||||||
|
prescriptionOpinion?: string
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface DoctorCallApi {
|
interface DoctorCallApi {
|
||||||
start(config: DoctorCallConfig): Promise<void>
|
start(config: DoctorCallConfig): Promise<void>
|
||||||
hangup(): Promise<void>
|
hangup(): Promise<void>
|
||||||
|
|||||||
+196
-44
@@ -26,6 +26,7 @@ interface NormalizedCallConfig {
|
|||||||
targetUserId: string
|
targetUserId: string
|
||||||
diagnosisId: number | string
|
diagnosisId: number | string
|
||||||
patientName: string
|
patientName: string
|
||||||
|
patientCase: UiPatientCase
|
||||||
mode: CompanionMode
|
mode: CompanionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +45,33 @@ interface UiLiveCaption {
|
|||||||
id: string
|
id: string
|
||||||
speaker: string
|
speaker: string
|
||||||
text: string
|
text: string
|
||||||
|
time: string
|
||||||
completed: boolean
|
completed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UiPatientCase {
|
||||||
|
diagnosisId: string
|
||||||
|
name: string
|
||||||
|
gender: string
|
||||||
|
age: string
|
||||||
|
height: string
|
||||||
|
weight: string
|
||||||
|
diagnosisDate: string
|
||||||
|
appointmentDate: string
|
||||||
|
clinicalDiagnosis: string
|
||||||
|
chiefComplaint: string
|
||||||
|
presentIllness: string
|
||||||
|
pastHistory: string
|
||||||
|
allergyHistory: string
|
||||||
|
personalHistory: string
|
||||||
|
familyHistory: string
|
||||||
|
currentMedication: string
|
||||||
|
tongue: string
|
||||||
|
pulse: string
|
||||||
|
prescriptionOpinion: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
interface BridgeMessage {
|
interface BridgeMessage {
|
||||||
source: 'doctor-call'
|
source: 'doctor-call'
|
||||||
event:
|
event:
|
||||||
@@ -56,6 +81,7 @@ interface BridgeMessage {
|
|||||||
| 'room'
|
| 'room'
|
||||||
| 'hangup'
|
| 'hangup'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
| 'open-diagnosis-request'
|
||||||
| 'transcription-start-request'
|
| 'transcription-start-request'
|
||||||
| 'transcription-segment'
|
| 'transcription-segment'
|
||||||
| 'transcription-stop'
|
| 'transcription-stop'
|
||||||
@@ -127,6 +153,11 @@ interface PendingLocalRecordingReply {
|
|||||||
interface TrtcAudioTrackEvent {
|
interface TrtcAudioTrackEvent {
|
||||||
userId?: string
|
userId?: string
|
||||||
track?: MediaStreamTrack
|
track?: MediaStreamTrack
|
||||||
|
sourceTrack?: MediaStreamTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrtcRemoteAudioEvent {
|
||||||
|
userId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrtcAudioCloud {
|
interface TrtcAudioCloud {
|
||||||
@@ -135,7 +166,9 @@ interface TrtcAudioCloud {
|
|||||||
processed?: boolean
|
processed?: boolean
|
||||||
} | string): MediaStreamTrack | null
|
} | string): MediaStreamTrack | null
|
||||||
on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
|
on?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
|
||||||
|
on?(event: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
|
||||||
off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
|
off?(event: 'track', handler: (event: TrtcAudioTrackEvent) => void): void
|
||||||
|
off?(event: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = ref<CallPhase>('ready')
|
const phase = ref<CallPhase>('ready')
|
||||||
@@ -150,6 +183,7 @@ const hasMoreMessages = ref(false)
|
|||||||
const transcriptionState = ref<TranscriptionState>('idle')
|
const transcriptionState = ref<TranscriptionState>('idle')
|
||||||
const localRecordingState = ref<LocalRecordingState>('idle')
|
const localRecordingState = ref<LocalRecordingState>('idle')
|
||||||
const liveCaptions = ref<UiLiveCaption[]>([])
|
const liveCaptions = ref<UiLiveCaption[]>([])
|
||||||
|
const patientCase = ref<UiPatientCase>(emptyPatientCase())
|
||||||
|
|
||||||
let activeConfig: NormalizedCallConfig | null = null
|
let activeConfig: NormalizedCallConfig | null = null
|
||||||
let chat: any = null
|
let chat: any = null
|
||||||
@@ -192,6 +226,7 @@ let localAudioContext: AudioContext | null = null
|
|||||||
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
|
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
|
||||||
let localAudioCloud: TrtcAudioCloud | null = null
|
let localAudioCloud: TrtcAudioCloud | null = null
|
||||||
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
|
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
|
||||||
|
let localRemoteAudioAvailableHandler: ((event: TrtcRemoteAudioEvent) => void) | null = null
|
||||||
let localAudioSources: MediaStreamAudioSourceNode[] = []
|
let localAudioSources: MediaStreamAudioSourceNode[] = []
|
||||||
let localAudioTrackIds = new Set<string>()
|
let localAudioTrackIds = new Set<string>()
|
||||||
let localAudioOwnedTracks: MediaStreamTrack[] = []
|
let localAudioOwnedTracks: MediaStreamTrack[] = []
|
||||||
@@ -205,8 +240,6 @@ let localRecordingChunkChain: Promise<void> = Promise.resolve()
|
|||||||
let localRecordingStartPromise: Promise<void> | null = null
|
let localRecordingStartPromise: Promise<void> | null = null
|
||||||
let localRecordingStopPromise: Promise<void> | null = null
|
let localRecordingStopPromise: Promise<void> | null = null
|
||||||
let localRecordingFatalError = ''
|
let localRecordingFatalError = ''
|
||||||
let liveCaptionClearTimer: number | null = null
|
|
||||||
|
|
||||||
function initializeQtWebChannel(): void {
|
function initializeQtWebChannel(): void {
|
||||||
const transport = window.qt?.webChannelTransport
|
const transport = window.qt?.webChannelTransport
|
||||||
const QWebChannel = window.QWebChannel
|
const QWebChannel = window.QWebChannel
|
||||||
@@ -259,11 +292,80 @@ function emit(message: BridgeMessage): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openDiagnosis(): Promise<void> {
|
||||||
|
if (!activeConfig) throw new Error('诊单上下文尚未就绪')
|
||||||
|
emit({
|
||||||
|
source: 'doctor-call',
|
||||||
|
event: 'open-diagnosis-request',
|
||||||
|
diagnosisId: activeConfig.diagnosisId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function cleanString(value: unknown, field: string): string {
|
function cleanString(value: unknown, field: string): string {
|
||||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
|
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
|
||||||
return value.trim()
|
return value.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalText(value: unknown, maxLength = 2000): string {
|
||||||
|
if (value === undefined || value === null) return ''
|
||||||
|
return String(value).trim().slice(0, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPatientCase(): UiPatientCase {
|
||||||
|
return {
|
||||||
|
diagnosisId: '',
|
||||||
|
name: '',
|
||||||
|
gender: '',
|
||||||
|
age: '',
|
||||||
|
height: '',
|
||||||
|
weight: '',
|
||||||
|
diagnosisDate: '',
|
||||||
|
appointmentDate: '',
|
||||||
|
clinicalDiagnosis: '',
|
||||||
|
chiefComplaint: '',
|
||||||
|
presentIllness: '',
|
||||||
|
pastHistory: '',
|
||||||
|
allergyHistory: '',
|
||||||
|
personalHistory: '',
|
||||||
|
familyHistory: '',
|
||||||
|
currentMedication: '',
|
||||||
|
tongue: '',
|
||||||
|
pulse: '',
|
||||||
|
prescriptionOpinion: '',
|
||||||
|
remark: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePatientCase(
|
||||||
|
value: DoctorPatientCase | undefined,
|
||||||
|
diagnosisId: number | string,
|
||||||
|
fallbackName: string,
|
||||||
|
): UiPatientCase {
|
||||||
|
const source = value && typeof value === 'object' ? value : {}
|
||||||
|
return {
|
||||||
|
diagnosisId: optionalText(source.diagnosisId ?? diagnosisId, 80),
|
||||||
|
name: optionalText(source.name || fallbackName, 120) || fallbackName,
|
||||||
|
gender: optionalText(source.gender, 20),
|
||||||
|
age: optionalText(source.age, 20),
|
||||||
|
height: optionalText(source.height, 20),
|
||||||
|
weight: optionalText(source.weight, 20),
|
||||||
|
diagnosisDate: optionalText(source.diagnosisDate, 80),
|
||||||
|
appointmentDate: optionalText(source.appointmentDate, 80),
|
||||||
|
clinicalDiagnosis: optionalText(source.clinicalDiagnosis),
|
||||||
|
chiefComplaint: optionalText(source.chiefComplaint),
|
||||||
|
presentIllness: optionalText(source.presentIllness),
|
||||||
|
pastHistory: optionalText(source.pastHistory),
|
||||||
|
allergyHistory: optionalText(source.allergyHistory),
|
||||||
|
personalHistory: optionalText(source.personalHistory),
|
||||||
|
familyHistory: optionalText(source.familyHistory),
|
||||||
|
currentMedication: optionalText(source.currentMedication),
|
||||||
|
tongue: optionalText(source.tongue),
|
||||||
|
pulse: optionalText(source.pulse),
|
||||||
|
prescriptionOpinion: optionalText(source.prescriptionOpinion),
|
||||||
|
remark: optionalText(source.remark),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
||||||
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
|
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
|
||||||
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
|
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
|
||||||
@@ -274,15 +376,17 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
|||||||
throw new Error('诊单ID不能为空')
|
throw new Error('诊单ID不能为空')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedPatientName = typeof config.patientName === 'string' && config.patientName.trim()
|
||||||
|
? config.patientName.trim()
|
||||||
|
: '患者'
|
||||||
return {
|
return {
|
||||||
SDKAppID,
|
SDKAppID,
|
||||||
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
|
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
|
||||||
userSig: cleanString(config.userSig, '用户签名'),
|
userSig: cleanString(config.userSig, '用户签名'),
|
||||||
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
||||||
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
|
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
|
||||||
patientName: typeof config.patientName === 'string' && config.patientName.trim()
|
patientName: normalizedPatientName,
|
||||||
? config.patientName.trim()
|
patientCase: normalizePatientCase(config.patientCase, diagnosisId, normalizedPatientName),
|
||||||
: '患者',
|
|
||||||
mode: config.mode === 'chat' ? 'chat' : 'video',
|
mode: config.mode === 'chat' ? 'chat' : 'video',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,30 +851,50 @@ function attachLocalRecordingTrack(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachDoctorAudioTrack(cloud: TrtcAudioCloud): boolean {
|
||||||
|
if (typeof cloud.getAudioTrack !== 'function') return false
|
||||||
|
let attached = false
|
||||||
|
try {
|
||||||
|
attached = attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
|
||||||
|
} catch {
|
||||||
|
// Some TRTC versions do not support processed tracks.
|
||||||
|
}
|
||||||
|
if (!attached) {
|
||||||
|
try {
|
||||||
|
attached = attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
|
||||||
|
} catch {
|
||||||
|
// The microphone fallback below remains available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attached
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachPatientAudioTrack(cloud: TrtcAudioCloud, userId: string): boolean {
|
||||||
|
if (typeof cloud.getAudioTrack !== 'function' || !userId) return false
|
||||||
|
let attached = false
|
||||||
|
try {
|
||||||
|
attached = attachLocalRecordingTrack(cloud.getAudioTrack({
|
||||||
|
userId,
|
||||||
|
processed: true,
|
||||||
|
}), 'patient')
|
||||||
|
} catch {
|
||||||
|
// Some TRTC versions do not expose a processed remote track.
|
||||||
|
}
|
||||||
|
if (!attached) {
|
||||||
|
try {
|
||||||
|
attached = attachLocalRecordingTrack(cloud.getAudioTrack(userId), 'patient')
|
||||||
|
} catch {
|
||||||
|
// Remote audio can become available a few frames after connected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attached
|
||||||
|
}
|
||||||
|
|
||||||
function attachCurrentCallAudioTracks(cloud: TrtcAudioCloud | null): void {
|
function attachCurrentCallAudioTracks(cloud: TrtcAudioCloud | null): void {
|
||||||
if (!activeConfig) return
|
if (!activeConfig) return
|
||||||
if (typeof cloud?.getAudioTrack === 'function') {
|
if (cloud) {
|
||||||
try {
|
attachDoctorAudioTrack(cloud)
|
||||||
attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
|
attachPatientAudioTrack(cloud, activeConfig.targetUserId)
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
attachLocalRecordingTrack(cloud.getAudioTrack(), 'doctor')
|
|
||||||
} catch {
|
|
||||||
// The rendered media elements below remain a supported fallback.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
attachLocalRecordingTrack(cloud.getAudioTrack({
|
|
||||||
userId: activeConfig.targetUserId,
|
|
||||||
processed: true,
|
|
||||||
}), 'patient')
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
attachLocalRecordingTrack(cloud.getAudioTrack(activeConfig.targetUserId), 'patient')
|
|
||||||
} catch {
|
|
||||||
// Remote audio can become available a few frames after connected.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const media of document.querySelectorAll<HTMLMediaElement>('video, audio')) {
|
for (const media of document.querySelectorAll<HTMLMediaElement>('video, audio')) {
|
||||||
@@ -852,8 +976,20 @@ async function cleanupLocalRecordingGraph(): Promise<void> {
|
|||||||
// The call engine may already have released its event dispatcher.
|
// The call engine may already have released its event dispatcher.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
localAudioCloud
|
||||||
|
&& localRemoteAudioAvailableHandler
|
||||||
|
&& typeof localAudioCloud.off === 'function'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
localAudioCloud.off('remote-audio-available', localRemoteAudioAvailableHandler)
|
||||||
|
} catch {
|
||||||
|
// The call engine may already have released its event dispatcher.
|
||||||
|
}
|
||||||
|
}
|
||||||
localAudioCloud = null
|
localAudioCloud = null
|
||||||
localAudioTrackHandler = null
|
localAudioTrackHandler = null
|
||||||
|
localRemoteAudioAvailableHandler = null
|
||||||
for (const source of localAudioSources) {
|
for (const source of localAudioSources) {
|
||||||
try {
|
try {
|
||||||
source.disconnect()
|
source.disconnect()
|
||||||
@@ -910,15 +1046,26 @@ async function performStartLocalRecording(): Promise<void> {
|
|||||||
localAudioDestination = context.createMediaStreamDestination()
|
localAudioDestination = context.createMediaStreamDestination()
|
||||||
const cloud = getTrtcAudioCloud()
|
const cloud = getTrtcAudioCloud()
|
||||||
localAudioCloud = cloud
|
localAudioCloud = cloud
|
||||||
localAudioTrackHandler = (event) => attachLocalRecordingTrack(
|
localAudioTrackHandler = (event) => {
|
||||||
event.track,
|
const sourceKind = !event.userId || event.userId === activeConfig?.userID
|
||||||
event.userId === activeConfig?.userID
|
|
||||||
? 'doctor'
|
? 'doctor'
|
||||||
: event.userId === activeConfig?.targetUserId
|
: event.userId === activeConfig?.targetUserId
|
||||||
? 'patient'
|
? 'patient'
|
||||||
: 'unknown',
|
: 'unknown'
|
||||||
)
|
if (!attachLocalRecordingTrack(event.track, sourceKind)) {
|
||||||
if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler)
|
attachLocalRecordingTrack(event.sourceTrack, sourceKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localRemoteAudioAvailableHandler = (event) => {
|
||||||
|
const userId = event.userId || activeConfig?.targetUserId || ''
|
||||||
|
if (cloud && userId === activeConfig?.targetUserId) {
|
||||||
|
attachPatientAudioTrack(cloud, userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof cloud?.on === 'function') {
|
||||||
|
cloud.on('track', localAudioTrackHandler)
|
||||||
|
cloud.on('remote-audio-available', localRemoteAudioAvailableHandler)
|
||||||
|
}
|
||||||
if (context.state === 'suspended') await context.resume()
|
if (context.state === 'suspended') await context.resume()
|
||||||
await waitForCallAudioTracks(cloud, sessionId)
|
await waitForCallAudioTracks(cloud, sessionId)
|
||||||
startCallAudioDiscovery(cloud)
|
startCallAudioDiscovery(cloud)
|
||||||
@@ -1151,10 +1298,6 @@ function getTranscriberManager(): RealtimeTranscriberManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearLiveCaptions(): void {
|
function clearLiveCaptions(): void {
|
||||||
if (liveCaptionClearTimer !== null) {
|
|
||||||
window.clearTimeout(liveCaptionClearTimer)
|
|
||||||
liveCaptionClearTimer = null
|
|
||||||
}
|
|
||||||
liveCaptions.value = []
|
liveCaptions.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,19 +1313,26 @@ function showLiveCaption(message: RealtimeTranscriberMessage): void {
|
|||||||
: speakerUserId === activeConfig.targetUserId
|
: speakerUserId === activeConfig.targetUserId
|
||||||
? patientName.value || '患者'
|
? patientName.value || '患者'
|
||||||
: '对话'
|
: '对话'
|
||||||
|
const rawTimestamp = Number(message.timestamp)
|
||||||
|
const captionTimestamp = Number.isFinite(rawTimestamp) && rawTimestamp > 0
|
||||||
|
? (rawTimestamp < 1_000_000_000_000 ? rawTimestamp * 1000 : rawTimestamp)
|
||||||
|
: Date.now()
|
||||||
const caption: UiLiveCaption = {
|
const caption: UiLiveCaption = {
|
||||||
id,
|
id,
|
||||||
speaker,
|
speaker,
|
||||||
text: text.slice(0, 500),
|
text: text.slice(0, 500),
|
||||||
|
time: new Date(captionTimestamp).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}),
|
||||||
completed: message.isCompleted === true,
|
completed: message.isCompleted === true,
|
||||||
}
|
}
|
||||||
const previous = liveCaptions.value.filter((item) => item.id !== id)
|
const previous = liveCaptions.value.filter((item) => item.id !== id)
|
||||||
liveCaptions.value = [...previous, caption].slice(-2)
|
// Keep the current call's transcript visible in the side rail. Repeated
|
||||||
if (liveCaptionClearTimer !== null) window.clearTimeout(liveCaptionClearTimer)
|
// partial updates replace the same segment and the bounded history prevents
|
||||||
liveCaptionClearTimer = window.setTimeout(() => {
|
// long calls from growing memory without limit.
|
||||||
liveCaptions.value = []
|
liveCaptions.value = [...previous, caption].slice(-120)
|
||||||
liveCaptionClearTimer = null
|
|
||||||
}, message.isCompleted === true ? 9000 : 5000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTranscriberMessage(
|
function handleTranscriberMessage(
|
||||||
@@ -1421,7 +1571,6 @@ async function stopTranscription(
|
|||||||
transcriptionSessionId = ''
|
transcriptionSessionId = ''
|
||||||
acknowledgedSegmentIds.clear()
|
acknowledgedSegmentIds.clear()
|
||||||
pendingSegments.clear()
|
pendingSegments.clear()
|
||||||
clearLiveCaptions()
|
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
transcriptionStopPromise = null
|
transcriptionStopPromise = null
|
||||||
})
|
})
|
||||||
@@ -1720,6 +1869,7 @@ async function open(config: DoctorCallConfig): Promise<void> {
|
|||||||
activeConfig = normalizeConfig(config)
|
activeConfig = normalizeConfig(config)
|
||||||
mode.value = activeConfig.mode
|
mode.value = activeConfig.mode
|
||||||
patientName.value = activeConfig.patientName
|
patientName.value = activeConfig.patientName
|
||||||
|
patientCase.value = activeConfig.patientCase
|
||||||
messages.value = []
|
messages.value = []
|
||||||
nextReqMessageID = ''
|
nextReqMessageID = ''
|
||||||
hasMoreMessages.value = false
|
hasMoreMessages.value = false
|
||||||
@@ -1805,12 +1955,14 @@ createApp(App, {
|
|||||||
transcriptionState: readonly(transcriptionState),
|
transcriptionState: readonly(transcriptionState),
|
||||||
localRecordingState: readonly(localRecordingState),
|
localRecordingState: readonly(localRecordingState),
|
||||||
liveCaptions: readonly(liveCaptions),
|
liveCaptions: readonly(liveCaptions),
|
||||||
|
patientCase: readonly(patientCase),
|
||||||
onSendText: sendText,
|
onSendText: sendText,
|
||||||
onSendAttachment: sendAttachment,
|
onSendAttachment: sendAttachment,
|
||||||
onLoadMore: () => loadMessages(true),
|
onLoadMore: () => loadMessages(true),
|
||||||
onReconnectChat: reconnectChat,
|
onReconnectChat: reconnectChat,
|
||||||
onStartVideo: startVideo,
|
onStartVideo: startVideo,
|
||||||
onHangup: hangup,
|
onHangup: hangup,
|
||||||
|
onOpenDiagnosis: openDiagnosis,
|
||||||
onSaveScreenshot: saveScreenshot,
|
onSaveScreenshot: saveScreenshot,
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
|||||||
|
|
||||||
.video-layer {
|
.video-layer {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
@@ -281,8 +283,21 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
|||||||
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
|
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
|
||||||
#090d14;
|
#090d14;
|
||||||
}
|
}
|
||||||
|
.video-layer--with-rail {
|
||||||
|
grid-template-columns: minmax(0, 1fr) clamp(330px, 31vw, 380px);
|
||||||
|
}
|
||||||
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
|
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
|
||||||
|
|
||||||
|
.video-stage {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
|
||||||
|
#090d14;
|
||||||
|
}
|
||||||
|
|
||||||
.call-kit,
|
.call-kit,
|
||||||
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
@@ -336,40 +351,249 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
|||||||
}
|
}
|
||||||
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
|
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
|
||||||
|
|
||||||
.live-captions {
|
.consultation-rail {
|
||||||
position: absolute;
|
position: relative;
|
||||||
z-index: 38;
|
z-index: 55;
|
||||||
left: 50%;
|
|
||||||
bottom: 92px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
grid-template-rows: auto minmax(0, 1.08fr) minmax(0, .92fr);
|
||||||
width: min(820px, calc(100% - 360px));
|
min-width: 0;
|
||||||
transform: translateX(-50%);
|
min-height: 0;
|
||||||
pointer-events: none;
|
overflow: hidden;
|
||||||
|
border-left: 1px solid #dce3f0;
|
||||||
|
color: #15233a;
|
||||||
|
background: #f6f8fc;
|
||||||
|
box-shadow: -16px 0 38px rgba(4, 12, 26, .16);
|
||||||
}
|
}
|
||||||
.live-captions p {
|
.consultation-rail__header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
gap: 11px;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
width: fit-content;
|
min-height: 74px;
|
||||||
max-width: 100%;
|
padding: 13px 16px;
|
||||||
margin: 0 auto;
|
border-bottom: 1px solid #e2e7f1;
|
||||||
padding: 8px 14px;
|
background: #fff;
|
||||||
border: 1px solid rgba(255, 255, 255, .2);
|
}
|
||||||
border-radius: 10px;
|
.consultation-rail__avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 13px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: rgba(9, 13, 20, .78);
|
background: #5761f4;
|
||||||
box-shadow: 0 6px 24px rgba(0, 0, 0, .2);
|
font-size: 17px;
|
||||||
font-size: 16px;
|
font-weight: 700;
|
||||||
line-height: 1.55;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
.live-captions strong {
|
.consultation-rail__identity {
|
||||||
color: #aeb8ff;
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.consultation-rail__identity strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #111f46;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.live-captions span { min-width: 0; word-break: break-word; }
|
.consultation-rail__identity span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #7886a3;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.diagnosis-chip {
|
||||||
|
padding: 5px 7px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #4a56d5;
|
||||||
|
background: #eef0ff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.consultation-rail__actions {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.open-diagnosis-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #cfd5ff;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #3f4bc5;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.open-diagnosis-button:hover,
|
||||||
|
.open-diagnosis-button:focus-visible {
|
||||||
|
border-color: #6874e7;
|
||||||
|
background: #f3f4ff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.open-diagnosis-button:disabled { cursor: not-allowed; opacity: .55; }
|
||||||
|
.case-panel,
|
||||||
|
.transcript-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.case-panel { border-bottom: 1px solid #dde4f0; }
|
||||||
|
.rail-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #e5e9f2;
|
||||||
|
background: rgba(255, 255, 255, .72);
|
||||||
|
}
|
||||||
|
.rail-section-heading > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.rail-section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1b2945;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
.rail-section-heading > span {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 154px;
|
||||||
|
color: #8b97ad;
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rail-section-heading__index {
|
||||||
|
color: #6874e7;
|
||||||
|
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.case-panel__content,
|
||||||
|
.live-captions {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-color: #b9c3d5 transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.case-panel__content { padding: 5px 16px 14px; }
|
||||||
|
.case-field-list { display: grid; }
|
||||||
|
.case-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #e5e9f2;
|
||||||
|
}
|
||||||
|
.case-field:last-child { border-bottom: 0; }
|
||||||
|
.case-field > span {
|
||||||
|
padding-top: 2px;
|
||||||
|
color: #7a879d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.case-field p {
|
||||||
|
margin: 0;
|
||||||
|
color: #263551;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.case-field--risk > span { color: #bc3e4e; }
|
||||||
|
.case-field--risk p {
|
||||||
|
color: #9f3141;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.live-captions {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 12px 14px 18px;
|
||||||
|
background: #f2f5fa;
|
||||||
|
}
|
||||||
|
.caption-entry {
|
||||||
|
padding: 10px 11px;
|
||||||
|
border-left: 2px solid #6571e8;
|
||||||
|
border-radius: 0 9px 9px 0;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(30, 46, 76, .045);
|
||||||
|
}
|
||||||
|
.caption-entry--partial { border-left-color: #9ca6b7; opacity: .78; }
|
||||||
|
.caption-entry header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.caption-entry strong {
|
||||||
|
color: #4a56d5;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.caption-entry time {
|
||||||
|
color: #9aa5b7;
|
||||||
|
font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.caption-entry p {
|
||||||
|
margin: 0;
|
||||||
|
color: #24334e;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.58;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.transcript-state {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
.transcript-state::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #a5adbb;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
.transcript-state--active { color: #168260 !important; }
|
||||||
|
.transcript-state--active::before {
|
||||||
|
background: #24b987;
|
||||||
|
box-shadow: 0 0 0 3px rgba(36, 185, 135, .12);
|
||||||
|
}
|
||||||
|
.rail-empty {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
align-content: center;
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 18px;
|
||||||
|
color: #8a96aa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.rail-empty strong { color: #53617a; font-size: 12px; }
|
||||||
|
.rail-empty span { font-size: 11px; line-height: 1.55; }
|
||||||
|
.rail-empty--case { min-height: 100%; }
|
||||||
|
|
||||||
.video-actions {
|
.video-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -494,8 +718,54 @@ button:disabled { cursor: not-allowed; opacity: .55; }
|
|||||||
.consultation-shell { min-width: 620px; }
|
.consultation-shell { min-width: 620px; }
|
||||||
.message-list { padding-inline: 18px; }
|
.message-list { padding-inline: 18px; }
|
||||||
.message-bubble { max-width: 82%; }
|
.message-bubble { max-width: 82%; }
|
||||||
.live-captions { width: calc(100% - 36px); bottom: 88px; }
|
}
|
||||||
.live-captions p { font-size: 14px; }
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.consultation-shell { min-width: 760px; }
|
||||||
|
.video-layer--with-rail { grid-template-columns: minmax(0, 1fr) 310px; }
|
||||||
|
.video-actions {
|
||||||
|
right: 12px;
|
||||||
|
bottom: 14px;
|
||||||
|
left: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.recording-status {
|
||||||
|
flex-basis: 100%;
|
||||||
|
width: fit-content;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.consultation-rail__header { padding-inline: 12px; }
|
||||||
|
.diagnosis-chip { display: none; }
|
||||||
|
.case-panel__content { padding-inline: 12px; }
|
||||||
|
.rail-section-heading { padding-inline: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.consultation-shell { min-width: 0; }
|
||||||
|
.video-layer,
|
||||||
|
.video-stage { min-height: 280px; }
|
||||||
|
.video-layer--with-rail { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.consultation-rail { display: none; }
|
||||||
|
.video-actions {
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
left: auto;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.recording-status,
|
||||||
|
.capture-button { display: none; }
|
||||||
|
.video-actions button {
|
||||||
|
padding: 8px 11px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.live-status {
|
||||||
|
top: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Doctor workstation blue-white subwindow contract. Video pixels remain on
|
/* Doctor workstation blue-white subwindow contract. Video pixels remain on
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ services:
|
|||||||
#user: "1000:1000"
|
#user: "1000:1000"
|
||||||
|
|
||||||
|
|
||||||
|
qywx-promotion-welcome:
|
||||||
|
container_name: likeadmin-qywx-promotion-welcome
|
||||||
|
image: registry.cn-guangzhou.aliyuncs.com/likeadmin/php:8.0.30.3-fpm
|
||||||
|
restart: always
|
||||||
|
working_dir: /likeadmin_php/server
|
||||||
|
command: ["php", "think", "qywx:work-promotion-automation"]
|
||||||
|
depends_on:
|
||||||
|
- "mysql"
|
||||||
|
- "redis"
|
||||||
|
volumes:
|
||||||
|
- ../server:/likeadmin_php/server
|
||||||
|
networks:
|
||||||
|
- likeadmin
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
container_name: likeadmin-mysql
|
container_name: likeadmin-mysql
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
# 企业微信推广链接能力核验
|
||||||
|
|
||||||
|
核验日期:2026-08-31。范围:企业自建应用的获客助手、客户联系、客户欢迎语。本文是只读研究交付,没有修改应用代码,也没有调用任何企业的写接口。
|
||||||
|
|
||||||
|
来源均为企业微信开发者中心官方文档。网页搜索工具无法打开部分官方页面,实际通过 HTTPS 读取同一官方 URL 的公开 HTML,提取正文核验;没有以 SDK、博客或第三方镜像作为结论依据。以下标为“实现建议”的内容是本项目的工程设计,不是官方 API 自带能力。
|
||||||
|
|
||||||
|
## 1. 结论与能力边界
|
||||||
|
|
||||||
|
| 功能 | 官方能力 | 本地需要实现的部分 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 获客成员范围 | `create_link` / `update_link` 的 `range.user_list`、`range.department_list` | 成员开关、有效期、星期时段、当日上限计算后写入范围 |
|
||||||
|
| 老客户优先找原员工 | `priority_option`,且仅部分经营类目支持 | 校验经营类目能力;与排班、上限的冲突提示 |
|
||||||
|
| 按星期、时段自动上下线 | 获客链接接口没有排班字段 | 常驻任务/定时调度重算,调用 `update_link` 覆盖范围 |
|
||||||
|
| 备用员工 | 获客链接接口没有“主用/备用”字段 | 主用无人可接待时才把合格备用成员放入范围 |
|
||||||
|
| 客户标签 | 读取企业标签库、对指定员工的客户 `mark_tag` | 配置标签 ID、回调后应用、幂等和失败补偿 |
|
||||||
|
| 客户备注/描述 | `externalcontact/remark` | 模板变量展开、字符数校验、只更新明确配置的字段 |
|
||||||
|
| 欢迎语文本/附件 | `send_welcome_msg` | 默认/渠道/关闭/分时策略选择、变量展开、素材准备、20 秒内发送 |
|
||||||
|
| 通过 `LinkId` 定位添加客户渠道 | **普通 `add_external_contact` 文档不承诺有 `LinkId`** | 使用 `State` 映射本地推广方案;有 `LinkId` 的获客事件作补充 |
|
||||||
|
|
||||||
|
依据:[获客链接管理](https://developer.work.weixin.qq.com/document/path/97297)、[事件格式](https://developer.work.weixin.qq.com/document/path/92130)、[发送新客户欢迎语](https://developer.work.weixin.qq.com/document/path/92137)。
|
||||||
|
|
||||||
|
## 2. 获客链接 create / update
|
||||||
|
|
||||||
|
官方文档:[获客链接管理](https://developer.work.weixin.qq.com/document/path/97297),页面最后更新 2025-11-17。
|
||||||
|
|
||||||
|
### 2.1 请求
|
||||||
|
|
||||||
|
创建:`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/customer_acquisition/create_link?access_token=ACCESS_TOKEN`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"link_name": "门诊咨询推广",
|
||||||
|
"range": {
|
||||||
|
"user_list": ["assistant_a", "assistant_b"],
|
||||||
|
"department_list": [2]
|
||||||
|
},
|
||||||
|
"skip_verify": true,
|
||||||
|
"priority_option": {
|
||||||
|
"priority_type": 2,
|
||||||
|
"priority_userid_list": ["assistant_a", "assistant_b"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/customer_acquisition/update_link?access_token=ACCESS_TOKEN`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"link_id": "LINK_ID",
|
||||||
|
"range": {
|
||||||
|
"user_list": ["assistant_b"],
|
||||||
|
"department_list": []
|
||||||
|
},
|
||||||
|
"skip_verify": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
示例中的 `priority_option` 仅在企业确实具备该能力且配置了好友优先策略时提交;它不是排班、权重或备用配置。`skip_verify` 应始终使用本地已保存的值,不能在范围同步时意外改变免验证设置。
|
||||||
|
|
||||||
|
### 2.2 已核实限制
|
||||||
|
|
||||||
|
- 创建 `link_name` 必填,更新可选,最长 **30 个字符**。
|
||||||
|
- `range.user_list` 最多 **500 人**;部门覆盖人数也有上限,最终 `range` 覆盖总人数不得超过 **500 人**。
|
||||||
|
- 创建时 `user_list` 与 `department_list` **不可同时为空**。
|
||||||
|
- 更新的 `range` 是**覆盖更新**,不是增量加入或删除。若目的是精确排班,应使用明确的 `user_list`,并清掉不受排班控制的部门范围。
|
||||||
|
- `skip_verify` 缺省值为 `true`。
|
||||||
|
- `priority_type=1`:在全企业内优先分配给已有好友关系的成员。
|
||||||
|
- `priority_type=2`:在 `priority_userid_list` 中优先分配给已有好友关系的成员;创建时该列表必填,最多 **1000 人**。
|
||||||
|
- `priority_option` 也是覆盖更新;仅支持“客户与成员关系绑定”的经营类目可用,需在管理端“高级功能 → 获客助手”确认。
|
||||||
|
- `range` / `priority_userid_list` 受应用可见范围或客户可建联成员范围约束。
|
||||||
|
- 还有 `mark_source`,缺省 `true`,但**只对“营销获客”应用生效**;本项目自建应用不要将其误当作通用渠道标记开关。
|
||||||
|
- 查询、更新、删除的 `link_id` 必须属于当前应用创建的链接。
|
||||||
|
|
||||||
|
### 2.3 权限和不确定点
|
||||||
|
|
||||||
|
官方明确要求使用配置到客户联系“可调用应用”列表中的自建应用 secret 获取的 token;获客链接 API **不支持客户联系系统应用调用**。不能因为客户详情接口过去可用,就推断同一 secret 一定支持获客链接。
|
||||||
|
|
||||||
|
文档没有明确以下行为,不能自行编造 payload:
|
||||||
|
|
||||||
|
1. `priority_type=0` 的含义以及取消已存在 `priority_option` 的正确方式。官方只列出 `1`、`2`;不要宣称传 `0`、空对象或省略字段能清除既有设置。
|
||||||
|
2. 更新链接时提交全空 `range` 是否有特殊停用语义。创建明确不允许空范围,本项目应继续把“至少一名可接待成员”作为有效配置约束。
|
||||||
|
3. `update_link` 的传播延迟以及对已经打开的成员页/已经发起的好友请求是否有追溯影响。
|
||||||
|
4. 好友优先列表与排班范围交叉时的完整路由细节。`priority_type=1` 涵盖全企业,不能承诺严格服从本地排班/上限;需提供冲突说明并做真实企业联调。
|
||||||
|
|
||||||
|
## 3. 星期排班、备用员工与上限
|
||||||
|
|
||||||
|
以下为根据官方范围更新能力提出的实现建议,并非独立的企微“上下线 API”。
|
||||||
|
|
||||||
|
1. 将星期、开始/结束时间、时区、是否启用、有效期、成员角色(主用/备用)存到本地;统一用 `Asia/Shanghai`,时间区间采用左闭右开 `[start, end)`,跨午夜时段拆成两天或显式处理前一日。
|
||||||
|
2. 先计算符合开关、有效期、班次、业务上限的主用成员;主用集合非空就只发送主用集合。主用全部不可用时才选择合格备用成员;备用成员不要日常混在同一 `range` 中,否则企微会把他们当普通候选成员。
|
||||||
|
3. 保存后立即同步;分钟任务持续重算;在时段边界可以额外立即同步。数据库事务只认领任务/保存状态,网络请求放在事务之外。
|
||||||
|
4. `range` 有变化才调用 `update_link`,保留版本号、租约、重试和最后成功应用范围;调用成功后用 `customer_acquisition/get` 回读核验。
|
||||||
|
5. 企微自动跳过“暂时无法添加客户”的异常账号;若整条链接所有成员异常,会推送 `customer_acquisition/link_unavailable`。这是账号异常路由能力,**不代表按本地班次自动启用备用员工**。可以据此触发告警或经过本地规则校验的备用范围切换。[获客助手事件通知](https://developer.work.weixin.qq.com/document/path/97299)
|
||||||
|
6. 若主用和备用都为空,不要把“本地已下线”展示成“官方链接已停用”。保留明确阻塞状态、错误提示,并由业务选择停止曝光/受控入口暂停;不能在保存排班时偷偷删除官方链接。
|
||||||
|
7. 回调记账后更新范围是事后控制;有网络延迟和并发好友申请,不能声称“日上限绝不超发”。UI 应说明本地统计上限与官方建联并发之间的边界。
|
||||||
|
|
||||||
|
现有 `QywxPromotionRangeSyncService` 已有任务租约、版本号、回读范围、分钟重算和空范围阻塞,适合作为扩展点;不必另造一套链路。现有调度代码通过 `State=zyt_pool:{id}` 记账,扩展渠道参数时应保持兼容。
|
||||||
|
|
||||||
|
## 4. 企业标签、客户备注、客户详情
|
||||||
|
|
||||||
|
### 4.1 获取企业客户标签
|
||||||
|
|
||||||
|
文档:[管理企业标签](https://developer.work.weixin.qq.com/document/path/92117),最后更新 2023-12-01。
|
||||||
|
|
||||||
|
`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get_corp_tag_list?access_token=ACCESS_TOKEN`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tag_id`、`group_id` 都不传即返回所有标签;如果需要按组获取:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"group_id":["GROUP_ID"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时传两个筛选条件时以 `group_id` 为准,忽略 `tag_id`。返回 `tag_group[]`,组内为 `tag[]`,标签使用 `id`、`name`,有删除标记时应过滤。应用仅可编辑/删除自己创建的标签,但读取标签库和给客户打已有企业标签是另一个权限层次,不能据此把所有其他来源标签都从选择器隐藏。
|
||||||
|
|
||||||
|
自建应用需被列入客户联系可调用应用;企业标签库最多 10000 个标签。页面没有给 `get_corp_tag_list` 列出分页参数,不要自行增加 cursor 分页。
|
||||||
|
|
||||||
|
### 4.1.1 自定义企业客户标签(2026-08-31 补充核验)
|
||||||
|
|
||||||
|
官方接口:`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_corp_tag?access_token=ACCESS_TOKEN`。来源仍为 [管理企业标签](https://developer.work.weixin.qq.com/document/path/92117) 中“添加企业客户标签”一节;本次通过 HTTPS 读取官方页面公开 HTML 核实,没有调用企业 API。
|
||||||
|
|
||||||
|
已有“推广渠道”分组时:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"group_id":"EXISTING_GROUP_ID","tag":[{"name":"直播推广"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
没有该分组时,一次请求创建分组及标签:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"group_name":"推广渠道","tag":[{"name":"直播推广"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tag.name` 必填,最长30个字符;`group_name` 同样最长30个字符,均不是字节上限。
|
||||||
|
- 指定已有分组用 `group_id`。填写该字段后,`group_name` 和标签组 `order` 被忽略。
|
||||||
|
- 通过 `group_name` 创建分组时,如果分组名称已存在,会在已有分组下新增标签;不能创建空分组。
|
||||||
|
- 同组标签不能重名;单次传入多个同名标签只创建一个。官方没有承诺“名称已存在”的每种错误码及返回列表形态,因此不能靠猜测错误码返回本地假ID。
|
||||||
|
- 返回值包含 `tag_group.group_id/group_name/tag[]`,标签真实ID为 `tag[].id`;企业标签总数上限10000。
|
||||||
|
- `agentid` 仅旧第三方多应用套件需要,本项目自建应用不提交。
|
||||||
|
|
||||||
|
本项目新增 `POST firstvisit.wecomPromotion/createTag`,复用页面权限,只接受 `{name}` 并固定使用“推广渠道”分组。名称须非空、最多30字符,不得包含控制/不可见格式字符。先查询同组同名并复用,创建失败或响应无法确认时只读回确认;无法确认则提示刷新列表核对,不再次发送创建请求。每个推广方案保存的 `tag_ids` 仍是数组,但最多一项,开启时必须一项;读取旧多选数据不截断,重新保存时要求用户明确选一个。
|
||||||
|
|
||||||
|
### 4.2 给指定员工的客户打标签
|
||||||
|
|
||||||
|
文档:[编辑客户企业标签](https://developer.work.weixin.qq.com/document/path/92118),最后更新 2023-12-01。
|
||||||
|
|
||||||
|
`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/mark_tag?access_token=ACCESS_TOKEN`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userid": "assistant_a",
|
||||||
|
"external_userid": "EXTERNAL_USER_ID",
|
||||||
|
"add_tag": ["ENTERPRISE_TAG_ID_A", "ENTERPRISE_TAG_ID_B"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 可选 `remove_tag` 用于明确移除;`add_tag` 与 `remove_tag` 不能同时为空。
|
||||||
|
- 客户必须已是该 `userid` 的外部联系人;操作面向**员工与客户的关系**,不是企业下无差别更新所有员工视角。
|
||||||
|
- 每个成员对同一客户最多 3000 个标签;同一标签组可以选多个标签。
|
||||||
|
- 应用只能操作可见范围内成员的客户标签;规则组标签要求同一应用创建该规则组,且成员在其管理范围。
|
||||||
|
- 渠道自动标签建议只增添已配置标签,不能为了“同步一致”删除员工手动添加的其他标签。
|
||||||
|
|
||||||
|
### 4.3 客户备注和描述
|
||||||
|
|
||||||
|
文档:[修改客户备注信息](https://developer.work.weixin.qq.com/document/path/92115),最后更新 2025-11-17。
|
||||||
|
|
||||||
|
`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/remark?access_token=ACCESS_TOKEN`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userid": "assistant_a",
|
||||||
|
"external_userid": "EXTERNAL_USER_ID",
|
||||||
|
"remark": "渠道A-李女士",
|
||||||
|
"description": "来自门诊咨询推广"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `remark` 最多 **20 个字符**;`description` 最多 **150 个字符**,均是字符数,不是欢迎语的字节数。
|
||||||
|
- 可选 `remark_company`(最多20字符,仅微信客户有效)、`remark_mobiles`、`remark_pic_mediaid`。
|
||||||
|
- 不可全部为空;仅写本次用户明确启用的字段,避免覆盖人工备注/电话。
|
||||||
|
- 电话数组会覆盖旧值;官方清除全部电话的特殊说明为给 `remark_mobiles` 填一个空字符串。当前推广需求无须触及这一功能。
|
||||||
|
- 修改权限限制在应用可见范围内成员添加的客户。
|
||||||
|
- 文档未对清空 `remark` / `description` 的空字符串语义作同样明确说明,不要将“未配置”自动转换成清空远端。
|
||||||
|
|
||||||
|
### 4.4 获取客户详情、名字与渠道
|
||||||
|
|
||||||
|
文档:[获取客户详情](https://developer.work.weixin.qq.com/document/path/92114),最后更新 2025-12-19。
|
||||||
|
|
||||||
|
`GET https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get?access_token=ACCESS_TOKEN&external_userid=EXTERNAL_USER_ID`
|
||||||
|
|
||||||
|
重要字段:
|
||||||
|
|
||||||
|
- `external_contact.name`:微信客户为微信昵称;企微联系人为其对外别名或实名。
|
||||||
|
- `follow_user[]`:每个跟进人的 `userid`、`remark`、`description`、`tags`、`state`、`add_way`。
|
||||||
|
- `follow_user.add_way=16` 表示获客链接添加;`state` 是本地可自定义渠道,两者不能混用。
|
||||||
|
- 读取/应用关系级备注和标签时,要匹配回调实际 `UserID`,不能直接取第一个 `follow_user`。
|
||||||
|
- 跟进人超过500时,使用返回的 `next_cursor` 分页;只保证获取应用有可见权限的成员信息。
|
||||||
|
- 官方注明自 2023-12-01 起不再支持新场景使用系统应用 secret,存量企业暂不受影响。项目应以已列入可调用列表的自建应用作为正式接入方式。
|
||||||
|
|
||||||
|
## 5. 获客渠道与回调字段
|
||||||
|
|
||||||
|
### 5.1 customer_channel 与 State
|
||||||
|
|
||||||
|
将渠道标识放在已创建的官方链接 URL 查询参数中,而不是写进 `create_link` 的自造 `state` 字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://work.weixin.qq.com/ca/LINK_PATH?customer_channel=zyt_pool%3A123
|
||||||
|
```
|
||||||
|
|
||||||
|
如果原链接已有查询串,应以 URL 解析器安全合并;不能重复叠加 `customer_channel`。自定义字符串最长 **64 字节**,超过会截断,因此应在保存/生成时拒绝超长值,避免两个渠道被截断后碰撞。返回的客户列表与客户详情 `state` 对应这个字符串。[获取由获客链接添加的客户信息](https://developer.work.weixin.qq.com/document/path/97298)
|
||||||
|
|
||||||
|
建议继续采用无个人信息的短、不透明标识;若扩展为独立渠道 ID,应新增明确映射并保持 `zyt_pool:{id}` 老链接兼容。
|
||||||
|
|
||||||
|
### 5.2 添加客户事件
|
||||||
|
|
||||||
|
官方文档:[事件格式](https://developer.work.weixin.qq.com/document/path/92130)。以下是接收 XML 解密后用于本地处理的字段示意(**不是 POST API 请求体**):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Event": "change_external_contact",
|
||||||
|
"ChangeType": "add_external_contact",
|
||||||
|
"UserID": "assistant_a",
|
||||||
|
"ExternalUserID": "EXTERNAL_USER_ID",
|
||||||
|
"State": "zyt_pool:123",
|
||||||
|
"WelcomeCode": "WELCOME_CODE",
|
||||||
|
"CreateTime": 1788141600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
本事件的官方字段表**没有 `LinkId`**。应以 `State` 识别渠道;不能在本事件没有 `LinkId` 时放弃欢迎语/标签,也不能把后来的首次聊天事件当成欢迎语触发条件。
|
||||||
|
|
||||||
|
`WelcomeCode` 不是必然存在:客户与成员已开始聊天、已经在半客户事件中发过欢迎语等情况不会继续给 code;企业微信商务伙伴自动递名片,也不回调 code。
|
||||||
|
|
||||||
|
`add_half_external_contact` 同样可能带 `State` 与 `WelcomeCode`。若需要支持免验证添加全流程,应让欢迎语处理器在有效 code 出现时就处理,不应被“半客户不入客户表”的早返回吞掉;但打标签和修改备注可以等关系确认后做,不能为等客户详情而消耗欢迎语窗口。
|
||||||
|
|
||||||
|
### 5.3 LinkId 出现在哪些事件
|
||||||
|
|
||||||
|
获客助手专用事件为 `Event=customer_acquisition`。例如 `link_unavailable`、`delete_link`、`open_profile`、`friend_request`、`customer_start_chat`、`message_from_customer` 等有 `LinkId`;其中 `open_profile` / `friend_request` 有 `State`,但**没有可用于发送新客户欢迎语的 `WelcomeCode`**。[获客助手事件通知](https://developer.work.weixin.qq.com/document/path/97299),最后更新 2026-07-22。
|
||||||
|
|
||||||
|
`message_from_customer` 的 `UserID`、`ExternalUserID`、`ChatSeq` 自 2024-12-19 起不再保证回调,须使用 30 分钟内有效的 `ChatKey` 查询。当前项目已接入 ChatKey 处理,应保留这条补偿链路,不能用旧示例假定字段永远齐全。
|
||||||
|
|
||||||
|
### 5.4 接收要求
|
||||||
|
|
||||||
|
配置了客户联系可调用应用、API 接收消息,且勾选“外部联系人变更回调”,才能收到可见范围内成员客户事件。[回调通知概述](https://developer.work.weixin.qq.com/document/path/92129)
|
||||||
|
|
||||||
|
企业微信要求回调在 **5 秒内响应**;连接失败或超时时会重试,官方说明总共重试三次,并明确回调并非100%可靠。接收端应验签解密、快速持久化并应答,业务由立即运行的工作进程处理;需要额外对账。[回调配置](https://developer.work.weixin.qq.com/document/path/90930)
|
||||||
|
|
||||||
|
## 6. 发送新客户欢迎语及附件
|
||||||
|
|
||||||
|
官方文档:[发送新客户欢迎语](https://developer.work.weixin.qq.com/document/path/92137),最后更新 2025-11-17。
|
||||||
|
|
||||||
|
`POST https://qyapi.weixin.qq.com/cgi-bin/externalcontact/send_welcome_msg?access_token=ACCESS_TOKEN`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"welcome_code": "WELCOME_CODE",
|
||||||
|
"text": {"content": "李女士您好,我是小张医助。"},
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"msgtype": "image",
|
||||||
|
"image": {"media_id": "IMAGE_MEDIA_ID"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"msgtype": "link",
|
||||||
|
"link": {
|
||||||
|
"title": "就诊指南",
|
||||||
|
"picurl": "https://example.com/guide-cover.jpg",
|
||||||
|
"desc": "查看就诊须知",
|
||||||
|
"url": "https://example.com/guide"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"msgtype": "miniprogram",
|
||||||
|
"miniprogram": {
|
||||||
|
"title": "预约入口",
|
||||||
|
"pic_media_id": "COVER_MEDIA_ID",
|
||||||
|
"appid": "ASSOCIATED_MINIPROGRAM_APPID",
|
||||||
|
"page": "/pages/appointment/index"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"msgtype": "video", "video": {"media_id": "VIDEO_MEDIA_ID"}},
|
||||||
|
{"msgtype": "file", "file": {"media_id": "FILE_MEDIA_ID"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
以上五类均有官方示例/对应字段。官方参数表的 `attachments.msgtype` 行漏列了 `file`,但页面说明、完整示例和 `file.media_id` 行都明确支持文件;这是文档内部不一致,应记录而不是误删文件支持。
|
||||||
|
|
||||||
|
### 6.1 时效、互斥、错误处理
|
||||||
|
|
||||||
|
- 收到相关事件后 **20 秒内**调用,`welcome_code` 有效期20秒,只能成功使用一次;不能靠分钟级任务补发过期欢迎语。
|
||||||
|
- 管理端已为成员配置可用欢迎语时,不会返回 `welcome_code`。本地“关闭渠道欢迎语”只能控制**本应用是否发送**,无法压制企业微信管理端或其他应用自己发的欢迎语。
|
||||||
|
- 长期未登录企业微信的成员不能发送欢迎语。
|
||||||
|
- 已成功下发后再发返回 `41051`,无需重试。
|
||||||
|
- 多应用竞争发送时,后来的应用可能返回 `41096`,表示正在由其他应用分发,不等于已发成功;官方允许重试,但仍受20秒窗口限制。收到 `41051` 则停止。
|
||||||
|
- 自建应用须配置到可调用应用列表;成员须在其可见范围。获客链接可用不等于欢迎语权限和回调配置必然正确。
|
||||||
|
|
||||||
|
### 6.2 内容限制
|
||||||
|
|
||||||
|
| 字段 | 限制 |
|
||||||
|
| --- | --- |
|
||||||
|
| `text.content` | 最长4000字节(UTF-8 字节计算) |
|
||||||
|
| `attachments` | 最多9个;可以同时发文本和附件 |
|
||||||
|
| `text` / `attachments` | 不可同时为空 |
|
||||||
|
| `link.title` | 必填,最长128字节 |
|
||||||
|
| `link.desc` | 可选,最长512字节 |
|
||||||
|
| `link.url` | 必填 |
|
||||||
|
| `link.picurl` | 可选封面 URL;注意字段拼写不是 `pic_url` |
|
||||||
|
| `image.media_id` / `image.pic_url` | 至少一个;都传时 `media_id` 优先 |
|
||||||
|
| `image.pic_url` | 仅可用官方“上传图片”接口得到的 URL,不能直接塞任意本地/CDN图片地址 |
|
||||||
|
| `miniprogram.title` | 必填,最长64字节 |
|
||||||
|
| `miniprogram.pic_media_id` | 必填,封面建议520×416 |
|
||||||
|
| `miniprogram.appid` | 必须是关联到企业的小程序 |
|
||||||
|
| `miniprogram.page` | 必填的小程序页面路径 |
|
||||||
|
| `video.media_id` / `file.media_id` | 对应类型必填 |
|
||||||
|
|
||||||
|
`msgtype` 必须与同项内的内容对象一致。不能把多个附件拼成旧版顶层 `image` / `link` / `miniprogram` 字段。
|
||||||
|
|
||||||
|
### 6.3 素材必须提前准备
|
||||||
|
|
||||||
|
临时素材:`POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=ACCESS_TOKEN&type=TYPE`,multipart 文件字段名为 `media`。`media_id` 有效 **3天**,同一企业内应用可共享。文件需大于5字节;图片 JPG/PNG ≤10MB,视频 MP4 ≤10MB,普通文件 ≤20MB。[上传临时素材](https://developer.work.weixin.qq.com/document/path/90253)
|
||||||
|
|
||||||
|
永久图片 URL:`POST https://qyapi.weixin.qq.com/cgi-bin/media/uploadimg?access_token=ACCESS_TOKEN`,得到可用于欢迎语的 URL。图片大小5B~2MB,每企业每日最多1000张、每月最多3000张;返回 URL 永久有效,但用途受企微环境限制。[上传图片](https://developer.work.weixin.qq.com/document/path/90256)
|
||||||
|
|
||||||
|
实现建议:配置欢迎语时保存源文件和上传状态,提前转成企微素材并按 hash 去重;临时素材在过期前刷新。发送时不应临时下载大文件再上传,否则20秒窗口很容易失效。失效附件应有明确错误/降级记录,不能假装发送完整成功。
|
||||||
|
|
||||||
|
## 7. 模板变量及默认/渠道/关闭/分时策略
|
||||||
|
|
||||||
|
以下是服务端职责,官方 `send_welcome_msg` 不会替换 `{客户昵称}`、`{员工昵称}` 等模板内容,也没有这些模式参数。
|
||||||
|
|
||||||
|
### 7.1 变量
|
||||||
|
|
||||||
|
- `{客户昵称}`:取 `external_contact.name`,而非把某个员工的 `remark` 当客户原始昵称。缓存缺失时可做有严格超时预算的详情查询;无法取得时使用事先定义的“您”等兜底,不要把未展开的占位符发送出去。
|
||||||
|
- `{员工昵称}`:首先明确产品含义。可用本地配置的对外称呼,或企业成员 `name`;若要 `alias`,需明确优先级。`GET /cgi-bin/user/get?userid=USER_ID` 返回 `name`/`alias` 受应用类型和可见权限影响,第三方并不能普遍拿到姓名/别名。[读取成员](https://developer.work.weixin.qq.com/document/path/90196)
|
||||||
|
- 只支持白名单变量,不运行表达式、不执行任意模板代码;API 发出的最终文本必须已完成替换。
|
||||||
|
- 保存时校验模板结构,发送时在变量展开后再校验长度:欢迎语 UTF-8 字节上限,客户备注20字符、描述150字符。对模板变量导致的超限采用明确的截断/拒绝策略并记录,不能依赖企微静默截断。
|
||||||
|
|
||||||
|
### 7.2 建议的确定性策略
|
||||||
|
|
||||||
|
每条本地推广渠道保存 `welcome_mode = inherit | custom | disabled | scheduled`,并定义唯一优先级:
|
||||||
|
|
||||||
|
1. 能定位的渠道设为 `disabled` → 不发送本应用欢迎语,**不可回退默认欢迎语**。
|
||||||
|
2. 渠道为 `custom` → 使用渠道内容。
|
||||||
|
3. 渠道为 `scheduled` → 按事件发生时间、北京时间和星期选择规则;规则重叠要拒绝或使用明确排序。无匹配时使用该配置明确指定的兜底(默认/固定内容/不发),不能凭实现猜测。
|
||||||
|
4. 渠道为 `inherit`,或业务明确允许未知渠道走默认 → 使用默认欢迎语。
|
||||||
|
|
||||||
|
分时表示“添加客户时选哪一段内容”,不是“把欢迎语延迟到某个时段再发”;延迟通常会越过20秒有效期。配置应和事件一起保存版本或内容快照,避免工作进程稍后读到另一版规则。
|
||||||
|
|
||||||
|
### 7.3 处理链建议
|
||||||
|
|
||||||
|
```text
|
||||||
|
验签解密 → 最小事件幂等落库 → 立即工作进程领取
|
||||||
|
├→ 欢迎语:选策略/展开变量 → 20秒内send_welcome_msg
|
||||||
|
├→ 成员记账/范围同步(独立,可补偿)
|
||||||
|
└→ 关系确认后标签/备注/详情同步(独立,可补偿)
|
||||||
|
```
|
||||||
|
|
||||||
|
回调应答不能等全部远端请求完成;欢迎语必须使用立即消费的队列/工作进程,不能复用分钟任务。若部署没有立即消费能力,应明确补齐部署要求,不能只保存一条“待发送”记录就宣称欢迎语已经打通。
|
||||||
|
|
||||||
|
欢迎语状态建议包含 `pending/processing/sent/skipped/expired/failed`,并保存事件时间、首次接收时间、处理耗时、策略版本、结果码和跳过原因。幂等至少考虑企业、实际员工、客户、事件及 welcome_code;code 本身仅短期保留/加密,日志只记录摘要,不能泄露 token/code。`add_half` / `add` 以及回调重试不应造成重复发送。
|
||||||
|
|
||||||
|
标签、备注的结果单独记录,失败不能撤销已成功的欢迎语;人工重试仅重试失败动作,不重放全部新增客户流程。先读取现有关系可避免覆盖人工信息,但不得挡在欢迎语关键路径前。
|
||||||
|
|
||||||
|
## 8. 对当前代码的落地提示
|
||||||
|
|
||||||
|
只读查看了以下文件:
|
||||||
|
|
||||||
|
- `server/app/common/service/qywx/QywxCustomerAcquisitionApiService.php`
|
||||||
|
- `server/app/api/controller/QywxExternalContactCallbackController.php`
|
||||||
|
- `server/app/common/service/qywx/QywxPromotionRangeSyncService.php`
|
||||||
|
- `server/app/common/service/qywx/QywxPromotionMemberSchedulerService.php`
|
||||||
|
|
||||||
|
观察及建议:
|
||||||
|
|
||||||
|
1. API service 已有 create/update/get/list 和 token 无效单次刷新;它本身没有标签、备注、欢迎语或素材封装。扩展时应区分权限、超时预算和返回错误,而非把前端配置原样塞给 `create_link`。
|
||||||
|
2. 回调已提取 `State`、`WelcomeCode`,但目前欢迎语只记为是否存在;没有发送欢迎语。新增服务要取得真实 code,而不是只接收布尔值。
|
||||||
|
3. `add_half_external_contact` 目前早返回;如果要支持其欢迎语,须在这个返回之前处理有效 code,客户落库的原有跳过行为可保留。
|
||||||
|
4. 目前 `add_external_contact` 会同步触发范围 API,再拉客户详情。欢迎语不应附加在这些操作之后;其超时窗口比范围/资料同步更严格。
|
||||||
|
5. `State` 当前正则为 `^zyt_pool:(\d+)$`。如果新前端生成别的 State 格式而不改兼容解析,现有统计和范围调度会失效。
|
||||||
|
6. 当前范围同步只发送 `link_id/link_name/range/skip_verify`;若新增好友优先策略,要确认创建、编辑、后台定时同步、远端回读都不会意外覆盖/遗失该设置。
|
||||||
|
7. 若排班/备用规则改变了本地“可用成员”判断,应只保留一个统一计算器供页面预览、保存校验、分钟重算和回调后同步共用,避免页面与官方实际范围不一致。
|
||||||
|
|
||||||
|
## 9. 联调时必须验证的项目
|
||||||
|
|
||||||
|
- 当前企业的自建应用已具备获客助手、客户详情、标签、备注、欢迎语权限及成员可见范围;新增这些功能不能仅沿用“获客链接列表成功”的权限检测结果。
|
||||||
|
- 管理端欢迎语是否已关闭/让位,本应用是否实际收到含 `WelcomeCode` 的回调。
|
||||||
|
- `priority_option` 取消语义、旧好友优先与排班范围的实际交互,官方未给明文保证的部分应以联调记录为准。
|
||||||
|
- `range` 更新到官方生效的实际时延;全员下线/备用不可用时既有链接仍可能维持旧范围,UI需如实显示同步阻塞。
|
||||||
|
- 20秒期限下“冷 token、冷客户缓存、素材过期、队列堆积”的处理;5秒回调应答要求。
|
||||||
|
- Unicode昵称展开后的UTF-8字节限制、备注字符限制、小程序关联/页面可用性、每类附件真实接收效果。
|
||||||
|
- 半客户转正式客户、重复回调、多应用竞争、人工欢迎语已发送、成员长期未登录等情况下,不把应跳过/过期误报成普通系统故障。
|
||||||
|
|
||||||
|
上述研究未通过真实企业写接口验收;所有不确定行为已显式列出,不能将研究示例当作企业权限或下发成功证明。
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 企业微信推广自动化部署与验收
|
||||||
|
|
||||||
|
此实现覆盖推广渠道欢迎语、企业标签、客户备注/描述及原客户同步补偿。企微获客链接接口不提供欢迎语和标签配置字段,因此这些设置不会显示在企微链接详情的“配置”入口,而是在客户添加回调后通过客户联系接口执行;它不会覆盖企业微信后台欢迎语设置,也不意味着企业已开通接口权限。官方能力及限制见 [核验报告](research/wecom-promotion-api-capabilities.md)。
|
||||||
|
|
||||||
|
## 部署顺序
|
||||||
|
|
||||||
|
1. 新环境先执行 `server/sql/1.9.20260831/add_wecom_promotion_automation.sql`;已建四张自动化表的环境再执行 `server/sql/1.9.20260901/upgrade_qywx_promotion_automation_runtime.sql`,补登记分钟补偿和素材刷新任务。默认表前缀为 `zyt_`,实际前缀不同须由部署人员调整。本开发任务没有执行业务数据库迁移。
|
||||||
|
2. 部署 PHP 代码。为 PHP-FPM、CLI worker 使用同一项目目录 `server/runtime/qywx_promotion_private/`,授予应用运行用户读写权限。目录必须位于 Web 根目录之外,禁止静态文件映射;保留源文件,不能随意清理该目录。
|
||||||
|
3. 配置客户联系“可调用应用”,优先使用创建获客链接的同一自建应用。代码默认使用 `qywx_customer_acquisition.secret`,无该值才使用 `pay.wechat_work.customer_contact_secret`。需要明确覆盖时设置 `WECHAT_WORK_PROMOTION_CONTACT_SECRET`。不会使用 `external_pay_secret`。新接入不要依赖已受官方限制的客户联系系统应用 Secret。检查应用可见范围、可信 IP、外部联系人变更事件订阅;欢迎语回调必须来自可调用应用的相应配置。
|
||||||
|
4. 建议设置至少32字符的随机 `WECHAT_WORK_PROMOTION_ENCRYPTION_KEY`,所有 API/CLI 节点保持一致。未设置时会自动在项目私有目录生成 `welcome.key`(0600)。多节点使用共享源文件目录和一致密钥;更换密钥前先处理/过期并清空待处理欢迎码。
|
||||||
|
5. 在启用渠道欢迎语之前,启动并监控下面的常驻 worker,并确保系统 `crontab` 调度进程正常运行。回调会立即尝试欢迎语和标签;常驻 worker 负责欢迎码窗口内的快速重试,分钟任务负责其余补偿和素材预热。
|
||||||
|
|
||||||
|
若旧版脚本在 `welcome_cipher` 字段的 `COMMENT=` 处报 MySQL 1064,请重新打开已修正的 SQL 文件并完整重跑。字段注释必须使用 `COMMENT '内容'`(不带等号);表级的 `COMMENT='内容'` 是合法语法,无须修改。脚本中的四张表都使用 `CREATE TABLE IF NOT EXISTS`,已成功创建的表会保留,不需要删表。四张表全部创建成功后再启动 worker。
|
||||||
|
|
||||||
|
## 必须运行的进程
|
||||||
|
|
||||||
|
欢迎码只有20秒有效。验签回调入库后会立即尝试欢迎语,并为正式客户立即添加标签;普通客户同步、备注、范围更新和素材上传等慢动作不进入回调。独立常驻进程继续秒级消费明确可重试的欢迎语任务。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /path/to/server
|
||||||
|
php think qywx:work-promotion-automation
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 Supervisor 或 systemd 保持常驻、自动重启。建议同一数据库启动2个欢迎语worker,以免单个HTTP请求阻塞其他事件;任务租约保证同一任务不重复领取。高并发须按实际20秒延迟指标增加进程。`--once` 仅用于受控诊断/部署测试,不能替代守护进程。
|
||||||
|
|
||||||
|
例如 Supervisor 配置(路径和用户替换为实际值):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:qywx-promotion-welcome]
|
||||||
|
command=/usr/bin/php /path/to/server/think qywx:work-promotion-automation
|
||||||
|
directory=/path/to/server
|
||||||
|
numprocs=2
|
||||||
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
|
user=www-data
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
stopwaitsecs=35
|
||||||
|
stdout_logfile=/var/log/qywx-promotion-welcome.log
|
||||||
|
stderr_logfile=/var/log/qywx-promotion-welcome-error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移会把每分钟补偿和每5分钟素材预热登记到 `zyt_dev_crontab`;部署仍须保证 `php think crontab` 被系统每分钟触发。若部署不使用内置任务表,可改用以下系统 cron:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
现有 `qywx:sync-promotion-ranges`、`qywx:retry-customer-acquisition-events` 也须保留。新推广配置事件优先在回调中添加标签;失败标签以及备注、描述、成员记账、范围更新、客户同步由补偿任务执行,处理延迟通常不超过一分钟。不要把这些慢速动作塞进欢迎语worker。方案日上限是回调后的统计控制,不是企微并发建联的硬性上限。
|
||||||
|
|
||||||
|
## 配置与素材契约
|
||||||
|
|
||||||
|
- `QywxPromotionContactApiService::tagOptions(): array` → `{tag_groups:[{group_id,group_name,tag:[{id,name}]}]}`。
|
||||||
|
- 方案客户标签为单选;接口仍使用 `tag_ids: []/[id]`,服务端拒绝多个标签。旧多选配置会显示重新选择提示,不自动截断。自定义标签通过 `POST firstvisit.wecomPromotion/createTag`(`{name}`)调用企微 `externalcontact/add_corp_tag`,固定保存到“推广渠道”分组,名称最多30字符;同组同名复用,创建成功后自动选中。创建立即写入企微标签库,取消方案编辑不会删除该标签;本次调整不新增数据库迁移。
|
||||||
|
- `QywxPromotionMediaService::upload($file, string $type, int $adminId): array` → `{asset_id,name,type}`。仅接受 ThinkPHP 已验证的 `UploadedFile`;不接受服务器路径或网络下载地址。上传时立即预热临时素材。
|
||||||
|
- `validateConfig(array $config, int $adminId, array $existingConfig = []): array` → 保留其他配置字段、规范化附件后的完整配置。调用方必须先校验方案编辑权;第三参数只传数据库读取的旧配置,不能传用户提供的“白名单”。其他管理员只能保留已获授权的旧方案资产,不能新引入别人的资产。
|
||||||
|
- `validateAttachments(array $attachments, int $adminId, array $allowedAssetIds = []): array` 为底层契约;不要直接向HTTP客户端暴露第三参数。
|
||||||
|
- 素材 ID 是随机48位十六进制字符串。图片 JPG/PNG≤10MB、视频MP4≤10MB、文件≤20MB,且大于5字节。普通文件限定PDF、Office、文本、CSV、ZIP、JPG/PNG、MP4;拒绝可执行文件、HTML/SVG等格式。后端使用实际MIME和扩展名,不信任浏览器Content-Type。
|
||||||
|
- 附件支持 `image/video/file.{asset_id}`、`miniprogram.{title,appid,page,pic_asset_id}`、`link.{title,url,desc,picurl?}`。不接受前端任意 `media_id`。本实现不开放 `image.pic_url`:官方只支持uploadimg生成的URL,普通CDN地址不能替代。
|
||||||
|
- 欢迎语文本最多4000 UTF-8字节(产品配置还可额外限制1200字符),最多9附件;链接标题128字节、描述512字节,小程序标题64字节。小程序需已关联企业;本地无法代替企微验证关联与页面可达性。
|
||||||
|
- 源文件与媒体记录持久保留;缓存media_id有效3天,在到期前1小时进入刷新候选。发送只使用剩余至少5分钟的缓存。过期素材不会在欢迎语关键路径上传,不会静默丢掉附件只发文字;会记录准备失败,直至20秒期限结束。
|
||||||
|
- 临时素材与凭证指纹绑定。更换企业/应用Secret后,先运行 `qywx:refresh-promotion-media` 并检查失败数,再恢复渠道曝光。
|
||||||
|
|
||||||
|
## 回调、幂等与失败策略
|
||||||
|
|
||||||
|
- 入口仅在 EasyWeChat 已验签/解密的 `change_external_contact` listener 中调用 `enqueueVerifiedEvent($event, true)`;不得把此服务直接作为未验签HTTP接口。
|
||||||
|
- 优先用 `State=zyt_pool:{id}`,无State时才使用事件实际存在的 `LinkId/LinkID`。必须存在正常的推广方案、非删除成员关系、官方有效链接、新配置记录。仅有可猜测的State不是授权。普通客户事件、无新配置旧方案、迁移尚未安装时保留旧同步路径;不会让所有客户突然依赖新worker。
|
||||||
|
- 正式客户add可执行全部动作;半客户add_half只处理欢迎语,不提前打标签、改备注或落正式客户表。事件重复通过唯一事件键去重;同一WelcomeCode在half/add之间通过唯一摘要索引只分配一次消费权。
|
||||||
|
- 队列持久化失败会抛专用异常,listener不吞掉,HTTP返回500供企微重投。即时发送结果按分动作持久化;明确可重试的失败由持久任务补偿,事件重投不会重复发送已完成动作。
|
||||||
|
- 每条任务保存配置快照;分时欢迎语按事件时间和Asia/Shanghai选择,未命中用基础欢迎语。`default`和`none`均不发送本系统欢迎语,不能抑制企业微信管理端或其他应用发送。
|
||||||
|
- 欢迎码以AES-256-GCM短存;动作进入sent/skipped/expired/uncertain/failed后清除密文。既有客户事件raw字段现在移除WelcomeCode;审计中无明文code/token、上游请求或Guzzle异常链。历史已有raw数据需要另外评估清理,本次未改历史记录。
|
||||||
|
- 发送前先持久化running。网络超时、无法解析响应、HTTP失败,或进程在发送后记录成功前崩溃,均记为uncertain,清理密文并停止自动重发,避免重复推送;需通过企微实际聊天结果人工核对。
|
||||||
|
- 只有明确的企微拒绝响应允许在剩余窗口重试;41096可重试,41051记为已使用并停止。token明确失效可刷新一次。过期或缺少code有独立原因,不会当成成功发送。
|
||||||
|
- 标签仅增添配置标签,不删除人工标签;备注与描述只写明确启用的字段。变量替换只支持白名单,名字接口失败使用本地员工名或“客户顾问”、客户用“您”。备注截断到20字符;欢迎语字节截断有审计原因。
|
||||||
|
- 元数据每个动作单独记录状态。失败最多10次、指数间隔后终态failed;成功动作不重放。最终failed不是成功,应安排告警/人工修复。范围动作仅触发现有范围任务,其最终应用状态仍以原范围同步表为准。
|
||||||
|
|
||||||
|
## 监控与验收
|
||||||
|
|
||||||
|
`zyt_qywx_promotion_automation_task.actions_json` 保存分动作状态、次数、错误码、原因和重试时间;`zyt_qywx_promotion_automation_action_log` 保存每次状态转移。监控欢迎语入队延迟、`expired/uncertain/failed` 数量、待处理最早事件时间、素材 `last_error` 和worker存活。一次没有异常输出不等于客户端已收到消息。
|
||||||
|
|
||||||
|
安全离线验证(不会初始化现有数据库、HTTP全部Mock):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd server
|
||||||
|
php tests/QywxPromotionContactApiServiceTest.php
|
||||||
|
php tests/QywxPromotionMediaServiceTest.php
|
||||||
|
php tests/QywxPromotionAutomationServiceTest.php
|
||||||
|
php tests/QywxPromotionCodeCipherTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
生产验收仍需在授权的企业测试客户/员工上验证真实权限、半客户/正式客户回调、每类附件实际接收、昵称模板、默认欢迎语互斥、worker故障和跨节点存储。此开发没有发出任何真实企业微信写请求。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 企业微信获客配置验证记录
|
||||||
|
|
||||||
|
日期:2026-08-31。
|
||||||
|
|
||||||
|
## 已完成的验证
|
||||||
|
|
||||||
|
- 单选与自定义标签:`QywxPromotionCreateTagTest.php`、`WecomPromotionCreateTagControllerTest.php` 及配置/前端 helper 回归通过。覆盖同组复用、新建分组/已有分组参数、并发冲突和网络不确定时只读回确认、非法名称、页面权限、POST 限制及拒绝多选。隔离浏览器验证单选替换、自定义空值提示、创建期间禁止保存、失败保留原标签、创建成功选中真实响应 ID、最终仅提交一个 ID,以及旧多选配置要求重新选择。未创建真实企微标签。
|
||||||
|
- 迁移修复复验:已修正 `welcome_cipher` 列注释中非法的 `COMMENT=`。在独立临时 MySQL 5.7.26 实例先创建前两张表并写入一条标记记录,再完整执行迁移两次;四张表均存在,字段注释正确,标记记录保持原值。未连接或修改业务数据库。
|
||||||
|
- `QywxPromotionAutomationConfigTest.php`:接待时段起止边界、跨午夜和跨周、主接待优先、日上限和跨日重置、备用禁用、无可用成员、非法配置、分时欢迎语重叠、昵称/日期模板、备注长度、欢迎语 UTF-8 字节限制。
|
||||||
|
- `QywxPromotionContactApiServiceTest.php`、`QywxPromotionMediaServiceTest.php`、`QywxPromotionAutomationServiceTest.php`:官方接口参数、Secret 选择、令牌失效重试、HTTP 不确定结果、素材权限/真实 MIME/私有路径/缓存刷新、欢迎码加密、重复事件、半客户、20 秒时效、分动作补偿和数据库异常触发回调重试。测试使用 HTTP mock 与内存存储,不连接业务数据库。
|
||||||
|
- `QywxPromotionCodeCipherTest.php`:6 个隔离 PHP 进程首次启动共用完整密钥、随机密文、篡改/错误密钥拒绝、损坏密钥不自动覆盖;仅使用临时目录。
|
||||||
|
- 原有成员范围、获客链接 URL、获客 API HTTP mock、获客事件重试、推广浮窗、删除契约及操作人权限契约测试通过。
|
||||||
|
- 浏览器使用独立 Vite 测试入口和虚构数据,替换 API 模块,未请求真实业务接口:验证排班缺项阻止保存、备用候选排除主接待、企业标签选择、渠道欢迎语变量与手机预览、网页附件编辑、客户备注预览和描述,最终保存参数与服务端配置契约一致。浏览器控制台无运行错误。
|
||||||
|
- 三个新增/修改 Vue 单文件组件编译通过,前端配置 helper 12 项边界断言通过。
|
||||||
|
- 完整 Vite 生产构建成功(4057 个模块),产物输出到隔离临时目录,没有执行 `release.mjs`,没有覆盖 `server/public/admin`。现有大型 bundle 和第三方播放器 `eval` 警告仍存在。
|
||||||
|
- 全项目 `vue-tsc --noEmit` 仍有 61 条其他文件的既存错误,本次推广页面、两个新组件、helper 和 API 文件未报错。未扩大范围修复其他模块。
|
||||||
|
|
||||||
|
## 验证边界
|
||||||
|
|
||||||
|
未向真实企业微信客户发送欢迎语、打标签、修改备注或上传测试素材;未执行生产数据库迁移。正式部署后仍须验证当前企业可调用应用的权限、成员可见范围、接收回调配置、后台欢迎语互斥、常驻进程和真实素材下发结果。
|
||||||
|
|
||||||
|
分钟调度存在传播时延;官方直链已打开或在途的好友请求无法由本地上限保证即时撤回。所有成员不可用时保留明确阻塞状态,不声称远端链接已停用。
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
namespace app\adminapi\controller;
|
namespace app\adminapi\controller;
|
||||||
|
|
||||||
|
|
||||||
|
use app\common\cache\AdminAuthCache;
|
||||||
use app\common\service\DirectUploadService;
|
use app\common\service\DirectUploadService;
|
||||||
use app\common\service\UploadService;
|
use app\common\service\UploadService;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -86,7 +87,12 @@ class UploadController extends BaseAdminController
|
|||||||
{
|
{
|
||||||
$type = trim((string)$this->request->post('type', 'video'));
|
$type = trim((string)$this->request->post('type', 'video'));
|
||||||
try {
|
try {
|
||||||
$result = DirectUploadService::issueCredentials($type);
|
$this->assertDirectUploadPermission($type);
|
||||||
|
$result = DirectUploadService::issueCredentials(
|
||||||
|
$type,
|
||||||
|
$this->adminId,
|
||||||
|
trim((string)$this->request->post('name', ''))
|
||||||
|
);
|
||||||
return $this->success('ok', $result);
|
return $this->success('ok', $result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
return $this->fail($e->getMessage());
|
return $this->fail($e->getMessage());
|
||||||
@@ -100,8 +106,10 @@ class UploadController extends BaseAdminController
|
|||||||
public function ossConfirm()
|
public function ossConfirm()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$type = trim((string)$this->request->post('type', 'video'));
|
||||||
|
$this->assertDirectUploadPermission($type);
|
||||||
$result = DirectUploadService::confirm([
|
$result = DirectUploadService::confirm([
|
||||||
'type' => trim((string)$this->request->post('type', 'video')),
|
'type' => $type,
|
||||||
'key' => trim((string)$this->request->post('key', '')),
|
'key' => trim((string)$this->request->post('key', '')),
|
||||||
'name' => trim((string)$this->request->post('name', '')),
|
'name' => trim((string)$this->request->post('name', '')),
|
||||||
'size' => (int)$this->request->post('size', 0),
|
'size' => (int)$this->request->post('size', 0),
|
||||||
@@ -115,4 +123,22 @@ class UploadController extends BaseAdminController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包属于发布能力,不能沿用普通素材上传的“登录即放行”。
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function assertDirectUploadPermission(string $type): void
|
||||||
|
{
|
||||||
|
if ($type !== DirectUploadService::TYPE_DESKTOP_PACKAGE
|
||||||
|
|| (int)($this->adminInfo['root'] ?? 0) === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = (new AdminAuthCache($this->adminId))->getAdminUri() ?? [];
|
||||||
|
$permissions = array_map('strtolower', $permissions);
|
||||||
|
if (!in_array('setting.desktop_workstation/setconfig', $permissions, true)) {
|
||||||
|
throw new Exception('权限不足,无法上传医生工作站安装包');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user