Compare commits

..
25 Commits
Author SHA1 Message Date
Your Name 928f72ec3d 更新 2026-09-03 16:03:41 +08:00
Your Name b4c11881b4 更新 2026-09-01 18:12:39 +08:00
Your Name 58ffde808f 更新 2026-09-01 17:49:29 +08:00
Your Name 486acc465d 更新 2026-09-01 17:18:42 +08:00
Your Name 398f9f3726 更新 2026-09-01 11:26:47 +08:00
Your Name 5bd5eae62d 更新 2026-09-01 10:42:16 +08:00
Your Name 456dd667df 更新 2026-08-31 15:17:34 +08:00
Your Name ed48f8be31 更新 2026-08-28 18:24:37 +08:00
Your Name 43ad07208f 更新 2026-08-27 16:26:14 +08:00
Your Name 4b8b4eb649 更新 2026-08-27 16:02:38 +08:00
Your Name 75e214dc08 更新 2026-08-27 15:41:09 +08:00
Your Name 74ff568ba4 更新 2026-08-27 14:54:18 +08:00
Your Name 2fa8492c56 更新 2026-08-27 14:23:23 +08:00
Your Name b5b14516a1 更新 2026-08-25 15:05:49 +08:00
Your Name 381fe65367 更新 2026-08-25 14:53:17 +08:00
Your Name af603a4e9a 更新 2026-08-25 14:16:31 +08:00
Your Name f24afa116f 更新 2026-08-25 11:40:58 +08:00
Your Name b8ccbaf567 更新 2026-08-25 10:45:24 +08:00
Your Name af1db59c07 更新 2026-08-25 09:41:26 +08:00
Your Name 47094cc617 更新 2026-08-25 09:36:25 +08:00
Your Name 01c38d8c5b 更新 2026-08-25 09:35:47 +08:00
Your Name 1f3e580cf8 更新 2026-08-25 09:34:39 +08:00
Your Name 562fe0ea0e 更新 2026-08-24 16:56:37 +08:00
Your Name d9bb94cd3f 更新 2026-08-22 10:50:20 +08:00
Your Name 9646ccd3f6 gengxin 2026-08-22 10:49:34 +08:00
592 changed files with 27883 additions and 5439 deletions
+5 -2
View File
@@ -100,6 +100,7 @@ export interface OssCredentialsResponse {
host?: string
cdn_domain?: string
key_prefix?: string
object_key?: string
max_size?: number
duration?: number
expired_time?: number
@@ -111,8 +112,10 @@ export interface OssCredentialsResponse {
}
}
export type OssDirectUploadType = 'video' | 'voice' | 'desktop_package'
/** 申请 STS 临时凭证 */
export function getOssCredentials(params: { type: 'video' }) {
export function getOssCredentials(params: { type: OssDirectUploadType; name?: string }) {
return request.post({
url: '/upload/ossCredentials',
params
@@ -121,7 +124,7 @@ export function getOssCredentials(params: { type: 'video' }) {
/** 直传完成回执:写 file 表 + HEAD 校验 */
export function confirmOssUpload(params: {
type: 'video'
type: OssDirectUploadType
key: string
name: string
size: number
+152 -13
View File
@@ -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 {
time_type: 'today' | 'yesterday' | 'week' | 'month'
dept_id?: number
@@ -190,21 +235,115 @@ export function wecomPromotionOverview() {
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
}
export function wecomPromotionSavePool(params: Record<string, unknown>) {
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>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
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 }) {
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
}
export function wecomPromotionSaveMember(params: Record<string, unknown>) {
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() {
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
+5
View File
@@ -5,6 +5,11 @@ export function qywxCustomerLists(params: any) {
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() {
return request.post({ url: '/qywx.customer/sync' })
+5
View File
@@ -578,6 +578,11 @@ export function prescriptionOrderAddLog(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 }) {
return request.post({ url: '/tcm.prescriptionOrder/updateAmount', params })
+23 -8
View File
@@ -51,8 +51,10 @@ import useAppStore from '@/stores/modules/app'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import {
DirectUploadApiError,
DirectUploadFallbackError,
uploadVideoDirectToCos
uploadDirectToCos,
type DirectUploadType
} from '@/utils/oss-direct-upload'
export default defineComponent({
@@ -83,7 +85,7 @@ export default defineComponent({
type: Boolean,
default: false
},
// 视频直传到 OSS绕开服务器中转,仅 type=video 生效)
// 直传到对象存储,绕开服务器中转
direct: {
type: Boolean,
default: false
@@ -102,8 +104,10 @@ export default defineComponent({
const visible = ref(false)
const fileList = ref<any[]>([])
// 仅 video/voice + direct 时才接管 http-request
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
const directTypes: DirectUploadType[] = ['video', 'voice', 'desktop_package']
const useDirect = computed(
() => props.direct && directTypes.includes(props.type as DirectUploadType)
)
const handleProgress = () => {
visible.value = true
@@ -131,7 +135,10 @@ export default defineComponent({
fileList.value = []
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)
visible.value = false
emit('change', file)
@@ -153,18 +160,20 @@ export default defineComponent({
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
case 'voice':
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
case 'desktop_package':
return '.exe,.zip'
default:
return '*'
}
})
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
// 走 COS 直传:成功时模拟老接口的响应 envelope
const httpRequest = async (options: UploadRequestOptions) => {
visible.value = true
try {
const data = await uploadVideoDirectToCos({
const data = await uploadDirectToCos({
file: options.file,
type: props.type as any,
type: props.type as DirectUploadType,
cid: Number((options.data as any)?.cid ?? 0),
onProgress(info) {
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
@@ -178,6 +187,12 @@ export default defineComponent({
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
} catch (err: any) {
if (err instanceof DirectUploadFallbackError) {
if (props.type === 'desktop_package') {
;(options as any).onError?.(
new Error('当前未启用腾讯云 COS,安装包无法直传,请配置 COS 后重试')
)
return
}
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
await defaultXhrUpload(options)
return
+43 -15
View File
@@ -3,10 +3,11 @@ import COS from 'cos-js-sdk-v5'
import {
confirmOssUpload,
getOssCredentials,
type OssCredentialsResponse
type OssCredentialsResponse,
type OssDirectUploadType
} from '@/api/file'
export type DirectUploadType = 'video'
export type DirectUploadType = OssDirectUploadType
export interface DirectUploadProgress {
/** 0-100 */
@@ -37,8 +38,17 @@ export interface DirectUploadOptions {
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
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 {
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
const ext = (file.name.split('.').pop() || 'bin').toLowerCase()
const ts = Date.now()
const rand = Math.random().toString(36).slice(2, 10)
return `${prefix}${ts}-${rand}.${ext}`
@@ -48,8 +58,13 @@ function buildKey(prefix: string, file: File): string {
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
*/
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
export async function uploadDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
const credentials: OssCredentialsResponse = await callDirectUploadApi(() =>
getOssCredentials({
type: options.type,
name: options.file.name
})
)
if (credentials.fallback) {
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) {
const mb = Math.round(credentials.max_size / 1024 / 1024)
throw new Error(`视频体积超出上限(${mb}MB`)
throw new Error(`文件体积超出上限(${mb}MB`)
}
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) => {
cos.uploadFile(
@@ -117,14 +132,16 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
)
})
const confirmed = await confirmOssUpload({
type: options.type,
key,
name: options.file.name,
size: options.file.size,
content_type: options.file.type || '',
cid: options.cid ?? 0
})
const confirmed = await callDirectUploadApi(() =>
confirmOssUpload({
type: options.type,
key,
name: options.file.name,
size: options.file.size,
content_type: options.file.type || '',
cid: options.cid ?? 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
}
}
/** 请求层已经展示过错误,避免 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) }}
</template>
</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-empty v-else description="未关联收款单" :image-size="60" />
</el-card>
@@ -1014,6 +1027,7 @@ import {
prescriptionOrderLogisticsTrace,
prescriptionOrderLogisticsJdUpdate,
prescriptionOrderPaidPayOrders,
prescriptionOrderUnlinkPayOrder,
prescriptionOrderPatchPrescriptionUsage
} from '@/api/tcm'
import { getDictData } from '@/api/app'
@@ -1107,6 +1121,38 @@ const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null)
const detailLogs = 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 p = detailData.value?.prescription
@@ -168,6 +168,7 @@ export function logActionText(act: string) {
ship: '确认发货',
withdraw: '撤销',
link_pay_order: '关联支付单',
unlink_pay_order: '移除收款关联',
completion_request: '完单申请',
auto_complete: '自动完成',
revoke_rx_audit: '撤回处方审核',
+299 -21
View File
@@ -94,15 +94,32 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="跟进人">
<el-input
v-model="queryParams.follow_user"
<el-form-item label="跟进人">
<el-input
v-model="queryParams.follow_user"
placeholder="跟进人姓名(后台姓名或企微账号)"
clearable
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="添加时间">
@keyup.enter="resetPage"
/>
</el-form-item>
<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
v-model="addTimeRange"
type="daterange"
@@ -235,6 +252,32 @@
<span v-else class="text-gray-400"></span>
</template>
</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">
<template #default="{ row }">
{{ formatTime(firstExternalAddTime(row)) }}
@@ -245,9 +288,19 @@
{{ formatTime(row.update_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<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>
</el-table-column>
</el-table>
@@ -500,6 +553,21 @@
</el-descriptions-item>
<el-descriptions-item label="添加时间" :span="2">
{{ 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 label="更新时间" :span="2">
{{ formatTime(currentCustomer.update_time) }}
@@ -549,8 +617,9 @@ import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-v
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import {
qywxCustomerLists,
qywxCustomerSync,
qywxCustomerLists,
qywxCustomerDelete,
qywxCustomerSync,
qywxCustomerStats,
qywxSyncSettingsGet,
qywxSyncSettingsSave,
@@ -563,6 +632,7 @@ const syncing = ref(false)
const showSyncSettings = ref(false)
const showDetail = ref(false)
const currentCustomer = ref<any>(null)
const deletingCustomerId = ref<number | null>(null)
const stats = reactive({
total: 0,
@@ -591,17 +661,19 @@ const syncSettings = reactive({
interval: 3600
})
const queryParams = reactive<{
name: string
follow_user: string
tag_ids: string[]
const queryParams = reactive<{
name: string
follow_user: string
add_way: number | ''
tag_ids: string[]
add_time_start: string
add_time_end: string
dedupe_mode: 'first' | 'any'
}>({
name: '',
follow_user: '',
tag_ids: [],
name: '',
follow_user: '',
add_way: '',
tag_ids: [],
add_time_start: '',
add_time_end: '',
dedupe_mode: 'first'
@@ -639,6 +711,22 @@ interface TagStatsPayload {
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>({
total_tags: 0,
total_relations: 0,
@@ -847,10 +935,11 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
params: queryParams
})
function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.tag_ids = []
function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.add_way = ''
queryParams.tag_ids = []
queryParams.add_time_start = ''
queryParams.add_time_end = ''
queryParams.dedupe_mode = 'first'
@@ -964,6 +1053,34 @@ function viewDetail(row: any) {
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_nameadmin.work_wechat_userid = userid */
function formatFollowUser(user: Record<string, any>) {
const adminName = String(user?.admin_name ?? '').trim()
@@ -985,6 +1102,167 @@ function followStaffTooltip(user: Record<string, any>) {
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 兜底);
* 再解析 follow_users;最后退回 create_time
@@ -98,8 +98,17 @@
<section class="metric-grid" aria-label="综合转化指标">
<article v-for="metric in visibleMetricCards" :key="metric.key" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
<small>{{ metric.hint }}</small>
<strong class="metric-value">
{{ 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>
</section>
@@ -145,7 +154,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=总进线=区间有效加粉按员工+客户去重剔除已删客户继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
<p>展开部门可查看人员明细加粉=总进线=区间新增加粉按员工+客户去重包含区间内添加后已删客户剔除继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加<template v-if="canViewDeletedFans">-N表示加粉总数中已删除</template>挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -178,7 +187,27 @@
</el-tag>
</template>
</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="paid_appointment_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>
</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>
</template>
@@ -278,7 +376,14 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Lock, Refresh } from '@element-plus/icons-vue'
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 MediaChannelOption = {
@@ -298,7 +403,7 @@ const emptyDashboard = () => ({
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
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',
can_view_finance: false
can_view_finance: false, can_view_deleted_fans: false
},
filters: {
departments: [] as any[],
@@ -324,6 +429,14 @@ const query = reactive<FirstVisitConversionParams>({
media_channel_code: ''
})
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 timeOptions = [
{ label: '今日', value: 'today' },
@@ -335,7 +448,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' }
]
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: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -353,6 +466,9 @@ const scopeDescription = computed(() => {
return parts.join(' · ')
})
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(() =>
canViewFinance.value
? metricCards
@@ -402,6 +518,13 @@ const rankingKind = computed(() => dashboard.meta.ranking_kind || (
))
const showRankings = computed(() => rankingKind.value !== 'hidden')
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 totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
const targetChartOption = computed(() => ({
@@ -418,10 +541,12 @@ const targetChartOption = computed(() => ({
}))
let latestDashboardRequestId = 0
let latestFansDetailRequestId = 0
async function loadDashboard() {
const requestId = ++latestDashboardRequestId
loading.value = true
dashboard.meta.can_view_deleted_fans = false
try {
const params: FirstVisitConversionParams = { ...query }
if (params.time_type === 'custom') {
@@ -470,6 +595,92 @@ function handleDeptChange() {
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) {
const value = dashboard.summary[key]
if (type === 'money') return formatMoney(value)
@@ -481,6 +692,10 @@ function formatNumber(value: any) {
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) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
@@ -621,6 +836,41 @@ onMounted(loadDashboard)
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; }
.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; }
.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) {
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
.range-text { margin-left: 0; }
@@ -698,5 +989,7 @@ onMounted(loadDashboard)
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
.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>
@@ -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>
@@ -239,7 +239,7 @@
<div class="install-tip">
<el-icon><CircleCheck /></el-icon>
<span><strong>无需暴露链接</strong>浮窗点击后仍由服务端执行可用性筛选权重随机和访问统计</span>
<span><strong>一个方案一个官方链接</strong>浮窗会直接打开企业微信链接多名医助由企业微信执行均衡分流</span>
</div>
</section>
</section>
@@ -274,6 +274,8 @@ interface PromotionPool {
name?: string
public_key?: string
script_url?: string
main_url?: string
compat_go_url?: string
go_url?: string
install_code?: string
trigger_code?: string
@@ -437,11 +439,12 @@ async function copySnippet(value: string | undefined, label: string) {
}
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('当前方案暂无测试链接')
return
}
window.open(props.pool.go_url, '_blank', 'noopener,noreferrer')
window.open(targetUrl, '_blank', 'noopener,noreferrer')
}
</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
+89 -5
View File
@@ -677,6 +677,32 @@
<el-option label="驼奶费用" :value="8" />
</el-select>
</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>
<template #footer>
<el-button @click="editOrderDialogVisible = false">取消</el-button>
@@ -782,6 +808,7 @@
<script setup lang="ts" name="orderList">
import { computed } from 'vue'
import { usePaging } from '@/hooks/usePaging'
import { hasPermission } from '@/utils/perm'
import {
orderLists,
orderDetail,
@@ -1015,9 +1042,33 @@ const editOrderFormRef = ref()
const editOrderLoading = ref(false)
const editPatientLoading = ref(false)
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 = {
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) => {
editOrderForm.value = {
id: row.id,
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] : []
editOrderDialogVisible.value = true
@@ -1320,11 +1397,18 @@ const submitEditOrder = async () => {
try {
await editOrderFormRef.value?.validate()
editOrderLoading.value = true
await orderEdit({
const payload: Record<string, unknown> = {
id: editOrderForm.value.id,
patient_id: editOrderForm.value.patient_id ?? 0,
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('保存成功')
editOrderDialogVisible.value = false
getLists()
@@ -16,7 +16,8 @@
<code>一键打包</code>
产物一致的安装包并填入打包目录中的 SHA-256Windows 推荐使用
Setup.exe用户点击立即更新后会自动安装并重启macOS 继续使用 ZIP
安装包通常超过 200MB优先传到对象存储 / CDN 后粘贴地址
安装包通常超过 200MB本页上传按钮会直传到已配置的腾讯云 COS也可以
自行上传到其他对象存储 / CDN 后粘贴地址
</div>
</el-alert>
<div class="text-xl font-medium mb-[20px]">升级策略</div>
@@ -125,7 +126,9 @@
<el-form-item label="上传安装包">
<div>
<upload
type="file"
v-perms="['setting.desktop_workstation/setConfig']"
type="desktop_package"
direct
:limit="1"
:multiple="false"
:show-progress="true"
@@ -136,8 +139,8 @@
<el-button type="primary" plain>选择安装包并上传</el-button>
</upload>
<div class="form-tips">
仅建议上传较小的包大文件请先传到对象存储再把地址和 SHA-256
填到上方Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验
安装包将分片直传腾讯云 COS不经过业务服务器支持 EXE / ZIP最大
2GBWindows 自动安装程序必须使用 HTTPS 地址并开启证书校验
</div>
</div>
</el-form-item>
+59 -35
View File
@@ -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) => {
@@ -1962,17 +1984,18 @@ const submitFillIdCard = async () => {
}
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
const handleVideoQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'video'
currentQRCodePatient.value = row
const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
if (!qrcodeRow.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'video'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1983,11 +2006,11 @@ const handleVideoQRCode = async (row: any) => {
feedback.msgError('小程序未配置,请先配置小程序信息')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
doctor_id: row.appointment_doctor_id,
diagnosis_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
doctor_id: qrcodeRow.appointment_doctor_id,
diagnosis_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || '',
mini_program_path: 'pages/login/login'
})
@@ -2004,17 +2027,18 @@ const handleVideoQRCode = async (row: any) => {
}
// 生成确认诊单二维码
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = row
const handleMiniProgramQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
return
}
if (!qrcodeRow.patient_id) {
feedback.msgWarning('患者信息不完整')
return
}
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -2033,11 +2057,11 @@ const handleMiniProgramQRCode = async (row: any) => {
// 获取当前登录用户信息
const currentUser = userStore.userInfo
// 调用生成二维码接口
const result = await generateMiniProgramQrcode({
diagnosis_id: row.id,
doctor_id: row.appointment_doctor_id,
patient_id: row.patient_id,
// 调用生成二维码接口
const result = await generateMiniProgramQrcode({
diagnosis_id: qrcodeRow.id,
doctor_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: currentUser?.id || ''
})
+48 -26
View File
@@ -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 s = Number(row.appointment_status)
@@ -1241,13 +1261,14 @@ const qrcodeAppointmentTimeText = computed(() => {
return p.appointment_time_text || '—'
})
const handleVideoQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
}
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video'
currentQRCodePatient.value = row
const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
}
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1258,11 +1279,11 @@ const handleVideoQRCode = async (row: any) => {
feedback.msgError('小程序未配置')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
doctor_id: row.appointment_doctor_id,
diagnosis_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
doctor_id: qrcodeRow.appointment_doctor_id,
diagnosis_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || '',
mini_program_path: 'pages/login/login'
})
@@ -1275,13 +1296,14 @@ const handleVideoQRCode = async (row: any) => {
}
}
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
}
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = row
const handleMiniProgramQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row)
if (!qrcodeRow) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
}
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = qrcodeRow
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
@@ -1292,11 +1314,11 @@ const handleMiniProgramQRCode = async (row: any) => {
feedback.msgError('小程序未配置')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
diagnosis_id: row.id,
doctor_id: row.appointment_doctor_id,
patient_id: row.patient_id,
}
const result = await generateMiniProgramQrcode({
diagnosis_id: qrcodeRow.id,
doctor_id: qrcodeRow.appointment_doctor_id,
patient_id: qrcodeRow.patient_id,
share_user_id: userStore.userInfo?.id || ''
})
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -45,9 +45,10 @@ export default defineConfig(({ mode }) => {
: 'http://127.0.0.1:8080'
return {
base: '/admin/',
server: {
host: '0.0.0.0',
base: '/admin/',
server: {
port: 5555,
host: '0.0.0.0',
hmr: true,
open: true,
proxy: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+40 -2
View File
@@ -11,6 +11,7 @@ their platform media backends.
"""
import os
import re
import sys
from pathlib import Path
@@ -23,9 +24,46 @@ RESOURCES = PROJECT_ROOT / "resources"
WINDOWS_ICON = RESOURCES / "branding" / "app-icon.ico"
MACOS_ICON = RESOURCES / "branding" / "app-icon.icns"
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"
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():
raise SystemExit(f"Application entry point is missing: {ENTRY_POINT}")
if not (VIDEO_DIST / "index.html").is_file():
@@ -107,7 +145,7 @@ exe = EXE(
icon=str(WINDOWS_ICON) if sys.platform == "win32" else None,
codesign_identity=os.environ.get("MACOS_CODESIGN_IDENTITY") 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(
@@ -1,9 +1,10 @@
# 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(
ffi=FixedFileInfo(
filevers=(0, 1, 0, 0),
prodvers=(0, 1, 0, 0),
filevers=(@VERSION_TUPLE@),
prodvers=(@VERSION_TUPLE@),
mask=0x3f,
flags=0x0,
OS=0x40004,
@@ -18,11 +19,11 @@ VSVersionInfo(
[
StringStruct('CompanyName', 'ZYT'),
StringStruct('FileDescription', '医生工作台'),
StringStruct('FileVersion', '0.1.0.0'),
StringStruct('FileVersion', '@VERSION_STRING@'),
StringStruct('InternalName', 'DoctorWorkstation'),
StringStruct('OriginalFilename', 'DoctorWorkstation.exe'),
StringStruct('ProductName', '医生工作台'),
StringStruct('ProductVersion', '0.1.0.0')
StringStruct('ProductVersion', '@VERSION_STRING@')
]
)
]),
+4 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "zhenyang-doctor-workstation"
version = "0.1.0"
dynamic = ["version"]
description = "Cross-platform doctor consultation workstation for Windows and macOS"
readme = "README.md"
requires-python = ">=3.11"
@@ -31,6 +31,9 @@ build = [
[project.scripts]
doctor-workstation = "doctor_workstation.app:main"
[tool.hatch.version]
path = "src/doctor_workstation/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/doctor_workstation"]
@@ -46,4 +49,3 @@ target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"]
+158
View File
@@ -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 SetupURL 应为 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`。
+171
View File
@@ -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 契约,再运行相关完整测试和冻结构建门禁;仅本分析任务未修改生产代码、也未执行会连接线上环境的测试。
+143
View File
@@ -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 使用受控 configQSettings 值未被 `_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。只有这四层都成立,才不是单纯的视觉隐藏。
+79
View File
@@ -0,0 +1,79 @@
# Windows 1.2.0 正式包重建结果(DEBUG_MODE=False
- 执行日期:2026-08-28Asia/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` 逐字符匹配;清单恰好包含两条记录。
+57
View File
@@ -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`.
+166
View File
@@ -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 clientUI `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-28Asia/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 环境):通过
- ZIP7-Zip 报告 `Everything is Ok`
- 安装器:Inno Setup 6.7.3 编译成功,`Successful compile (140.609 sec)`
构建期间有非阻断警告:Vite 报告单个压缩后 chunk 超过 500 kBPyInstaller 报告一个 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` 逐字符一致。
+182
View File
@@ -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` 未提交改动均已保留。
+52
View File
@@ -0,0 +1,52 @@
# Windows 1.2.0 正式打包结果
- 打包日期:2026-08-28Asia/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`、当前用户开始菜单快捷方式及本次当前用户卸载注册表键均不存在。隔离测试根目录按设计保留,仅包含安装/卸载日志和隔离应用日志,便于审计。
+233
View File
@@ -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-256SHA-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 不消费该形态。
+447
View File
@@ -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))
+200
View File
@@ -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 和
finishedfinished 后断开连接并 `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 后才调用 quithelper 未 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-28Asia/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.143 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 kBPyInstaller 报告一个可选 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-28Asia/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 可读取。
- 工作树原本已有大量未提交改动,包括本报告涉及的生产文件和测试;本次未修改、覆盖或回退它们。
- 除新增本文档外,没有修改生产代码或测试。
+3 -3
View File
@@ -131,9 +131,9 @@ PYINSTALLER_PYTHON="$python_bin" SKIP_FRONTEND_INSTALL=1 \
/bin/bash "$script_dir/build_macos.sh"
artifact="$project_root/dist/DoctorWorkstation.app"
project_version="$(/usr/bin/awk -F '"' '/^version = "/ { print $2; exit }' \
"$project_root/pyproject.toml")"
[[ -n "$project_version" ]] || macos_die "无法从 pyproject.toml 读取版本号。"
project_version="$(/usr/bin/awk -F '"' '/^__version__ = "/ { print $2; exit }' \
"$project_root/src/doctor_workstation/__init__.py")"
[[ -n "$project_version" ]] || macos_die "无法从 doctor_workstation.__version__ 读取版本号。"
case "$(uname -m)" in
arm64) release_arch="arm64" ;;
x86_64) release_arch="x64" ;;
+12 -11
View File
@@ -20,7 +20,7 @@ $ReleaseLauncher = Join-Path $DistributionRoot "Start_DoctorWorkstation.bat"
$InstallerDefinition = Join-Path $ProjectRoot "packaging\windows\doctor_workstation.iss"
$EnsureInstallerCompiler = Join-Path $PSScriptRoot "ensure_inno_setup.ps1"
$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"
$WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico"
@@ -53,7 +53,7 @@ try {
$BuildScript,
$PackageLock,
(Join-Path $ProjectRoot "uv.lock"),
$ProjectMetadata,
$VersionSource,
$MediaSmokeHook,
$WindowsIcon,
$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"
$Node = Find-Application -Name "node.exe"
if (-not $Npm -or -not $Node) {
@@ -156,15 +166,6 @@ try {
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 (
"DoctorWorkstation-Windows-x64-$ProjectVersion.zip"
)
+8 -2
View File
@@ -1,5 +1,11 @@
"""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"
+373 -64
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import logging
import os
import sys
import time
from contextlib import suppress
import time
from collections.abc import Mapping, Sequence
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
@@ -34,17 +35,179 @@ from doctor_workstation.services import (
build_repository,
)
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
from doctor_workstation.ui.widgets import (
friendly_error,
run_async,
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
from doctor_workstation.ui.widgets import (
first_value,
friendly_error,
gender_text,
get_value,
run_async,
set_authentication_expired_handler,
show_toast,
)
from doctor_workstation.video import BackendMode, launch_video_call
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):
@@ -237,19 +400,22 @@ class ApplicationController(QObject):
"""Own windows, repositories and the authenticated application lifecycle."""
def __init__(self, application: QApplication, config: AppConfig) -> None:
super().__init__()
self.application = application
self.config = config
self.token_store = TokenStore(config.config_dir / "credentials.json")
self.demo_repository = DemoDoctorRepository()
super().__init__()
self.application = application
self.config = config
self.debug_mode = bool(getattr(config, "debug_mode", False))
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.login_window: LoginWindow | None = None
self.shell_window: ShellWindow | None = None
self.current_repository: Any = None
self.current_demo_mode = config.demo_mode
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
self.current_demo_mode = self.debug_mode and config.demo_mode
self.video_calls: dict[str, Any] = {}
self.video_pending: dict[str, object] = {}
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_in_progress = False
self._restore_worker: Any = None
@@ -340,10 +506,11 @@ class ApplicationController(QObject):
if self.login_window is not None:
self.login_window.config = self.config
def _on_demo_mode_changed(self, enabled: bool) -> None:
self.current_demo_mode = enabled
if enabled:
self._cancel_session_restore()
def _on_demo_mode_changed(self, enabled: bool) -> None:
allowed = self.debug_mode and enabled
self.current_demo_mode = allowed
if allowed:
self._cancel_session_restore()
def _rebuild_remote_repository(self) -> None:
old = self.remote_repository
@@ -503,7 +670,8 @@ class ApplicationController(QObject):
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
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:
with suppress(Exception):
repository.logout()
@@ -547,16 +715,17 @@ class ApplicationController(QObject):
self._logout(message="登录状态已失效,请重新登录。")
return True
def _logout(self, *, message: str = "") -> None:
"""Clear authenticated resources and return to the login window."""
calls = tuple(self.video_calls.values())
def _logout(self, *, message: str = "") -> None:
"""Clear authenticated resources and return to the login window."""
self._restore_video_preview(activate=False)
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
self._wait_for_video_lifecycle(calls, timeout=1.25)
self.video_calls.clear()
self.video_pending.clear()
self.video_calls.clear()
self.video_pending.clear()
for dialog in self.demo_video_dialogs.values():
dialog.close()
self.demo_video_dialogs.clear()
@@ -579,7 +748,8 @@ class ApplicationController(QObject):
patient_id = payload.get("patient_id")
diagnosis_id = payload.get("diagnosis_id")
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, ""):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return
@@ -633,27 +803,49 @@ class ApplicationController(QObject):
marker = object()
self.video_pending[call_key] = marker
def get_ticket() -> Any:
return repository.get_call_ticket(
patient_id=int(patient_id),
diagnosis_id=int(diagnosis_id),
)
def get_video_context() -> tuple[Any, dict[str, str]]:
ticket = repository.get_call_ticket(
patient_id=int(patient_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:
if self.video_pending.get(call_key) is not marker:
return
run_async(
get_ticket,
on_success=lambda ticket: self._launch_video(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
run_async(
get_video_context,
on_success=lambda context: self._launch_video(
context[0],
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
open_im=open_im,
patient_name=patient_name,
),
open_im=open_im,
patient_name=patient_name,
patient_case=context[1],
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
@@ -696,8 +888,9 @@ class ApplicationController(QObject):
repository: Any,
call_key: str,
marker: object,
open_im: bool = False,
patient_name: str = "患者",
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
) -> None:
if self.video_pending.get(call_key) is not marker:
return
@@ -723,9 +916,13 @@ class ApplicationController(QObject):
local_dist=video_dist_path(),
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
open_im=open_im,
patient_name=patient_name,
)
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=lambda current_id=diagnosis_id: (
self._open_video_diagnosis(current_id)
),
)
except Exception as error:
LOGGER.exception("video call could not be launched")
show_toast(
@@ -734,20 +931,123 @@ class ApplicationController(QObject):
"danger",
5600,
)
return
self.video_calls[call_key] = call
return
self.video_calls[call_key] = call
qt_window = getattr(call, "qt_window", None)
if qt_window is not None:
qt_window.destroyed.connect(
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
key,
expected,
)
)
def _release_video_call(self, call_key: str, call: Any) -> None:
if self.video_calls.get(call_key) is call:
self.video_calls.pop(call_key, None)
)
)
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
"""Open the diagnosis while keeping its live video visible as a preview."""
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:
if self.demo_video_dialogs.get(call_key) is dialog:
@@ -775,18 +1075,27 @@ class ApplicationController(QObject):
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = app_icon_path()
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
def shutdown(self) -> None:
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
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."""
if self._shutting_down:
return
self._shutting_down = True
self._cancel_session_restore()
set_authentication_expired_handler(None)
calls = tuple(self.video_calls.values())
self._shutting_down = True
self._cancel_session_restore()
self.app_updater.shutdown()
set_authentication_expired_handler(None)
self._restore_video_preview(activate=False)
calls = tuple(self.video_calls.values())
for call in calls:
with suppress(Exception):
call.close()
+27 -6
View File
@@ -14,6 +14,8 @@ from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from doctor_workstation import DEBUG_MODE, ONLINE_API_BASE_URL
try:
from dotenv import load_dotenv
except ImportError: # pragma: no cover - optional during pure unit tests
@@ -90,7 +92,8 @@ class AppConfig:
"""Runtime configuration loaded from environment and user preferences."""
api_base_url: str = ""
demo_mode: bool = True
demo_mode: bool = False
debug_mode: bool = DEBUG_MODE
video_mode: str = "embedded"
video_web_url: str = ""
verify_ssl: bool = True
@@ -115,18 +118,28 @@ class AppConfig:
if load_dotenv is not None:
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:
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 = ""
if not debug_mode and not api_url:
raise ValueError("正式模式下 ONLINE_API_BASE_URL 不能为空")
config = cls(
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_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")),
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"))
except (OSError, ValueError, TypeError):
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}
if "api_base_url" in clean:
try:
@@ -159,12 +174,18 @@ class AppConfig:
target = self.preferences_file
temporary = target.with_suffix(".tmp")
payload = asdict(self)
payload.pop("debug_mode", None)
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
with suppress(OSError):
os.chmod(temporary, 0o600)
temporary.replace(target)
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:
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"}:
File diff suppressed because it is too large Load Diff
@@ -1705,6 +1705,24 @@ class DemoDoctorRepository:
}
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(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Patient]:
@@ -455,6 +455,14 @@ class DoctorRepository(Protocol):
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
"""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]:
"""Return a patient-scoped order detail."""
@@ -1761,6 +1769,40 @@ class RemoteDoctorRepository:
payload = self.client.get("config/dict", {"type": 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(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> 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",
]
+146 -12
View File
@@ -7,12 +7,25 @@ object names and dynamic properties so adjacent pages keep their own styling.
from __future__ import annotations
import weakref
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
import weakref
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import ceil
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 (
QColor,
QHideEvent,
@@ -23,7 +36,7 @@ from PySide6.QtGui import (
QResizeEvent,
QShowEvent,
)
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import (
QAbstractButton,
QAbstractItemView,
@@ -50,6 +63,8 @@ from PySide6.QtWidgets import (
)
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"""
QDialog#DiagnosisDialogRoot {
@@ -1243,7 +1258,7 @@ class DiagnosisLineEdit(QLineEdit):
self.setText(text)
class DiagnosisTextEdit(QPlainTextEdit):
class DiagnosisTextEdit(QPlainTextEdit):
"""Multiline editor using the same scoped field state as line inputs."""
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.
return QSize(48, self._hint_height)
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
return QSize(160, self._hint_height)
class DiagnosisComboBox(QComboBox):
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
return QSize(160, self._hint_height)
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."""
def __init__(
@@ -2467,7 +2584,7 @@ class _RemoteImageButton(QPushButton):
self._fallback_size = QSize(maximum_size) if cover else QSize(132, 64)
self._fallback_text = fallback_text
self._cover = cover
self._manager = QNetworkAccessManager(self)
self._manager = shared_image_manager(self)
self._reply: QNetworkReply | None = None
self._generation = 0
self._rendered_pixmap = QPixmap()
@@ -2508,6 +2625,18 @@ class _RemoteImageButton(QPushButton):
reply.deleteLater()
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:
self._source = str(source).strip()
generation = self._invalidate_request()
@@ -3116,6 +3245,8 @@ class CaseGrid(QFrame):
"2": "复诊",
}.get(diagnosis_type, diagnosis_type or "病例")
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}")
for key, labels in self.value_labels.items():
raw = _pick(
@@ -3152,6 +3283,9 @@ class CaseGrid(QFrame):
"pregnancy_history",
}:
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, ""):
rendered = f"{rendered}"
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 remaining sources underneath it. This module mirrors that contract with
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
@@ -12,14 +15,16 @@ import weakref
from collections.abc import Sequence
from typing import Any
from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QCloseEvent, QDesktopServices
from PySide6.QtCore import QCoreApplication, QObject, QSize, Qt, QUrl, Signal
from PySide6.QtGui import QCloseEvent, QDesktopServices, QKeyEvent, QPixmap, QResizeEvent
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSizePolicy,
QSlider,
QStackedLayout,
@@ -656,6 +661,449 @@ class RecordingPlayerDialog(QDialog):
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:
seconds = max(0, int(milliseconds) // 1000)
return f"{seconds // 60:02d}:{seconds % 60:02d}"
@@ -663,13 +1111,18 @@ def _clock(milliseconds: int) -> str:
__all__ = [
"MULTIMEDIA_AVAILABLE",
"ImagePreviewDialog",
"InlineRecordingPlayer",
"RecordingPlaybackCell",
"RecordingPlayerDialog",
"alternate_recording_label",
"image_display_name",
"is_previewable_image",
"normalize_recording_urls",
"open_safe_http_url",
"preferred_recording_url",
"safe_http_url",
"safe_image_sources",
"shared_image_manager",
"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": "离异"}
# 后台 yesNoText1 有,其余 无。
_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,
DiagnosisTabWidget,
DiagnosisTextEdit,
ExpandableDiagnosisTextEdit,
LoadingOverlay,
MessageStrip,
NotesTimeline,
@@ -300,7 +301,7 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
("渠道", "create_source", 12, "create_source"),
),
(("统计端就诊卡", "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"),),
(("大便情况", "stool_condition", 24, "stool_condition_choices"),),
(("腰肾情况", "kidney_condition", 24, "kidney_condition_choices"),),
(("其他补充", "symptoms", 24, "textarea3"),),
(("其他补充", "symptoms", 24, "expandable_textarea3"),),
),
),
("既往史", ((("既往史", "past_history", 24, "past_history_choices"),),)),
@@ -757,9 +758,11 @@ class DiagnosisDialog(QDialog):
parent: QWidget | None = None,
*,
permissions: Any = None,
embedded: bool = False,
) -> None:
super().__init__(parent)
self.repository = repository
self._embedded = bool(embedded)
self.permissions = (
permissions
if permissions is not None
@@ -881,11 +884,17 @@ class DiagnosisDialog(QDialog):
)
self.setObjectName("DiagnosisDialogRoot")
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
if self._embedded:
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.setMinimumSize(760, 520)
self.resize(1024, 640)
self.setStyleSheet(DIAGNOSIS_QSS)
self.view_stack = QStackedLayout(self)
@@ -910,16 +919,25 @@ class DiagnosisDialog(QDialog):
root = QVBoxLayout(page)
root.setContentsMargins(0, 0, 0, 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.setObjectName("DiagnosisReadonlyScroll")
self.readonly_scroll.setWidgetResizable(True)
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()
self.readonly_content_layout = QVBoxLayout(content)
self.readonly_content_layout.setContentsMargins(16, 16, 16, 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.setObjectName("DiagnosisReadonlyErrorCard")
self.readonly_error.setProperty("diagnosisReadonlyCard", True)
@@ -980,16 +998,16 @@ class DiagnosisDialog(QDialog):
left_layout = QHBoxLayout(self.readonly_hero_left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(12)
back = QPushButton("← 返回")
back.setObjectName("DiagnosisReadonlyBack")
back.setCursor(Qt.CursorShape.PointingHandCursor)
back.setStyleSheet(
self.readonly_back_button = QPushButton(" 收起资料" if self._embedded else " 返回")
self.readonly_back_button.setObjectName("DiagnosisReadonlyBack")
self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.readonly_back_button.setStyleSheet(
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
"color:#5265F6;font-size:13px;font-weight:500;}"
"QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}"
)
back.clicked.connect(self.reject)
left_layout.addWidget(back)
self.readonly_back_button.clicked.connect(self.reject)
left_layout.addWidget(self.readonly_back_button)
title = QLabel("患者信息详情")
title.setObjectName("DiagnosisReadonlyTitle")
left_layout.addWidget(title)
@@ -1008,6 +1026,13 @@ class DiagnosisDialog(QDialog):
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
self.readonly_status.setProperty("severity", "neutral")
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_right, 0, 1, Qt.AlignmentFlag.AlignRight)
layout.setColumnStretch(0, 1)
@@ -1234,11 +1259,12 @@ class DiagnosisDialog(QDialog):
occupied = 0
for label, key, span, field_kind in row_fields:
editor = self._ensure_editor(key, field_kind)
display = (
editor.wrap_with_unit()
if isinstance(editor, DiagnosisNumberEdit)
else editor
)
if isinstance(editor, DiagnosisNumberEdit):
display = editor.wrap_with_unit()
elif isinstance(editor, ExpandableDiagnosisTextEdit):
display = editor.wrap_with_expander(label)
else:
display = editor
container = self._field_container(label, display, span, editor=editor)
row_layout.addWidget(container, span)
fields.append(container)
@@ -1371,9 +1397,13 @@ class DiagnosisDialog(QDialog):
columns=4 if multiple else 5,
)
self._choice_fields[key] = editor
elif field_kind.startswith("textarea"):
elif field_kind.startswith(("textarea", "expandable_textarea")):
rows = int(field_kind[-1])
editor = DiagnosisTextEdit(rows)
editor = (
ExpandableDiagnosisTextEdit(rows)
if field_kind.startswith("expandable_")
else DiagnosisTextEdit(rows)
)
else:
editor = DiagnosisLineEdit()
editor.setObjectName(f"DiagnosisField_{key.removeprefix('__')}")
@@ -1784,6 +1814,8 @@ class DiagnosisDialog(QDialog):
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
def _sync_host_geometry(self) -> None:
if self._embedded:
return
owner = self._owner
if owner is None:
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)
def _install_owner_filter(self) -> None:
if self._embedded:
return
if self._owner is not None and not self._owner_filter_installed:
self._owner.installEventFilter(self)
self._owner_filter_installed = True
@@ -1824,6 +1858,10 @@ class DiagnosisDialog(QDialog):
def _rebind_owner(self) -> None:
"""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()
candidate = parent.window() if parent is not None else None
if candidate is self:
@@ -1846,8 +1884,9 @@ class DiagnosisDialog(QDialog):
super().resizeEvent(event)
def showEvent(self, event: Any) -> None:
self._rebind_owner()
self._sync_host_geometry()
if not self._embedded:
self._rebind_owner()
self._sync_host_geometry()
self._update_drawer_geometry()
self._reflow_readonly_hero()
super().showEvent(event)
@@ -1911,8 +1950,20 @@ class DiagnosisDialog(QDialog):
)
present_diagnosis_ai_report(self.repository, self.permissions, self, row)
def open_view_only(self, diagnosis_id: int, *, seed: Any = None) -> None:
self.open_for(diagnosis_id, editable=False, seed=seed, view_only=True)
def open_view_only(
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(
self,
@@ -1920,9 +1971,12 @@ class DiagnosisDialog(QDialog):
*,
editable: bool = False,
seed: Any = None,
authoritative_detail: Any = None,
view_only: bool = False,
modeless: bool = False,
auto_show: bool = True,
) -> None:
"""Open immediately, then replace the seed with authoritative server data."""
"""Prepare a diagnosis view, optionally showing it immediately."""
self._rebind_owner()
for player in list(self._recording_players):
@@ -1967,13 +2021,13 @@ class DiagnosisDialog(QDialog):
self._daily_todo_status = None
self._orders_page = 1
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.refresh_permissions()
self.view_stack.setCurrentWidget(
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(
"患者信息详情"
if self._standalone_readonly
@@ -1999,15 +2053,38 @@ class DiagnosisDialog(QDialog):
)
self._clear_tables()
self._clear_message()
if seed is not None:
self._render(seed, [], [])
if self._detail is not None:
self._render(self._detail, [], [])
self._sync_form_interactivity()
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._set_loading(True)
self._sync_host_geometry()
self.show()
self.raise_()
self._start_detail_load()
def _start_detail_load(self) -> None:
@@ -16,7 +16,7 @@ import re
import sys
import tempfile
from collections.abc import Iterable, Mapping, Sequence
from datetime import date, datetime
from datetime import date
from pathlib import Path
from time import monotonic
from typing import Any
@@ -80,6 +80,7 @@ from PySide6.QtWidgets import (
QScrollArea,
QSizePolicy,
QSpinBox,
QSplitter,
QTableWidget,
QTableWidgetItem,
QTabWidget,
@@ -89,6 +90,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..diagnosis_terms import shared_terms
from ..widgets import (
MessageBanner,
display_text,
@@ -103,6 +105,7 @@ from ..widgets import (
run_async,
show_toast,
)
from .diagnosis import DiagnosisDialog as _StructuredDiagnosisDialog
PRESCRIPTION_DRAWER_QSS = r"""
QFrame#PrescriptionDrawerSurface {
@@ -2696,6 +2699,7 @@ class PrescriptionEditorDialog(QDialog):
self._source = _mapping(prescription)
self._loading_data = False
self._linked_order_generation = 0
self._diagnosis_view: _StructuredDiagnosisDialog | None = None
self._prescribing_creator_id = _int(
first_value(
prescription,
@@ -2708,17 +2712,37 @@ class PrescriptionEditorDialog(QDialog):
self.setModal(True)
self.setMinimumSize(420, 600)
self.resize(self.DRAWER_WIDTH, 900)
root = QVBoxLayout(self)
root = QHBoxLayout(self)
root.setContentsMargins(0, 0, 0, 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.setObjectName("PrescriptionDrawerSurface")
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.setContentsMargins(0, 0, 0, 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()
surface_layout.addWidget(self.header)
@@ -2919,7 +2943,7 @@ class PrescriptionEditorDialog(QDialog):
self.diagnosis_button.setProperty("size", "small")
self.diagnosis_button.setVisible(diagnosis_id > 0)
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)
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))]
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:
parent = self.parentWidget()
if parent is None:
return
anchor = parent.window()
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())
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:
super().showEvent(event)
@@ -3835,6 +3902,11 @@ class PrescriptionEditorDialog(QDialog):
self.validation.clear()
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):
"""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)
return html.escape(display_text(value, default))
terms = shared_terms()
def value(*keys: str, default: Any = None) -> Any:
for key in keys:
translated = case.get(f"{key}_text")
@@ -3977,7 +4051,9 @@ def render_case_record_html(prescription: Any, *, print_layout: bool = False) ->
return translated
candidate = case.get(key)
if candidate not in (None, ""):
return candidate
# 处方快照是开方当时的原始 code,没有后端补的 *_text
# 这里按字典把它翻成中文,翻不了才回显原值。
return terms.value_label(key, candidate) or candidate
return default
def present_date(raw: Any) -> Any:
@@ -5097,8 +5173,8 @@ class PrescriptionDetailDialog(QDialog):
painter.end()
class DiagnosisDetailDialog(QDialog):
"""Read-only diagnosis view preserving the important admin tab boundaries."""
class DiagnosisDetailDialog(_StructuredDiagnosisDialog):
"""Compatibility entry that renders prescription-linked diagnoses with the shared UI."""
def __init__(
self,
@@ -5108,180 +5184,29 @@ class DiagnosisDetailDialog(QDialog):
repository: Any = None,
permissions: Any = None,
) -> None:
super().__init__(parent)
source = _mapping(diagnosis)
self.repository = repository
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
if repository is None:
raise ValueError("repository is required to show diagnosis details")
super().__init__(repository, parent, permissions=permissions)
self.setWindowTitle("诊单详情(只读)")
self.resize(880, 700)
root = QVBoxLayout(self)
tabs = QTabWidget()
groups = (
(
"病历",
(
"id",
"patient_id",
"patient_name",
"gender",
"age",
"phone",
"chief_complaint",
"present_illness",
"past_history",
"diagnosis",
"syndrome",
"treatment",
),
source = _mapping(diagnosis)
nested = _mapping(source.get("diagnosis"))
diagnosis_id = _int(
first_value(
nested,
"id",
"diagnosis_id",
default=first_value(source, "id", "diagnosis_id"),
),
("医生备注", ("doctor_notes", "doctor_note", "notes")),
("日常记录", ("daily_records", "blood_records")),
("处方", ("prescriptions", "case_records")),
("沟通与指派", ("call_records", "chat_records", "assign_logs", "appointments")),
0,
)
for title, keys in groups:
browser = QTextBrowser()
rows = []
for key in keys:
value = source.get(key)
if value in (None, "", [], {}):
continue
rendered = (
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,
if diagnosis_id <= 0:
raise ValueError("diagnosis detail is missing a valid diagnosis id")
self.open_for(
diagnosis_id,
editable=False,
seed=source,
authoritative_detail=source,
auto_show=False,
)
File diff suppressed because it is too large Load Diff
+69 -21
View File
@@ -460,6 +460,7 @@ class LoginWindow(QMainWindow):
super().__init__(parent)
self.repository = repository
self.config = config
self.debug_mode = bool(getattr(config, "debug_mode", False))
if demo_repository is None:
demo_repository = getattr(config, "demo_repository", None)
self.demo_repository = demo_repository
@@ -751,6 +752,7 @@ class LoginWindow(QMainWindow):
if self.demo_repository is None:
self.demo_check.setToolTip("当前未配置演示数据")
self.demo_check.toggled.connect(self._on_demo_toggled)
self.demo_check.setVisible(self.debug_mode)
choices.addWidget(self.demo_check)
card_layout.addLayout(choices)
card_layout.addSpacing(26)
@@ -765,8 +767,12 @@ class LoginWindow(QMainWindow):
self.login_button.setGraphicsEffect(login_shadow)
self.login_button.clicked.connect(self.submit)
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.setSpacing(18)
line_left = QFrame()
@@ -782,8 +788,8 @@ class LoginWindow(QMainWindow):
line_right.setFrameShape(QFrame.Shape.HLine)
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
divider.addWidget(line_right, 1)
card_layout.addLayout(divider)
card_layout.addSpacing(20)
debug_settings_layout.addLayout(divider)
debug_settings_layout.addSpacing(20)
self.server_toggle = _ServerButton("服务器设置 +")
self.server_toggle.setObjectName("ServerSettingsToggle")
@@ -791,7 +797,7 @@ class LoginWindow(QMainWindow):
self.server_toggle.setCheckable(True)
self.server_toggle.setFixedHeight(56)
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.setObjectName("SubtleCard")
@@ -850,9 +856,11 @@ class LoginWindow(QMainWindow):
self.server_hint.setWordWrap(True)
server_layout.addWidget(self.server_hint)
self.server_panel.setVisible(False)
card_layout.addSpacing(9)
card_layout.addWidget(self.server_panel)
card_layout.addSpacing(12)
debug_settings_layout.addSpacing(9)
debug_settings_layout.addWidget(self.server_panel)
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.setContentsMargins(0, 0, 0, 0)
@@ -922,8 +930,13 @@ class LoginWindow(QMainWindow):
configured_url = getattr(self.config, "base_url", "") or getattr(
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(
str(self.settings.value("server/base_url", configured_url) or "")
str(restored_url or "")
)
try:
configured_timeout = getattr(self.config, "request_timeout", 60)
@@ -937,15 +950,22 @@ class LoginWindow(QMainWindow):
configured_verify_ssl = _setting_bool(
getattr(self.config, "verify_ssl", True), True
)
verify_ssl = _setting_bool(
self.settings.value("server/verify_ssl", configured_verify_ssl),
configured_verify_ssl,
verify_ssl = (
_setting_bool(
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)
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)
):
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.restore_remembered_credentials()
if remembered:
@@ -954,6 +974,8 @@ class LoginWindow(QMainWindow):
self.account_edit.setFocus()
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"):
scope = self.server_url_edit.text().strip()
if scope:
@@ -1000,6 +1022,12 @@ class LoginWindow(QMainWindow):
self.reveal_button.setText("隐藏" if visible else "显示")
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.server_toggle.setEnabled(not enabled and not self._loading)
self.demo_mode_changed.emit(enabled)
@@ -1013,6 +1041,10 @@ class LoginWindow(QMainWindow):
self.password_edit.setPlaceholderText("请输入密码")
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_toggle.setText("服务器设置 -" if expanded else "服务器设置 +")
self.server_panel.updateGeometry()
@@ -1027,6 +1059,13 @@ class LoginWindow(QMainWindow):
self._apply_server_settings(announce=True)
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("/")
if base_url and not base_url.startswith(
("https://", "http://localhost", "http://127.0.0.1")
@@ -1079,7 +1118,7 @@ class LoginWindow(QMainWindow):
def submit(self) -> None:
if self._loading:
return
demo_mode = self.demo_check.isChecked()
demo_mode = self.debug_mode and self.demo_check.isChecked()
remember_account = self.remember_check.isChecked()
account = self.account_edit.text().strip()
password = self.password_edit.text()
@@ -1150,9 +1189,13 @@ class LoginWindow(QMainWindow):
self.password_edit.setEnabled(not loading)
self.remember_check.setEnabled(not loading)
self.reveal_button.setEnabled(not loading)
self.demo_check.setEnabled(not loading and self.demo_repository is not None)
self.server_toggle.setEnabled(not loading and not self.demo_check.isChecked())
self.server_panel.setEnabled(not loading)
self.demo_check.setEnabled(
self.debug_mode and not loading and self.demo_repository is not None
)
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.timeout_spin.setEnabled(not loading)
self.allow_self_signed_check.setEnabled(not loading)
@@ -1184,7 +1227,7 @@ class LoginWindow(QMainWindow):
if remember_account is None:
remember_account = self.remember_check.isChecked()
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
clearer = getattr(self.credential_store, "clear_password", None)
if (
@@ -1223,13 +1266,18 @@ class LoginWindow(QMainWindow):
def _on_login_error(self, error: Exception) -> None:
error_text = str(error).lower()
if (
certificate_error = (
"certificate_verify_failed" in error_text
or "self-signed certificate" in error_text
):
)
if certificate_error and self.debug_mode:
self.server_toggle.setChecked(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.login_failed.emit(message)
self.password_edit.selectAll()
@@ -4,10 +4,12 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from html import escape
from math import ceil
from typing import Any
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
from PySide6.QtCore import QDateTime, QModelIndex, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QTextDocument, QTextOption
from PySide6.QtWidgets import (
QComboBox,
QDateTimeEdit,
@@ -23,6 +25,9 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QStackedWidget,
QStyle,
QStyledItemDelegate,
QStyleOptionViewItem,
QVBoxLayout,
QWidget,
)
@@ -381,26 +386,84 @@ def _formula(value: Any) -> 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 []
herbs = get_value(row, "herbs", None) or []
if not isinstance(herbs, (list, tuple)) or not herbs:
herbs = get_value(source, "herbs", None)
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 ["请开方,当前处方药材为空白"]
seen: set[str] = set()
duplicate: list[str] = []
for herb in herbs:
name = str(first_value(herb, "name", "medicine_name", default="")).strip()
for name in names:
key = "".join(name.split()).lower()
if key and key in seen and name not in duplicate:
duplicate.append(name)
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:
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:
gender = first_value(row, "gender", default=None)
gender_text = "" if gender in (1, "1") else "" if gender in (0, "0") else "未知"
@@ -698,7 +761,7 @@ class PrescriptionsPage(QWidget):
self.table = SortableTable(
[
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
TableColumn("sn", "处方编号", 174, _sn_cell),
TableColumn("sn", "处方编号", 260, _sn_cell),
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
TableColumn("prescription_type", "处方类型", 96),
TableColumn("is_system_auto", "来源", 88, _source_cell),
@@ -713,6 +776,9 @@ class PrescriptionsPage(QWidget):
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
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).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
@@ -729,6 +795,10 @@ class PrescriptionsPage(QWidget):
layout.addWidget(self.stack, 1)
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(
self,
text: str,
@@ -910,8 +980,11 @@ class PrescriptionsPage(QWidget):
font.setWeight(QFont.Weight.DemiBold)
sn_item.setFont(font)
warnings = _order_warnings(row)
if warnings:
sn_item.setToolTip("\n".join(warnings))
description = "\n".join(
[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(
first_value(row, "prescription_type", default="")
@@ -1002,6 +1075,7 @@ class PrescriptionsPage(QWidget):
actions.addStretch(1)
self.table.setCellWidget(row_index, 2, actions_host)
self._sync_row_mutation_actions()
self.table.resizeRowsToContents()
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
+406 -49
View File
@@ -34,10 +34,12 @@ from PySide6.QtGui import (
QFont,
QFontMetrics,
QIcon,
QKeySequence,
QPainter,
QPainterPath,
QPen,
QPixmap,
QShortcut,
QTextCursor,
QTextLayout,
QTextOption,
@@ -72,6 +74,7 @@ from ..diagnosis_drawer import DailyRecordPanel
from ..diagnosis_editors import FlowLayout
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import present_ai_consult
from ..dialogs.appointment_complete import COMPLETION_NOTE_LIMIT, AppointmentCompleteDialog
from ..dialogs.prescription_ai import (
can_open_diagnosis_ai_report,
can_use_diagnosis_ai_assistant,
@@ -112,6 +115,12 @@ _AI_AUTOMATIC_REQUEST_SLOTS = BoundedSemaphore(2)
_AI_GENERATION_POOL = QThreadPool()
_AI_GENERATION_POOL.setMaxThreadCount(4)
_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 = (
"仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。"
"系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。"
@@ -157,6 +166,32 @@ QCalendarWidget#ReceptionDateCalendar {
border: 1px solid #DDE5FA;
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"] {
min-height: 32px;
max-height: 32px;
@@ -259,6 +294,7 @@ QWidget#ReceptionPage QLabel#StatusBadge {
font-size: 11px;
font-weight: 500;
}
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton,
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton,
QWidget#ReceptionPage QPushButton#ReceptionImButton {
min-height: 34px;
@@ -282,6 +318,7 @@ QWidget#ReceptionPage QPushButton#ReceptionMoreButton {
font-size: 13px;
font-weight: 500;
}
QWidget#ReceptionPage QPushButton#ReceptionNotifyButton:hover,
QWidget#ReceptionPage QPushButton#ReceptionHistoryButton:hover,
QWidget#ReceptionPage QPushButton#ReceptionImButton:hover {
color: #5469F0;
@@ -304,6 +341,24 @@ QWidget#ReceptionPage QPushButton#ReceptionCompleteButton {
font-size: 13px;
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 {
background-color: #FFFFFF;
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):
"""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_key: tuple[Any, ...] | None = None
self._detail_loading = False
self._completion_pending = False
self._completion_dialog: AppointmentCompleteDialog | None = None
self._detail_requests: set[tuple[int, int]] = set()
self._detail_cancel_events: dict[tuple[int, int], Event] = {}
self._detail_failed_requests: set[tuple[int, int]] = set()
@@ -3677,6 +3754,12 @@ class ReceptionPage(QWidget):
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(5_000)
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:
dialog = getattr(self, "diagnosis_dialog", None)
@@ -3703,6 +3786,14 @@ class ReceptionPage(QWidget):
title.setObjectName("ReceptionQueueTitle")
header_layout.addWidget(title)
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.setObjectName("ReceptionDateButton")
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_stack.addWidget(self.queue_list)
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)
layout.addWidget(self.queue_stack, 1)
self.queue_loading_indicator = _ReceptionQueueLoading(panel)
@@ -3825,13 +3916,14 @@ class ReceptionPage(QWidget):
patient_head.addLayout(identity, 1)
patient_head.addStretch(1)
self.complete_button = QPushButton("结束问诊", 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 = _ReceptionCompleteButton(hero)
self.complete_button.clicked.connect(self._complete_appointment)
self.complete_button.setVisible(self._can_complete)
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.setObjectName("ReceptionHistoryButton")
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.setObjectName("ReceptionMoreButton")
more_menu = QMenu(self.more_button)
more_menu.addAction("通知医助").triggered.connect(self._notify_assistant)
edit_action = more_menu.addAction("编辑病历")
edit_action.setVisible(self._can_edit)
edit_action.triggered.connect(self._edit_diagnosis)
@@ -3865,8 +3956,6 @@ class ReceptionPage(QWidget):
action_compat = QWidget(hero)
action_compat.setFixedSize(0, 0)
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.setProperty("variant", "secondary")
self.edit_button.clicked.connect(self._edit_diagnosis)
@@ -5264,6 +5353,7 @@ class ReceptionPage(QWidget):
appointment_id: int,
diagnosis_id: int | None,
force: bool,
read_only_refresh: bool = False,
) -> None:
if not force and _same_id(patient_id, self._ai_analysis_patient_id):
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_cancel_events.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()
return
request_key = (request_generation, appointment_id, patient_id)
@@ -5330,8 +5420,10 @@ class ReceptionPage(QWidget):
patient_id,
) or cancel_event.is_set():
return _ASYNC_REQUEST_CANCELLED
automatic_slot = _AI_AUTOMATIC_REQUEST_SLOTS.acquire(blocking=False)
if not automatic_slot:
automatic_slot = not read_only_refresh and _AI_AUTOMATIC_REQUEST_SLOTS.acquire(
blocking=False
)
if not read_only_refresh and not automatic_slot:
return _ASYNC_REQUEST_DEFERRED
try:
self._patient_ai_list_started.add(request_key)
@@ -5344,15 +5436,18 @@ class ReceptionPage(QWidget):
return _ASYNC_REQUEST_CANCELLED
return method(patient_id)
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,
on_success=lambda result: self._apply_patient_ai_report_list(
result,
request_generation,
appointment_id,
patient_id,
read_only_refresh=read_only_refresh,
),
on_error=lambda error: self._patient_ai_report_list_error(
error,
@@ -5374,6 +5469,8 @@ class ReceptionPage(QWidget):
request_generation: int,
appointment_id: int,
patient_id: int,
*,
read_only_refresh: bool = False,
) -> None:
request_key = (request_generation, appointment_id, patient_id)
authoritative = request_key in self._patient_ai_list_requests
@@ -5423,6 +5520,12 @@ class ReceptionPage(QWidget):
)
return
if read_only_refresh:
self._set_ai_analysis_state(
"missing", "暂无已保存的患者报告;刷新不会自动生成,可点击重新分析。"
)
return
if not self._can_ai_regenerate:
self._set_ai_analysis_state(
"missing",
@@ -6214,14 +6317,111 @@ class ReceptionPage(QWidget):
self._reset_queue_state()
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."""
if silent and self._queue_loading:
if silent and self._queue_loading and not workspace_refresh:
return
selected_date = self._queue_date or date.today().isoformat()
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)
if loaded > page_size:
page_size = loaded
@@ -6236,12 +6436,15 @@ class ReceptionPage(QWidget):
}
query_key = self._query_key(query)
if query_key != self._queue_query_key:
self._clear_selection()
if not workspace_refresh:
self._clear_selection()
self._reset_queue_state()
self._queue_query = dict(query)
self._queue_query["page_size"] = self._queue_page_size
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:
"""Do not let the timer supersede an explicit or slower queue request."""
@@ -6398,6 +6601,7 @@ class ReceptionPage(QWidget):
*,
append: bool,
silent: bool,
workspace_refresh: bool = False,
) -> None:
self._queue_generation += 1
generation = self._queue_generation
@@ -6409,12 +6613,18 @@ class ReceptionPage(QWidget):
frozen_query = dict(query)
page_no = int(frozen_query["page_no"])
query_key = self._query_key(frozen_query)
run_async(
lambda frozen_query=frozen_query: invoke(
def request() -> Any:
if generation != self._queue_generation:
return _ASYNC_REQUEST_CANCELLED
return invoke(
self.repository,
"list_appointments",
**frozen_query,
),
)
runner = self._run_refresh_read if workspace_refresh else run_async
runner(
request,
on_success=lambda result: self._apply_queue(
result,
generation,
@@ -6422,6 +6632,7 @@ class ReceptionPage(QWidget):
append=append,
query_key=query_key,
silent=silent,
workspace_refresh=workspace_refresh,
),
on_error=lambda error: self._queue_error(error, generation, silent=silent),
on_finished=lambda: self._queue_finished(generation),
@@ -6436,6 +6647,7 @@ class ReceptionPage(QWidget):
append: bool = False,
query_key: tuple[Any, ...] | None = None,
silent: bool = False,
workspace_refresh: bool = False,
) -> None:
if generation != self._queue_generation or query_key not in (None, self._queue_query_key):
return
@@ -6477,27 +6689,38 @@ class ReceptionPage(QWidget):
),
-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
self.queue_list.blockSignals(True)
self._sync_queue_rows(records, append=append)
if row_to_select >= 0 and self.queue_list.currentRow() != 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._sync_queue_row_selection()
self.queue_summary.setText(f"已加载 {len(records)} / 共 {self._queue_total} 位患者")
self.queue_stack.setCurrentIndex(0 if records else 1)
self.queue_banner.clear()
if not records:
self._clear_selection()
if row_to_select < 0:
# 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
chosen = records[row_to_select]
if selected_id is not None and _same_id(_record_id(chosen), selected_id):
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)
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:
extend = get_value(result, "extend", None)
@@ -6570,7 +6793,9 @@ class ReceptionPage(QWidget):
if isinstance(row, QueueRow):
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)
if appointment_id is None:
self._clear_selection()
@@ -6586,9 +6811,16 @@ class ReceptionPage(QWidget):
self._selected_record = record
self._selected_appointment_id = appointment_id
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."""
appointment_id = _record_id(record)
@@ -6611,9 +6843,12 @@ class ReceptionPage(QWidget):
self.detail_banner.show_message("正在加载患者详情…", "info")
self._detail_loading = True
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),
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_finished=lambda: self._detail_finished(generation, appointment_id),
priority=generation,
@@ -6874,7 +7109,12 @@ class ReceptionPage(QWidget):
self.daily_panel.set_loading(False)
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:
expected_id = appointment_id or self._selected_appointment_id
if (
@@ -6963,7 +7203,9 @@ class ReceptionPage(QWidget):
supports_patient_reports = self._can_patient_ai_read and callable(
getattr(self.repository, "list_patient_ai_reports", 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_loading = False
self._ai_analysis_diagnosis_id = None
@@ -6985,6 +7227,37 @@ class ReceptionPage(QWidget):
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:
patient_name = first_value(
appointment,
@@ -8165,7 +8438,10 @@ class ReceptionPage(QWidget):
self.history_button.setEnabled(appointment_id is not None)
self.more_button.setEnabled(appointment_id is not None)
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
self.note_edit.setEnabled(note_enabled)
@@ -8177,6 +8453,10 @@ class ReceptionPage(QWidget):
self, error: Exception, generation: int, appointment_id: int | None = None
) -> None:
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 (
generation == self._detail_generation
and expected_id is not None
@@ -8731,6 +9011,12 @@ class ReceptionPage(QWidget):
self._load_detail(self._selected_record, silent=True, clear=False)
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:
show_toast(self, "当前账号没有完成接诊权限。", "danger")
return
@@ -8745,24 +9031,75 @@ class ReceptionPage(QWidget):
if status not in RECEPTION_STATUSES:
show_toast(self, "仅待接诊或已过号记录可以完成接诊。", "danger")
return
answer = QMessageBox.question(
self,
"确认完成接诊",
"系统会再次核对服务端挂号状态;完成后不可撤销。确认继续吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
dialog = AppointmentCompleteDialog(can_note=self._can_note, parent=self)
self._completion_dialog = dialog
dialog.submitted.connect(
lambda note: self._submit_completion(dialog, generation, appointment_id, note)
)
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
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)
run_async(
lambda: self._complete_after_revalidation(appointment_id),
on_success=lambda _result: self._appointment_completed(generation, appointment_id),
on_error=lambda error: show_toast(self, friendly_error(error), "danger", 4200),
on_finished=lambda: self._restore_action_state(generation, appointment_id),
lambda: self._complete_after_revalidation(appointment_id, note),
on_success=lambda warning: self._completion_succeeded(
dialog, generation, appointment_id, warning
),
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(
self.repository,
"reception_detail",
@@ -8781,15 +9118,35 @@ class ReceptionPage(QWidget):
raise ValueError("服务端挂号记录与当前患者不一致,已停止完成操作")
if status not in RECEPTION_STATUSES:
raise ValueError("挂号状态已变化,请刷新队列后重试")
return invoke(
invoke(
self.repository,
"complete_appointment",
appointment_id=appointment_id,
id=appointment_id,
)
def _appointment_completed(self, generation: int, appointment_id: int) -> None:
show_toast(self, "接诊已完成。", "success")
if note:
# 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):
self._clear_selection()
self.refresh(silent=True)
+92 -3
View File
@@ -41,8 +41,15 @@ from PySide6.QtWidgets import (
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_picker import select_and_present_ai_consult
from .dialogs.diagnosis import DiagnosisDialog
from .dialogs.local_audio_queue import LocalAudioQueueDialog
from .pages import (
AppointmentsPage,
@@ -59,6 +66,7 @@ from .widgets import (
display_text,
first_value,
get_value,
has_permission,
show_toast,
)
@@ -895,6 +903,12 @@ class ShellWindow(QMainWindow):
self._activation_generation = 0
self._activation_refreshed = False
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)
screen = self.screen() or QApplication.primaryScreen()
@@ -1294,9 +1308,7 @@ class ShellWindow(QMainWindow):
"notification", size=38, parent=topbar
)
self.notification_button.setToolTip("消息通知")
self.notification_button.clicked.connect(
lambda: show_toast(self, "当前没有新的系统通知。", "info")
)
self.notification_button.clicked.connect(self._show_chat_notification_summary)
layout.addWidget(self.notification_button)
self.settings_button = _PaintedIconButton("settings", size=38, parent=topbar)
self.settings_button.setToolTip("设置中心")
@@ -1845,8 +1857,58 @@ class ShellWindow(QMainWindow):
if callable(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
super().showEvent(event)
# 登录后就开始轮询:不管医生停在哪个页面,患者进入会话都要提示。
self.chat_notifications.start()
page = self.stack.currentWidget()
if page is None or page is not self._activation_page:
return
@@ -1856,6 +1918,33 @@ class ShellWindow(QMainWindow):
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
if visible:
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False)
+158 -3
View File
@@ -15,9 +15,19 @@ import os
import sys
from pathlib import Path
from string import Template
from typing import Any
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 (
QApplication,
QDialog,
@@ -25,6 +35,7 @@ from PySide6.QtWidgets import (
QFileDialog,
QInputDialog,
QMessageBox,
QTextBrowser,
)
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
@@ -997,6 +1008,111 @@ QToolTip {
).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(
palette: QPalette,
group: QPalette.ColorGroup,
@@ -1085,17 +1201,48 @@ def _polish_dialog_buttons(button_box: QDialogButtonBox) -> None:
_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:
"""Opt a business subwindow into the shared visual contract.
The helper intentionally does not change modality, ownership or result
handling. It only supplies stable styling metadata and semantic button
roles, so existing workflows keep their original behavior.
handling. It only supplies stable styling metadata, semantic button roles
and window affordances, so existing workflows keep their original behavior.
"""
if object_name and not dialog.objectName():
dialog.setObjectName(object_name)
dialog.setProperty("businessDialog", True)
allow_dialog_resize(dialog)
for button_box in dialog.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
_refresh_widget_style(dialog)
@@ -1119,6 +1266,12 @@ class _BusinessDialogStyleFilter(QObject):
QEvent.Type.Show,
}:
_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:
for button_box in watched.findChildren(QDialogButtonBox):
_polish_dialog_buttons(button_box)
@@ -1215,6 +1368,8 @@ def apply_theme(app: QApplication) -> None:
__all__ = [
"COLORS",
"allow_dialog_resize",
"apply_reading_rhythm",
"GLOBAL_QSS",
"METRICS",
"TYPE",
@@ -378,6 +378,8 @@ class VideoCallLauncher:
patient_id: Any = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> Any:
request = self.prepare(
ticket,
@@ -395,6 +397,8 @@ class VideoCallLauncher:
browser_opener=self.browser_opener,
open_im=open_im,
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,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> Any:
"""Normalize a ticket and open a call with the requested backend."""
@@ -427,4 +433,6 @@ def launch_video_call(
patient_id=patient_id,
open_im=open_im,
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],
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> None:
super().__init__()
self.request = request
@@ -306,6 +308,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self.logger = logger
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self._on_open_diagnosis = on_open_diagnosis
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
@@ -451,6 +455,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
config = {
**self.request.to_web_config(),
"patientName": self.patient_name,
"patientCase": self.patient_case,
"mode": "chat" if self.open_im else "video",
}
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:
return
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":
self._start_call_cycle()
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:
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:
try:
succeeded = bool(future.result())
@@ -1214,6 +1234,8 @@ class VideoCallWindow:
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
@@ -1231,6 +1253,8 @@ class VideoCallWindow:
self.repository = repository
self.open_im = bool(open_im)
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.location = resolve_companion_location(
local_dist=local_dist,
@@ -1259,6 +1283,8 @@ class VideoCallWindow:
lifecycle_factory=self._new_lifecycle,
open_im=self.open_im,
patient_name=self.patient_name,
patient_case=self.patient_case,
on_open_diagnosis=self.on_open_diagnosis,
)
except Exception:
self.lifecycle.end("window-open-failed")
@@ -1302,6 +1328,8 @@ def open_video_call(
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
@@ -1316,6 +1344,8 @@ def open_video_call(
browser_opener=browser_opener,
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
).open()
+366
View File
@@ -12,9 +12,11 @@ from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
QDialog,
QLabel,
QPushButton,
QTextBrowser,
QVBoxLayout,
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)
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 "&lt;7.0" in html
assert "&amp;" 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
+217 -22
View File
@@ -29,6 +29,19 @@ def application() -> 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
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
@@ -468,17 +481,33 @@ def test_patient_report_response_owner_must_match_exactly(
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,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[str] = []
previews: list[tuple[tuple[str, ...], int]] = []
monkeypatch.setattr(
ai_consult_module,
"open_safe_http_url",
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())
pane = dialog.records["检查检验"]
dialog.tabs.setCurrentIndex(2)
@@ -498,20 +527,187 @@ def test_exam_tab_filters_foreign_attachments_and_blocks_file_urls(
assert len(thumbnails) == 1
assert thumbnails[0].isEnabled()
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.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())
assert not unsafe.isEnabled()
for button in buttons:
button.click()
assert len(opened) == 2
assert all(target.startswith(("http://", "https://")) for target in opened)
assert all(not target.startswith("file:") for target in opened)
thumbnails[0].click()
# 图片留在工作站内预览,只有非图片附件才交给系统打开。
assert opened == ["https://media.example.invalid/甲/report.pdf"]
assert previews == [(("https://media.example.invalid/甲/tongue.jpg",), 0)] * 2
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,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
@@ -533,23 +729,22 @@ def test_tongue_thumbnail_auto_get_requires_configured_https_origin(
RecordingRemoteImageButton,
)
untrusted = _open_dialog(application, WorkspaceRepository())
assert requested == []
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)
dialog = _open_dialog(application, WorkspaceRepository())
# 舌象照片按字段直接渲染,PDF 报告与 file:// 附件不会发起任何图片请求。
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(
+536 -357
View File
@@ -1,94 +1,124 @@
"""Desktop auto-update check, download and payload discovery."""
from __future__ import annotations
import hashlib
import zipfile
from pathlib import Path
import httpx
import pytest
from doctor_workstation.services import app_update
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_ARCHIVE,
PACKAGE_TYPE_INNO_SETUP,
AppUpdateError,
UpdatePackage,
apply_extracted_update,
apply_inno_setup_update,
compare_version,
discover_payload,
download_package,
fetch_update_offer,
normalize_version,
package_filename,
parse_update_offer,
safe_extract_zip,
validate_installer_download_policy,
validate_windows_installer,
)
def test_normalize_and_compare_versions() -> None:
assert normalize_version("0.2") == "0.2.0"
assert normalize_version("1.2.3.4") == "1.2.3"
assert normalize_version("nope") == ""
assert compare_version("0.1.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
def test_parse_offer_requires_hash_before_install() -> None:
offer = parse_update_offer(
{
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"package": {
"url": "https://cdn.example.com/app.zip",
"sha256": "",
"size": 12,
"filename": "app.zip",
},
"can_install": True,
},
current_version="0.1.0",
)
assert offer.has_update is True
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
"""Desktop auto-update check, download and payload discovery."""
from __future__ import annotations
import hashlib
import zipfile
from pathlib import Path
import httpx
import pytest
from doctor_workstation.services import app_update
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_ARCHIVE,
PACKAGE_TYPE_INNO_SETUP,
AppUpdateError,
UpdatePackage,
apply_extracted_update,
apply_inno_setup_update,
compare_version,
discover_payload,
download_package,
fetch_update_offer,
normalize_version,
package_filename,
parse_update_offer,
safe_extract_zip,
validate_installer_download_policy,
validate_windows_installer,
)
def test_normalize_and_compare_versions() -> None:
assert normalize_version("0.2") == "0.2.0"
assert normalize_version("1.2.3.4") == "1.2.3"
assert normalize_version("nope") == ""
assert compare_version("0.1.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
def test_parse_offer_requires_hash_before_install() -> None:
offer = parse_update_offer(
{
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"package": {
"url": "https://cdn.example.com/app.zip",
"sha256": "",
"size": 12,
"filename": "app.zip",
},
"can_install": True,
},
current_version="0.1.0",
)
assert offer.has_update is True
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_accepts_explicit_inno_setup_type() -> None:
offer = parse_update_offer(
{
"has_update": True,
"enabled": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
"sha256": "a" * 64,
"size": 123,
"filename": "DoctorWorkstation-Setup.exe",
"type": "inno_setup",
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is True
assert offer.package is not None
offer = parse_update_offer(
{
"has_update": True,
"enabled": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
"sha256": "a" * 64,
"size": 123,
"filename": "DoctorWorkstation-Setup.exe",
"type": "inno_setup",
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is True
assert offer.package is not None
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:
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.force is False
assert offer.package is None
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
offer = parse_update_offer(
{
"has_update": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/update.bin",
"sha256": "a" * 64,
"type": package_type,
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
base = {
"has_update": True,
"latest_version": "0.1.0",
"platform": "windows",
"arch": "x64",
"can_install": False,
}
stale = parse_update_offer(
base,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
wrong_platform = parse_update_offer(
{**base, "latest_version": "0.2.0", "platform": "macos"},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert stale.has_update is False
assert wrong_platform.has_update is False
def test_fetch_update_offer_uses_check_endpoint() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={
"code": 1,
"data": {
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"title": "医生工作站 0.2.0",
"notes": "修复登录",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation.zip",
"sha256": "a" * 64,
"size": 2048,
"filename": "DoctorWorkstation.zip",
},
"can_install": True,
},
},
)
with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client:
offer = fetch_update_offer(
client,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.has_update is True
assert offer.force is True
assert offer.can_install is True
assert offer.package is not None
assert "setting.desktop_workstation/check" in str(requests[0].url)
assert "current_version=0.1.0" in str(requests[0].url)
assert "platform=windows" in str(requests[0].url)
def test_safe_extract_rejects_zip_slip(tmp_path: Path) -> None:
archive = tmp_path / "evil.zip"
with zipfile.ZipFile(archive, "w") as bundle:
bundle.writestr("../outside.txt", "nope")
with pytest.raises(AppUpdateError, match="非法路径"):
safe_extract_zip(archive, tmp_path / "out")
def test_discover_windows_payload_prefers_internal_onedir(tmp_path: Path) -> None:
wrapped = tmp_path / "DoctorWorkstation"
wrapped.mkdir()
(wrapped / "_internal").mkdir()
(wrapped / "DoctorWorkstation.exe").write_bytes(b"mz")
(tmp_path / "Start_DoctorWorkstation.bat").write_text("start", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="windows") == wrapped
def test_discover_macos_payload_finds_app_bundle(tmp_path: Path) -> None:
app = tmp_path / "DoctorWorkstation.app"
macos = app / "Contents" / "MacOS"
macos.mkdir(parents=True)
(macos / "DoctorWorkstation").write_text("bin", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="macos") == app
def test_download_package_verifies_sha256_and_reports_progress(tmp_path: Path) -> None:
payload = b"doctor-workstation-zip"
digest = hashlib.sha256(payload).hexdigest()
progress: list[tuple[int, int]] = []
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(
200,
content=payload,
headers={"content-length": str(len(payload))},
)
destination = tmp_path / "pkg.zip"
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256=digest,
progress=lambda received, total: progress.append((received, total)),
transport=httpx.MockTransport(handler),
)
assert destination.read_bytes() == payload
assert progress[-1][0] == len(payload)
def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=b"tampered")
destination = tmp_path / "pkg.zip"
with pytest.raises(AppUpdateError, match="校验失败"):
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256="b" * 64,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
payload = b"short"
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=payload)
destination = tmp_path / "pkg.exe"
with pytest.raises(AppUpdateError, match="文件大小"):
download_package(
"https://cdn.example.com/pkg.exe",
destination,
sha256=hashlib.sha256(payload).hexdigest(),
expected_size=len(payload) + 1,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
assert not (tmp_path / "pkg.exe.part").exists()
def test_windows_installer_download_policy_requires_verified_https() -> None:
with pytest.raises(AppUpdateError, match="HTTPS"):
validate_installer_download_policy(
"http://cdn.example.com/setup.exe",
verify_ssl=True,
)
with pytest.raises(AppUpdateError, match="证书校验"):
validate_installer_download_policy(
"https://cdn.example.com/setup.exe",
verify_ssl=False,
)
validate_installer_download_policy(
"http://127.0.0.1/setup.exe",
verify_ssl=True,
)
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
installer = tmp_path / "Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
assert validate_windows_installer(installer) == installer.resolve()
invalid = tmp_path / "invalid.exe"
invalid.write_bytes(b"PK")
with pytest.raises(AppUpdateError, match="PE"):
validate_windows_installer(invalid)
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
installer = tmp_path / "DoctorWorkstation-Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
spawned: dict[str, Path] = {}
monkeypatch.setattr(app_update.sys, "platform", "win32")
def capture_spawn(
script: Path,
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
offer = parse_update_offer(
{
"has_update": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/update.bin",
"sha256": "a" * 64,
"type": package_type,
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
base = {
"has_update": True,
"latest_version": "0.1.0",
"platform": "windows",
"arch": "x64",
"can_install": False,
}
stale = parse_update_offer(
base,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
wrong_platform = parse_update_offer(
{**base, "latest_version": "0.2.0", "platform": "macos"},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert stale.has_update is False
assert wrong_platform.has_update is False
def test_fetch_update_offer_uses_check_endpoint() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={
"code": 1,
"data": {
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"title": "医生工作站 0.2.0",
"notes": "修复登录",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation.zip",
"sha256": "a" * 64,
"size": 2048,
"filename": "DoctorWorkstation.zip",
},
"can_install": True,
},
},
)
with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client:
offer = fetch_update_offer(
client,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.has_update is True
assert offer.force is True
assert offer.can_install is True
assert offer.package is not None
assert "setting.desktop_workstation/check" in str(requests[0].url)
assert "current_version=0.1.0" in str(requests[0].url)
assert "platform=windows" in str(requests[0].url)
def test_safe_extract_rejects_zip_slip(tmp_path: Path) -> None:
archive = tmp_path / "evil.zip"
with zipfile.ZipFile(archive, "w") as bundle:
bundle.writestr("../outside.txt", "nope")
with pytest.raises(AppUpdateError, match="非法路径"):
safe_extract_zip(archive, tmp_path / "out")
def test_discover_windows_payload_prefers_internal_onedir(tmp_path: Path) -> None:
wrapped = tmp_path / "DoctorWorkstation"
wrapped.mkdir()
(wrapped / "_internal").mkdir()
(wrapped / "DoctorWorkstation.exe").write_bytes(b"mz")
(tmp_path / "Start_DoctorWorkstation.bat").write_text("start", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="windows") == wrapped
def test_discover_macos_payload_finds_app_bundle(tmp_path: Path) -> None:
app = tmp_path / "DoctorWorkstation.app"
macos = app / "Contents" / "MacOS"
macos.mkdir(parents=True)
(macos / "DoctorWorkstation").write_text("bin", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="macos") == app
def test_download_package_verifies_sha256_and_reports_progress(tmp_path: Path) -> None:
payload = b"doctor-workstation-zip"
digest = hashlib.sha256(payload).hexdigest()
progress: list[tuple[int, int]] = []
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(
200,
content=payload,
headers={"content-length": str(len(payload))},
)
destination = tmp_path / "pkg.zip"
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256=digest,
progress=lambda received, total: progress.append((received, total)),
transport=httpx.MockTransport(handler),
)
assert destination.read_bytes() == payload
assert progress[-1][0] == len(payload)
def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=b"tampered")
destination = tmp_path / "pkg.zip"
with pytest.raises(AppUpdateError, match="校验失败"):
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256="b" * 64,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
payload = b"short"
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=payload)
destination = tmp_path / "pkg.exe"
with pytest.raises(AppUpdateError, match="文件大小"):
download_package(
"https://cdn.example.com/pkg.exe",
destination,
sha256=hashlib.sha256(payload).hexdigest(),
expected_size=len(payload) + 1,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
assert not (tmp_path / "pkg.exe.part").exists()
def test_windows_installer_download_policy_requires_verified_https() -> None:
with pytest.raises(AppUpdateError, match="HTTPS"):
validate_installer_download_policy(
"http://cdn.example.com/setup.exe",
verify_ssl=True,
)
with pytest.raises(AppUpdateError, match="证书校验"):
validate_installer_download_policy(
"https://cdn.example.com/setup.exe",
verify_ssl=False,
)
validate_installer_download_policy(
"http://127.0.0.1/setup.exe",
verify_ssl=True,
)
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
installer = tmp_path / "Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
assert validate_windows_installer(installer) == installer.resolve()
invalid = tmp_path / "invalid.exe"
invalid.write_bytes(b"PK")
with pytest.raises(AppUpdateError, match="PE"):
validate_windows_installer(invalid)
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
installer = tmp_path / "DoctorWorkstation-Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
spawned: dict[str, Path] = {}
monkeypatch.setattr(app_update.sys, "platform", "win32")
def capture_spawn(
script: Path,
*,
installer: Path,
restart_exe: Path,
helper_log_file: Path,
installer_log_file: Path,
ready_file: Path,
) -> None:
spawned.update(
script=script,
@@ -349,44 +380,192 @@ def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
restart_exe=restart_exe,
helper_log_file=helper_log_file,
installer_log_file=installer_log_file,
ready_file=ready_file,
)
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
apply_inno_setup_update(installer, install_root=install_root)
script_text = spawned["script"].read_text(encoding="utf-8-sig")
assert spawned["installer"] == installer.resolve()
assert spawned["restart_exe"] == installed_exe
assert "/VERYSILENT" in script_text
assert "/RESTARTEXITCODE=3010" in script_text
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
apply_inno_setup_update(installer, install_root=install_root)
script_text = spawned["script"].read_text(encoding="utf-8-sig")
assert spawned["installer"] == installer.resolve()
assert spawned["restart_exe"] == installed_exe
assert "/VERYSILENT" in script_text
assert "/RESTARTEXITCODE=3010" in script_text
assert "/NOFORCECLOSEAPPLICATIONS" in script_text
assert "$HelperLogFile" 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
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(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = tmp_path / "payload"
payload.mkdir()
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
captured: dict[str, Path] = {}
script = tmp_path / "apply.ps1"
script.write_text("", encoding="utf-8")
def capture_script(**kwargs: Path) -> Path:
captured.update(kwargs)
return script
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
apply_extracted_update(payload, install_root=install_root)
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = tmp_path / "payload"
payload.mkdir()
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
captured: dict[str, Path] = {}
script = tmp_path / "apply.ps1"
script.write_text("", encoding="utf-8")
def capture_script(**kwargs: Path) -> Path:
captured.update(kwargs)
return script
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
apply_extracted_update(payload, install_root=install_root)
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 更新助手"):
apply_inno_setup_update(installer, install_root=install_root)
def test_package_filename_defaults_match_package_type() -> None:
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
installer = UpdatePackage(
"https://cdn.example.com/",
"a" * 64,
0,
"",
type=PACKAGE_TYPE_INNO_SETUP,
)
assert package_filename(archive, "0.2.0").endswith(".zip")
assert package_filename(installer, "0.2.0").endswith(".exe")
assert archive.type == PACKAGE_TYPE_ARCHIVE
def test_package_filename_defaults_match_package_type() -> None:
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
installer = UpdatePackage(
"https://cdn.example.com/",
"a" * 64,
0,
"",
type=PACKAGE_TYPE_INNO_SETUP,
)
assert package_filename(archive, "0.2.0").endswith(".zip")
assert package_filename(installer, "0.2.0").endswith(".exe")
assert archive.type == PACKAGE_TYPE_ARCHIVE
+250 -70
View File
@@ -1,70 +1,250 @@
"""Update dialog contract for optional and forced desktop upgrades."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.app_update import UpdateOffer, UpdatePackage
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog
from doctor_workstation.ui.theme import apply_theme
def _offer(*, force: bool, can_install: bool = True) -> UpdateOffer:
package = (
UpdatePackage(
url="https://cdn.example.com/DoctorWorkstation.zip",
sha256="a" * 64,
size=1024,
filename="DoctorWorkstation.zip",
)
if can_install
else None
)
return UpdateOffer(
has_update=True,
force=force,
enabled=True,
current_version="0.1.0",
latest_version="0.2.0",
min_version="",
title="医生工作站 0.2.0",
notes="修复若干问题",
platform="windows",
arch="x64",
package=package,
can_install=can_install,
)
def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=False))
dialog.show()
app.processEvents()
assert dialog.later_button.isVisible()
assert dialog.update_button.text() == "立即更新"
assert dialog.notes.toPlainText() == "修复若干问题"
dialog.close()
def test_forced_update_dialog_hides_defer_and_blocks_escape(
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 "必须更新" in dialog.badge.text()
dialog.close()
app.processEvents()
assert dialog.isVisible()
dialog.offer = _offer(force=False)
dialog._busy = False
dialog.close()
"""Update dialog contract for optional and forced desktop upgrades."""
from __future__ import annotations
import os
from dataclasses import replace
from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QObject, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_INNO_SETUP,
UpdateOffer,
UpdatePackage,
)
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog, AppUpdateSession
from doctor_workstation.ui.theme import apply_theme
def _offer(
*,
force: bool,
can_install: bool = True,
install_unavailable_reason: str = "",
) -> UpdateOffer:
package = (
UpdatePackage(
url="https://cdn.example.com/DoctorWorkstation.zip",
sha256="a" * 64,
size=1024,
filename="DoctorWorkstation.zip",
)
if can_install
else None
)
return UpdateOffer(
has_update=True,
force=force,
enabled=True,
current_version="0.1.0",
latest_version="0.2.0",
min_version="",
title="医生工作站 0.2.0",
notes="修复若干问题",
platform="windows",
arch="x64",
package=package,
can_install=can_install,
install_unavailable_reason=install_unavailable_reason,
)
def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=False))
dialog.show()
app.processEvents()
deferred: list[bool] = []
dialog.update_deferred.connect(lambda: deferred.append(True))
assert dialog.later_button.isVisible()
assert dialog.later_button.isEnabled()
assert dialog.later_button.text() == "稍后提醒"
assert not dialog.exit_button.isVisible()
assert dialog.update_button.text() == "立即更新"
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
+306
View File
@@ -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
+207
View File
@@ -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"]
+82 -2
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from doctor_workstation import config as config_module
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:
assert AppConfig().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).with_updates(verify_ssl="false").verify_ssl is False
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(
+199 -1
View File
@@ -9,7 +9,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QImage
from PySide6.QtGui import QImage, QTextCursor
from PySide6.QtWidgets import (
QApplication,
QDialog,
@@ -29,6 +29,7 @@ from doctor_workstation.ui.diagnosis_drawer import (
DiagnosisLineEdit,
DiagnosisNumberEdit,
DiagnosisSwitch,
ExpandableDiagnosisTextEdit,
SaveStateButton,
)
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()
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("mode", ["edit", "viewOnly"])
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()
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(
application: QApplication,
) -> None:
@@ -16,6 +16,7 @@ from doctor_workstation.ui.diagnosis_drawer import (
NotesTimeline,
_RemoteImageButton,
)
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
@pytest.fixture(scope="module")
@@ -112,7 +113,6 @@ def test_remote_image_request_is_thread_owned_and_rejects_stale_results(
object_name="DiagnosisTongueThumb",
parent=owner,
)
button._manager.deleteLater()
manager = _FakeManager(button)
button._manager = manager
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",
parent=owner,
)
button._manager.deleteLater()
manager = _FakeManager(button)
button._manager = manager
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",
parent=owner,
)
button._manager.deleteLater()
manager = _FakeManager(button)
button._manager = manager
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()
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(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
+144
View File
@@ -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
+27
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import tomllib
from pathlib import Path
from doctor_workstation import __version__
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")
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:
for name in (
"Run_DoctorWorkstation.bat",
+49 -4
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
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.errors import ApiTimeoutError
@@ -385,7 +385,10 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
task: str,
) -> dict[str, Any]:
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.open_for(501, "并发症筛查", task="complication_risk")
@@ -393,15 +396,57 @@ def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
assert calls == [
{"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 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.retry_button.isEnabled()
dialog.close()
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(
application: QApplication,
immediate_async: None,
+197
View File
@@ -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()
+93 -94
View File
@@ -288,122 +288,121 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
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,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[tuple[Any, dict[str, Any]]] = []
requested: list[int] = []
shown: list[tuple[int, str]] = []
class Repository:
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
requested.append(order_id)
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
assert diagnosis_id == 745
return []
def queue_async(function: Any, **options: Any) -> object:
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)
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
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(),
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 len(queued) == 1
assert requested == []
assert shown == []
assert not table.isEnabled()
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()
assert isinstance(dialog, DiagnosisDialog)
assert dialog.view_stack.currentWidget() is dialog.readonly_page
assert dialog.edit_fields["chief_complaint"].toPlainText() == "睡眠不好、出汗多"
assert dialog.summary_fields["phone"].text() == "138****9442"
assert dialog.case_grid.isVisibleTo(dialog)
assert not dialog.findChildren(dialog_module.QTextBrowser)
dialog.close()
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,
monkeypatch: pytest.MonkeyPatch,
owner_width: int,
) -> None:
queued: list[dict[str, Any]] = []
shown: list[tuple[int, str]] = []
class Repository:
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:
queued.append(options)
return object()
def get_doctor_notes(self, _diagnosis_id: int) -> list[dict[str, Any]]:
return []
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"]))
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
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,
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
owner = QDialog()
owner.resize(owner_width, 720)
owner.show()
repository = Repository()
editor = PrescriptionEditorDialog(
repository,
{"diagnosis_id": 745, "patient_name": "庄志芳"},
mode="edit",
current_user=SimpleNamespace(id=9, name="周医生"),
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
parent=owner,
)
table = dialog._order_detail_table
button = dialog._order_detail_button
assert table is not None
assert button is not None
editor.show()
application.processEvents()
assert QApplication.activeModalWidget() is editor
assert editor.width() == editor.DRAWER_WIDTH
table.setCurrentCell(0, 0)
dialog._open_selected_order()
table.setCurrentCell(1, 0)
dialog._open_selected_order()
assert len(queued) == 2
editor.diagnosis_button.click()
application.processEvents()
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
queued[0]["on_finished"]()
assert shown == []
assert not table.isEnabled()
assert not button.isEnabled()
detail = editor._diagnosis_view
assert detail is not None
assert QApplication.activeModalWidget() is editor
assert not detail.isWindow()
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"))
queued[1]["on_finished"]()
assert shown == [(22, "ROW-22")]
assert table.isEnabled()
assert button.isEnabled()
dialog.close()
editor.patient_name.setText("庄志芳(已核对)")
assert editor.patient_name.isEnabled()
assert editor.payload()["patient_name"] == "庄志芳(已核对)"
detail.readonly_close_button.click()
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()
+29
View File
@@ -1018,6 +1018,35 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
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(
application: QApplication,
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"
+97 -1
View File
@@ -1436,6 +1436,102 @@ def test_im_consult_is_visible_immediately_after_history(
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(
application: QApplication,
immediate_async: None,
@@ -2483,7 +2579,7 @@ def test_completion_revalidates_server_status_before_write(
assert completed == []
repository.status = 4
assert page._complete_after_revalidation(51) == {"ok": True}
assert page._complete_after_revalidation(51) == ""
assert completed == [51]
page.close()
application.processEvents()
+413
View File
@@ -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)
+128
View File
@@ -288,6 +288,82 @@ def test_shell_ai_entry_always_opens_patient_picker_even_with_current_selection(
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(
application: QApplication,
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)
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 == []
+114 -1
View File
@@ -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:
older = 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)
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",
request_timeout=30,
demo_mode=True,
debug_mode=True,
remembered_account="",
)
payloads: list[dict[str, Any]] = []
@@ -340,6 +347,7 @@ def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
api_base_url="",
request_timeout=30,
demo_mode=False,
debug_mode=True,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
@@ -369,6 +377,49 @@ def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
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:
application = QApplication.instance() or QApplication([])
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,
verify_ssl=True,
demo_mode=False,
debug_mode=True,
remembered_account="",
)
window = LoginWindow(object(), config=config, settings=settings)
@@ -402,6 +454,7 @@ def test_login_applies_self_signed_setting_before_authentication(
config = AppConfig(
api_base_url="https://internal.example.test/adminapi",
demo_mode=False,
debug_mode=True,
verify_ssl=True,
)
calls: list[str] = []
@@ -494,6 +547,7 @@ def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
request_timeout=30,
verify_ssl=True,
demo_mode=False,
debug_mode=True,
remembered_account="",
)
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()
window.close()
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()
+363 -2
View File
@@ -5,7 +5,8 @@ import sys
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from types import MethodType, SimpleNamespace
from typing import Any
import pytest
@@ -52,7 +53,7 @@ def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> No
assert "context.createMediaStreamDestination()" 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 "recorder.start(1000)" 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 "navigator.mediaDevices.getUserMedia" 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 "localRecordingBytes < 1024" 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
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:
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
encoding="utf-8"
Generated
+1 -2
View File
@@ -746,7 +746,6 @@ wheels = [
[[package]]
name = "zhenyang-doctor-workstation"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -778,7 +777,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.0.1,<2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9,<1" },
]
provides-extras = ["dev", "build"]
provides-extras = ["build", "dev"]
[[package]]
name = "zipp"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/png" href="./favicon.png" />
<title>视频面诊</title>
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
<script type="module" crossorigin src="./assets/index-B_ek5NUi.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BMSk91Wa.css">
</head>
<body>
<div id="app"></div>
+220 -57
View File
@@ -18,9 +18,39 @@ interface LiveCaption {
id: string
speaker: string
text: string
time: string
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<{
phase: Readonly<Ref<string>>
statusText: Readonly<Ref<string>>
@@ -34,12 +64,14 @@ const props = defineProps<{
transcriptionState: Readonly<Ref<string>>
localRecordingState: Readonly<Ref<string>>
liveCaptions: Readonly<Ref<LiveCaption[]>>
patientCase: Readonly<Ref<PatientCase>>
onSendText: (text: string) => Promise<void>
onSendAttachment: (file: File) => Promise<void>
onLoadMore: () => Promise<void>
onReconnectChat: () => Promise<void>
onStartVideo: () => Promise<void>
onHangup: () => Promise<void>
onOpenDiagnosis: () => Promise<void>
onSaveScreenshot: (dataUrl: string) => Promise<void>
}>()
@@ -50,6 +82,7 @@ const messageList = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const screenshotPreview = ref('')
const stickToMessageBottom = ref(true)
const transcriptList = ref<HTMLElement | null>(null)
const isChat = computed(() => props.mode.value === 'chat')
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
@@ -84,6 +117,41 @@ const transcriptionStatusText = computed(() => {
if (textState === 'error') 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(
() => 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 {
const container = messageList.value
if (!container) return
@@ -335,70 +414,154 @@ watch(
</footer>
</section>
<section v-if="videoVisible" class="video-layer" :class="{ 'video-layer--overlay': isChat }">
<TUICallKit
class="call-kit"
:allowed-minimized="false"
:allowed-full-screen="true"
/>
<section
v-if="videoVisible"
class="video-layer"
:class="{
'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">
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
<div>
<p class="eyebrow">中医视频问诊</p>
<h2>{{ statusText.value }}</h2>
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
<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" />
<div>
<p class="eyebrow">中医视频问诊</p>
<h2>{{ statusText.value }}</h2>
<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>
</section>
<div v-else class="live-status" role="status">
<span class="status-dot status-dot--live" aria-hidden="true" />
{{ statusText.value }}
</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>
<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
v-if="canCapture && liveCaptions.value.length"
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 v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</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 v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
{{ localError || notice.value }}
</div>
<aside v-if="isCalling" class="consultation-rail" aria-label="患者病例与实时对话">
<header class="consultation-rail__header">
<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
v-if="screenshotPreview"
+24
View File
@@ -15,9 +15,33 @@ interface DoctorCallConfig {
patientUserId?: string
diagnosisId: number | string
patientName?: string
patientCase?: DoctorPatientCase
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 {
start(config: DoctorCallConfig): Promise<void>
hangup(): Promise<void>
+196 -44
View File
@@ -26,6 +26,7 @@ interface NormalizedCallConfig {
targetUserId: string
diagnosisId: number | string
patientName: string
patientCase: UiPatientCase
mode: CompanionMode
}
@@ -44,9 +45,33 @@ interface UiLiveCaption {
id: string
speaker: string
text: string
time: string
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 {
source: 'doctor-call'
event:
@@ -56,6 +81,7 @@ interface BridgeMessage {
| 'room'
| 'hangup'
| 'error'
| 'open-diagnosis-request'
| 'transcription-start-request'
| 'transcription-segment'
| 'transcription-stop'
@@ -127,6 +153,11 @@ interface PendingLocalRecordingReply {
interface TrtcAudioTrackEvent {
userId?: string
track?: MediaStreamTrack
sourceTrack?: MediaStreamTrack
}
interface TrtcRemoteAudioEvent {
userId?: string
}
interface TrtcAudioCloud {
@@ -135,7 +166,9 @@ interface TrtcAudioCloud {
processed?: boolean
} | string): MediaStreamTrack | null
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: 'remote-audio-available', handler: (event: TrtcRemoteAudioEvent) => void): void
}
const phase = ref<CallPhase>('ready')
@@ -150,6 +183,7 @@ const hasMoreMessages = ref(false)
const transcriptionState = ref<TranscriptionState>('idle')
const localRecordingState = ref<LocalRecordingState>('idle')
const liveCaptions = ref<UiLiveCaption[]>([])
const patientCase = ref<UiPatientCase>(emptyPatientCase())
let activeConfig: NormalizedCallConfig | null = null
let chat: any = null
@@ -192,6 +226,7 @@ let localAudioContext: AudioContext | null = null
let localAudioDestination: MediaStreamAudioDestinationNode | null = null
let localAudioCloud: TrtcAudioCloud | null = null
let localAudioTrackHandler: ((event: TrtcAudioTrackEvent) => void) | null = null
let localRemoteAudioAvailableHandler: ((event: TrtcRemoteAudioEvent) => void) | null = null
let localAudioSources: MediaStreamAudioSourceNode[] = []
let localAudioTrackIds = new Set<string>()
let localAudioOwnedTracks: MediaStreamTrack[] = []
@@ -205,8 +240,6 @@ let localRecordingChunkChain: Promise<void> = Promise.resolve()
let localRecordingStartPromise: Promise<void> | null = null
let localRecordingStopPromise: Promise<void> | null = null
let localRecordingFatalError = ''
let liveCaptionClearTimer: number | null = null
function initializeQtWebChannel(): void {
const transport = window.qt?.webChannelTransport
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 {
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
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 {
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
@@ -274,15 +376,17 @@ function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
throw new Error('诊单ID不能为空')
}
const normalizedPatientName = typeof config.patientName === 'string' && config.patientName.trim()
? config.patientName.trim()
: '患者'
return {
SDKAppID,
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
userSig: cleanString(config.userSig, '用户签名'),
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
patientName: typeof config.patientName === 'string' && config.patientName.trim()
? config.patientName.trim()
: '患者',
patientName: normalizedPatientName,
patientCase: normalizePatientCase(config.patientCase, diagnosisId, normalizedPatientName),
mode: config.mode === 'chat' ? 'chat' : 'video',
}
}
@@ -747,30 +851,50 @@ function attachLocalRecordingTrack(
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 {
if (!activeConfig) return
if (typeof cloud?.getAudioTrack === 'function') {
try {
attachLocalRecordingTrack(cloud.getAudioTrack({ processed: true }), 'doctor')
} 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.
}
}
if (cloud) {
attachDoctorAudioTrack(cloud)
attachPatientAudioTrack(cloud, activeConfig.targetUserId)
}
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.
}
}
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
localAudioTrackHandler = null
localRemoteAudioAvailableHandler = null
for (const source of localAudioSources) {
try {
source.disconnect()
@@ -910,15 +1046,26 @@ async function performStartLocalRecording(): Promise<void> {
localAudioDestination = context.createMediaStreamDestination()
const cloud = getTrtcAudioCloud()
localAudioCloud = cloud
localAudioTrackHandler = (event) => attachLocalRecordingTrack(
event.track,
event.userId === activeConfig?.userID
localAudioTrackHandler = (event) => {
const sourceKind = !event.userId || event.userId === activeConfig?.userID
? 'doctor'
: event.userId === activeConfig?.targetUserId
? 'patient'
: 'unknown',
)
if (typeof cloud?.on === 'function') cloud.on('track', localAudioTrackHandler)
: 'unknown'
if (!attachLocalRecordingTrack(event.track, sourceKind)) {
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()
await waitForCallAudioTracks(cloud, sessionId)
startCallAudioDiscovery(cloud)
@@ -1151,10 +1298,6 @@ function getTranscriberManager(): RealtimeTranscriberManager {
}
function clearLiveCaptions(): void {
if (liveCaptionClearTimer !== null) {
window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = null
}
liveCaptions.value = []
}
@@ -1170,19 +1313,26 @@ function showLiveCaption(message: RealtimeTranscriberMessage): void {
: speakerUserId === activeConfig.targetUserId
? 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 = {
id,
speaker,
text: text.slice(0, 500),
time: new Date(captionTimestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
completed: message.isCompleted === true,
}
const previous = liveCaptions.value.filter((item) => item.id !== id)
liveCaptions.value = [...previous, caption].slice(-2)
if (liveCaptionClearTimer !== null) window.clearTimeout(liveCaptionClearTimer)
liveCaptionClearTimer = window.setTimeout(() => {
liveCaptions.value = []
liveCaptionClearTimer = null
}, message.isCompleted === true ? 9000 : 5000)
// Keep the current call's transcript visible in the side rail. Repeated
// partial updates replace the same segment and the bounded history prevents
// long calls from growing memory without limit.
liveCaptions.value = [...previous, caption].slice(-120)
}
function handleTranscriberMessage(
@@ -1421,7 +1571,6 @@ async function stopTranscription(
transcriptionSessionId = ''
acknowledgedSegmentIds.clear()
pendingSegments.clear()
clearLiveCaptions()
})().finally(() => {
transcriptionStopPromise = null
})
@@ -1720,6 +1869,7 @@ async function open(config: DoctorCallConfig): Promise<void> {
activeConfig = normalizeConfig(config)
mode.value = activeConfig.mode
patientName.value = activeConfig.patientName
patientCase.value = activeConfig.patientCase
messages.value = []
nextReqMessageID = ''
hasMoreMessages.value = false
@@ -1805,12 +1955,14 @@ createApp(App, {
transcriptionState: readonly(transcriptionState),
localRecordingState: readonly(localRecordingState),
liveCaptions: readonly(liveCaptions),
patientCase: readonly(patientCase),
onSendText: sendText,
onSendAttachment: sendAttachment,
onLoadMore: () => loadMessages(true),
onReconnectChat: reconnectChat,
onStartVideo: startVideo,
onHangup: hangup,
onOpenDiagnosis: openDiagnosis,
onSaveScreenshot: saveScreenshot,
}).mount('#app')
+299 -29
View File
@@ -272,6 +272,8 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.video-layer {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
width: 100%;
height: 100%;
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%),
#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-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,
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
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-captions {
position: absolute;
z-index: 38;
left: 50%;
bottom: 92px;
.consultation-rail {
position: relative;
z-index: 55;
display: grid;
gap: 7px;
width: min(820px, calc(100% - 360px));
transform: translateX(-50%);
pointer-events: none;
grid-template-rows: auto minmax(0, 1.08fr) minmax(0, .92fr);
min-width: 0;
min-height: 0;
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;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
width: fit-content;
max-width: 100%;
margin: 0 auto;
padding: 8px 14px;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 10px;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
min-height: 74px;
padding: 13px 16px;
border-bottom: 1px solid #e2e7f1;
background: #fff;
}
.consultation-rail__avatar {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 13px;
color: #fff;
background: rgba(9, 13, 20, .78);
box-shadow: 0 6px 24px rgba(0, 0, 0, .2);
font-size: 16px;
line-height: 1.55;
backdrop-filter: blur(8px);
background: #5761f4;
font-size: 17px;
font-weight: 700;
}
.live-captions strong {
color: #aeb8ff;
.consultation-rail__identity {
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;
}
.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 {
position: absolute;
@@ -494,8 +718,54 @@ button:disabled { cursor: not-allowed; opacity: .55; }
.consultation-shell { min-width: 620px; }
.message-list { padding-inline: 18px; }
.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
+15
View File
@@ -38,6 +38,21 @@ services:
#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:
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故障和跨节点存储。此开发没有发出任何真实企业微信写请求。
+22
View File
@@ -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;
use app\common\cache\AdminAuthCache;
use app\common\service\DirectUploadService;
use app\common\service\UploadService;
use Exception;
@@ -86,7 +87,12 @@ class UploadController extends BaseAdminController
{
$type = trim((string)$this->request->post('type', 'video'));
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);
} catch (Exception $e) {
return $this->fail($e->getMessage());
@@ -100,8 +106,10 @@ class UploadController extends BaseAdminController
public function ossConfirm()
{
try {
$type = trim((string)$this->request->post('type', 'video'));
$this->assertDirectUploadPermission($type);
$result = DirectUploadService::confirm([
'type' => trim((string)$this->request->post('type', 'video')),
'type' => $type,
'key' => trim((string)$this->request->post('key', '')),
'name' => trim((string)$this->request->post('name', '')),
'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