更新
This commit is contained in:
@@ -242,6 +242,26 @@ 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 function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
@@ -56,22 +56,45 @@
|
||||
<h2>获客助手分流方案</h2>
|
||||
<p>选择几名医助就生成几条成员规则;官方链接只在方案顶部复制,成员行不重复展示链接。</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
|
||||
<div class="section-heading-actions">
|
||||
<span v-if="selectedPoolIds.length" class="selection-count">已选 {{ selectedPoolIds.length }} 个方案</span>
|
||||
<el-button
|
||||
:icon="User"
|
||||
:disabled="!selectedPoolIds.length"
|
||||
@click="openAccessDialog()"
|
||||
>批量设置访问操作</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="overview.pools.length" class="pool-layout">
|
||||
<aside class="pool-sidebar">
|
||||
<button
|
||||
<div
|
||||
v-for="pool in overview.pools"
|
||||
:key="pool.id"
|
||||
class="pool-item"
|
||||
class="pool-select-row"
|
||||
:class="{ active: Number(pool.id) === selectedPoolId }"
|
||||
@click="selectedPoolId = Number(pool.id)"
|
||||
>
|
||||
<span class="pool-status" :class="Number(pool.status) === 1 ? 'online' : 'offline'" />
|
||||
<span><strong>{{ pool.name }}</strong><small>{{ memberSummary(pool) }} · {{ pool.main_url ? '官方链接已生成' : '待生成链接' }}</small></span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
<el-checkbox
|
||||
:model-value="selectedPoolIds.includes(Number(pool.id))"
|
||||
:disabled="!pool.can_manage_access"
|
||||
:aria-label="`选择方案 ${pool.name}`"
|
||||
@change="(checked) => togglePoolSelection(pool, checked)"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="pool-item"
|
||||
@click="selectedPoolId = Number(pool.id)"
|
||||
>
|
||||
<span class="pool-status" :class="Number(pool.status) === 1 ? 'online' : 'offline'" />
|
||||
<span>
|
||||
<strong>{{ pool.name }}</strong>
|
||||
<small>{{ memberSummary(pool) }} · {{ accessSummary(pool) }}</small>
|
||||
</span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="pool-main">
|
||||
@@ -90,8 +113,9 @@
|
||||
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
|
||||
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
|
||||
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
|
||||
<el-button :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0" @click="removePool(selectedPool)">删除</el-button>
|
||||
<el-button v-if="selectedPool.can_manage_access" :icon="User" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
|
||||
<el-button v-if="selectedPool.can_operate" :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
|
||||
<el-button v-if="selectedPool.can_delete" type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0" @click="removePool(selectedPool)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,12 +166,12 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="上线" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="Number(row.enabled) === 1" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
|
||||
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openMemberDialog(row)">编辑规则</el-button>
|
||||
<el-button v-if="selectedPool.can_operate" type="primary" link @click="openMemberDialog(row)">编辑规则</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
|
||||
@@ -393,6 +417,70 @@
|
||||
<template #footer><el-button :disabled="savingPool" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="accessDialogVisible"
|
||||
title="批量设置他人访问操作"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!savingAccess"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="`将设置 ${accessForm.pool_ids.length} 个分流方案`"
|
||||
description="被添加的账号可以查看并编辑方案、成员规则和浮窗配置;不能删除方案,也不能继续分配操作权限。"
|
||||
/>
|
||||
<el-form class="access-form" label-position="top">
|
||||
<el-form-item label="设置方式" required>
|
||||
<el-radio-group v-model="accessForm.action" :disabled="savingAccess">
|
||||
<el-radio-button value="grant">添加操作人</el-radio-button>
|
||||
<el-radio-button value="revoke">移除操作人</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="form-tip">批量添加不会覆盖各方案已有操作人;批量移除只撤销本次选中的账号。</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择账号" required>
|
||||
<el-select
|
||||
v-model="accessForm.operator_admin_ids"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:max-collapse-tags="3"
|
||||
:multiple-limit="100"
|
||||
:disabled="savingAccess"
|
||||
placeholder="搜索姓名或部门"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="operator in overview.operator_options"
|
||||
:key="operator.id"
|
||||
:label="operatorOptionLabel(operator)"
|
||||
:value="Number(operator.id)"
|
||||
:disabled="accessForm.action === 'grant' && !operator.can_grant"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="form-tip">添加时仅可选择已启用且拥有本页面权限的账号;失效账号仍可用于移除历史授权。</span>
|
||||
</el-form-item>
|
||||
<div v-if="accessDialogPools.length" class="access-pool-preview">
|
||||
<strong>当前授权</strong>
|
||||
<div v-for="pool in accessDialogPools.slice(0, 5)" :key="pool.id">
|
||||
<span>{{ pool.name }}</span>
|
||||
<small>{{ operatorNames(pool) }}</small>
|
||||
</div>
|
||||
<p v-if="accessDialogPools.length > 5">另有 {{ accessDialogPools.length - 5 }} 个方案</p>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="savingAccess" @click="accessDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingAccess" @click="savePoolOperators">
|
||||
{{ accessForm.action === 'grant' ? '批量添加' : '批量移除' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="memberDialogVisible" title="编辑医助分流规则" width="560px" destroy-on-close :close-on-click-modal="false">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="获客医助">
|
||||
@@ -424,6 +512,7 @@ import {
|
||||
Promotion, Refresh, Search, SetUp, User, Warning
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
wecomPromotionBatchSetOperators,
|
||||
wecomPromotionCheckApiPermission,
|
||||
wecomPromotionCustomerStats,
|
||||
wecomPromotionDeletePool,
|
||||
@@ -454,6 +543,16 @@ interface PromotionMemberOption {
|
||||
dept_names: string[]
|
||||
}
|
||||
|
||||
interface PromotionOperatorOption {
|
||||
id: number
|
||||
name: string
|
||||
disable: number
|
||||
can_grant: boolean
|
||||
display_dept_id?: number
|
||||
dept_ids: number[]
|
||||
dept_names: string[]
|
||||
}
|
||||
|
||||
interface PromotionMemberTreeNode {
|
||||
value: number | string
|
||||
label: string
|
||||
@@ -466,12 +565,13 @@ interface PromotionMemberTreeNode {
|
||||
}
|
||||
|
||||
const emptyOverview = () => ({
|
||||
meta: { scope_label: '', generated_at: '' },
|
||||
meta: { admin_id: 0, scope_label: '', generated_at: '' },
|
||||
config: { mode: 'internal', configured: false, ready: false, missing: [] as string[], corp_id_masked: '', agent_id: '', secret_configured: false, callback_ready: false, callback_url: '', official_doc: '' },
|
||||
summary: { configured_apps: 0, pool_count: 0, online_links: 0, today_clicks: 0 },
|
||||
pools: [] as any[],
|
||||
links: [] as any[],
|
||||
member_options: [] as PromotionMemberOption[],
|
||||
operator_options: [] as PromotionOperatorOption[],
|
||||
department_options: [] as PromotionDepartmentOption[],
|
||||
customer_acquisition_link_example: 'https://work.weixin.qq.com/ca/xxxxxxxx'
|
||||
})
|
||||
@@ -481,6 +581,7 @@ const loading = ref(false)
|
||||
const activeTab = ref<TabName>('links')
|
||||
const selectedPoolId = ref<number>()
|
||||
const selectedInstallPoolId = ref<number>()
|
||||
const selectedPoolIds = ref<number[]>([])
|
||||
const poolDialogVisible = ref(false)
|
||||
const savingPool = ref(false)
|
||||
const checkingApi = ref(false)
|
||||
@@ -488,7 +589,10 @@ const memberDialogVisible = ref(false)
|
||||
const savingMember = ref(false)
|
||||
const togglingMemberId = ref(0)
|
||||
const deletingPoolId = ref(0)
|
||||
const accessDialogVisible = ref(false)
|
||||
const savingAccess = ref(false)
|
||||
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '' })
|
||||
const accessForm = reactive({ pool_ids: [] as number[], operator_admin_ids: [] as number[], action: 'grant' as 'grant' | 'revoke' })
|
||||
const memberForm = reactive({ id: 0, name: '', userid: '', daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
|
||||
const customerStatsLoading = ref(false)
|
||||
const customerStatsLoaded = ref(false)
|
||||
@@ -510,6 +614,7 @@ const customerStats = reactive({
|
||||
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
||||
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
||||
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
||||
const accessDialogPools = computed(() => overview.pools.filter((item: any) => accessForm.pool_ids.includes(Number(item.id))))
|
||||
const memberTreeProps = { value: 'value', label: 'label', children: 'children', disabled: 'disabled' }
|
||||
const memberDepartmentTree = computed(() => buildMemberDepartmentTree(overview.department_options, overview.member_options))
|
||||
const memberTreeDefaultExpandedKeys = computed(() => memberDepartmentTree.value.map((node) => node.value))
|
||||
@@ -527,16 +632,28 @@ const customerMemberOptions = computed(() => customerStats.member_options.length
|
||||
department_name: Array.isArray(item.dept_names) ? item.dept_names.join(' / ') : (item.department_name || item.dept_name || '')
|
||||
})))
|
||||
|
||||
watch(() => overview.pools.map((item: any) => Number(item.id)).join(','), () => {
|
||||
watch(() => overview.pools.map((item: any) => `${Number(item.id)}:${item.can_manage_access ? 1 : 0}`).join(','), () => {
|
||||
const ids = overview.pools.map((item: any) => Number(item.id))
|
||||
const manageableIds = new Set(overview.pools
|
||||
.filter((item: any) => item.can_manage_access)
|
||||
.map((item: any) => Number(item.id)))
|
||||
if (!selectedPoolId.value || !ids.includes(selectedPoolId.value)) selectedPoolId.value = ids[0]
|
||||
if (!selectedInstallPoolId.value || !ids.includes(selectedInstallPoolId.value)) selectedInstallPoolId.value = ids[0]
|
||||
selectedPoolIds.value = selectedPoolIds.value.filter((id) => manageableIds.has(id))
|
||||
}, { immediate: true })
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'customer-stats' && !customerStatsLoaded.value) loadCustomerStats()
|
||||
})
|
||||
|
||||
watch(() => accessForm.action, (action) => {
|
||||
if (action !== 'grant') return
|
||||
const grantableIds = new Set(overview.operator_options
|
||||
.filter((operator) => operator.can_grant)
|
||||
.map((operator) => Number(operator.id)))
|
||||
accessForm.operator_admin_ids = accessForm.operator_admin_ids.filter((id) => grantableIds.has(Number(id)))
|
||||
})
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -549,6 +666,76 @@ async function loadOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
function togglePoolSelection(pool: any, checked: unknown) {
|
||||
if (!pool?.can_manage_access) return
|
||||
const id = Number(pool.id)
|
||||
const next = new Set(selectedPoolIds.value)
|
||||
checked ? next.add(id) : next.delete(id)
|
||||
selectedPoolIds.value = [...next]
|
||||
}
|
||||
|
||||
function openAccessDialog(poolIds: number[] = selectedPoolIds.value) {
|
||||
const manageableIds = new Set(overview.pools
|
||||
.filter((pool: any) => pool.can_manage_access)
|
||||
.map((pool: any) => Number(pool.id)))
|
||||
const ids = [...new Set(poolIds.map(Number).filter((id) => manageableIds.has(id)))]
|
||||
if (!ids.length) return ElMessage.warning('请先选择可管理的分流方案')
|
||||
Object.assign(accessForm, {
|
||||
pool_ids: ids,
|
||||
operator_admin_ids: [],
|
||||
action: 'grant'
|
||||
})
|
||||
accessDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function savePoolOperators() {
|
||||
if (!accessForm.pool_ids.length) return ElMessage.warning('请至少选择一个分流方案')
|
||||
if (!accessForm.operator_admin_ids.length) return ElMessage.warning('请至少选择一名操作人')
|
||||
savingAccess.value = true
|
||||
try {
|
||||
const result = await wecomPromotionBatchSetOperators({
|
||||
pool_ids: [...accessForm.pool_ids],
|
||||
operator_admin_ids: [...accessForm.operator_admin_ids],
|
||||
action: accessForm.action
|
||||
})
|
||||
accessDialogVisible.value = false
|
||||
selectedPoolIds.value = []
|
||||
await loadOverview()
|
||||
const actionLabel = accessForm.action === 'grant' ? '添加' : '移除'
|
||||
ElMessage.success(result?.affected
|
||||
? `已批量${actionLabel} ${result.affected} 条方案操作权限`
|
||||
: '权限设置已检查,没有需要变更的记录')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '批量设置访问操作失败')
|
||||
} finally {
|
||||
savingAccess.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function operatorOptionLabel(operator: PromotionOperatorOption) {
|
||||
const departments = Array.isArray(operator.dept_names) && operator.dept_names.length
|
||||
? operator.dept_names.join(' / ')
|
||||
: '未分部门'
|
||||
const unavailable = Number(operator.disable) === 1
|
||||
? ' · 已禁用'
|
||||
: (!operator.can_grant ? ' · 无页面权限' : '')
|
||||
return `${operator.name} · ${departments}${unavailable}`
|
||||
}
|
||||
|
||||
function operatorNames(pool: any) {
|
||||
const operators = Array.isArray(pool?.operators) ? pool.operators : []
|
||||
if (!operators.length) return '仅原管理范围可操作'
|
||||
return operators
|
||||
.map((operator: any) => `${operator.name || `账号 ${operator.id}`}${Number(operator.disable) === 1 ? '(已禁用)' : ''}`)
|
||||
.join('、')
|
||||
}
|
||||
|
||||
function accessSummary(pool: any) {
|
||||
const linkState = pool?.main_url ? '官方链接已生成' : '待生成链接'
|
||||
const operatorCount = Array.isArray(pool?.operator_admin_ids) ? pool.operator_admin_ids.length : 0
|
||||
return operatorCount > 0 ? `${linkState} · ${operatorCount} 人可操作` : linkState
|
||||
}
|
||||
|
||||
function openPoolDialog(pool?: any) {
|
||||
const selectableMemberIds = new Set(overview.member_options.map((member) => Number(member.id)))
|
||||
Object.assign(poolForm, pool ? {
|
||||
@@ -1076,10 +1263,15 @@ h1, h2, h3, p { margin: 0; }
|
||||
.tab-content { min-height: 460px; padding: 20px; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
||||
.section-heading h2 { font-size: 17px; }.section-heading p { margin-top: 5px; color: var(--muted); font-size: 12px; }
|
||||
.section-heading-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.section-heading-actions .el-button + .el-button { margin-left: 0; }
|
||||
.selection-count { color: #117f75; font-size: 11px; font-weight: 600; }
|
||||
.pool-layout { display: grid; grid-template-columns: 252px minmax(0, 1fr); min-height: 430px; border: 1px solid var(--line); border-radius: 11px; overflow: hidden; }
|
||||
.pool-sidebar { padding: 8px; border-right: 1px solid var(--line); background: #f7f9fa; }
|
||||
.pool-item { display: grid; grid-template-columns: 9px 1fr 16px; align-items: center; gap: 9px; width: 100%; min-height: 62px; margin-bottom: 5px; padding: 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; cursor: pointer; }
|
||||
.pool-item:hover { background: #fff; }.pool-item.active { border-color: #bfe1dc; background: #fff; box-shadow: 0 5px 16px rgba(25,69,70,.05); }
|
||||
.pool-select-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 4px; margin-bottom: 5px; }
|
||||
.pool-select-row :deep(.el-checkbox) { justify-content: center; margin-right: 0; }
|
||||
.pool-item { display: grid; grid-template-columns: 9px minmax(0, 1fr) 16px; align-items: center; gap: 9px; width: 100%; min-height: 62px; padding: 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; cursor: pointer; }
|
||||
.pool-item:hover { background: #fff; }.pool-select-row.active .pool-item { border-color: #bfe1dc; background: #fff; box-shadow: 0 5px 16px rgba(25,69,70,.05); }
|
||||
.pool-item strong, .pool-item small { display: block; }.pool-item strong { overflow: hidden; color: #253348; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.pool-item small { margin-top: 4px; color: #8c98a7; font-size: 10px; }
|
||||
.pool-status { width: 8px; height: 8px; border-radius: 50%; }.pool-status.online { background: #18a277; }.pool-status.offline { background: #aab3bf; }.pool-item > .el-icon { color: #9ca7b4; }
|
||||
.pool-main { min-width: 0; }.pool-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 78px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
|
||||
@@ -1113,6 +1305,14 @@ h1, h2, h3, p { margin: 0; }
|
||||
.member-tree-option__count { flex: 0 0 auto; color: #98a2b3; font-size: 11px; }
|
||||
.member-tree-option__detail { overflow: hidden; color: #8b97a6; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.form-grid, .rule-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 14px; }.form-tip { display: block; margin-top: 5px; color: #8b97a6; font-size: 10px; }.link-form .el-select, .link-form .el-input-number { width: 100%; }
|
||||
.access-form { margin-top: 16px; }
|
||||
.access-pool-preview { padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: #f8fafb; }
|
||||
.access-pool-preview > strong { display: block; margin-bottom: 7px; font-size: 12px; }
|
||||
.access-pool-preview > div { display: grid; grid-template-columns: minmax(120px, .8fr) minmax(0, 1.6fr); gap: 12px; padding: 6px 0; border-top: 1px solid #edf0f2; font-size: 11px; }
|
||||
.access-pool-preview span { overflow: hidden; color: #344054; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.access-pool-preview small, .access-pool-preview p { color: #8491a2; font-size: 10px; }
|
||||
.access-pool-preview small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.access-pool-preview p { margin-top: 7px; }
|
||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; } }
|
||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.section-heading-actions { width: 100%; justify-content: flex-start; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-select-row { min-width: 220px; }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }.access-pool-preview > div { grid-template-columns: 1fr; gap: 3px; } }
|
||||
</style>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,158 @@
|
||||
# Windows x64 客户端更新 `can_install=False` 诊断
|
||||
|
||||
## 结论
|
||||
|
||||
“已识别最新版本,但按钮显示「暂不可安装」并提示后台尚未配置安装包”并不等价于只有一种后台配置错误。当前链路把多种拒绝原因压缩为同一个 `UpdateOffer.can_install=False`,而对话框的兜底文案统一归因为“后台未配置”。
|
||||
|
||||
对标准 Windows x64 客户端,最值得按以下顺序检查:
|
||||
|
||||
1. **服务端没有在 `packages.windows_x64` 取到同时非空的 `url` 和 `sha256`。** 最新版本是全局字段,安装包是按平台另行选择,因此完全可能 `has_update=True` 但 `can_install=False`。
|
||||
2. **Windows Inno Setup 包使用了 HTTP(非 localhost)地址,或本机关闭了 HTTPS 证书校验。** 前者会被解析器拒绝;后者会在 UI session 中把一个原本可安装的 offer 二次降级为不可安装。
|
||||
3. **服务端返回了非 64 位十六进制 SHA-256。** 当前 PHP evaluate 只检查 SHA 是否非空,Python 客户端则做严格格式校验,两端判定可能不一致。
|
||||
4. **`package` 结构或 `package.type` 不符合客户端契约。** 客户端只接受对象形式的 `package`,类型只接受 `archive` / `inno_setup`;不过当前第一方 PHP 后端会把未知类型归一为 `archive`,当前管理端也只提供这两个选项,所以这通常只发生在旧服务、手工响应或绕过当前保存链路的配置中。
|
||||
|
||||
如果更新对话框确实已经出现,则单纯的版本、`enabled`、响应平台/架构不匹配通常可以排除:`AppUpdateSession._on_offer()` 在 `offer.has_update=False` 时直接返回,不会展示更新对话框(`app/src/doctor_workstation/ui/dialogs/app_update.py:360-368`)。
|
||||
|
||||
## 端到端链路与证据
|
||||
|
||||
### 1. 客户端发送的身份
|
||||
|
||||
- Windows 被映射为 `windows`(`app/src/doctor_workstation/services/app_update.py:78-83`)。
|
||||
- `AMD64`、`x86_64`、`x64` 都被映射为 `x64`(`app/src/doctor_workstation/services/app_update.py:86-92`)。
|
||||
- 检查请求固定发往 `setting.desktop_workstation/check`,携带 `current_version`、`platform`、`arch`(`app/src/doctor_workstation/services/app_update.py:218-243`)。
|
||||
- `ApiClient` 会解开 `{code, data}` 信封,`code == 1` 时把 `data` 直接交给更新解析器(`app/src/doctor_workstation/services/api_client.py:515-542`)。
|
||||
|
||||
因此标准 64 位 Windows 的请求应为:
|
||||
|
||||
```text
|
||||
GET /adminapi/setting.desktop_workstation/check
|
||||
?current_version=<当前版本>&platform=windows&arch=x64
|
||||
```
|
||||
|
||||
### 2. 服务端先决定是否有对应平台安装包
|
||||
|
||||
- 服务端只声明三个包槽位:`windows_x64`、`macos_arm64`、`macos_x64`(`server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:28-32`)。
|
||||
- `windows`/`win32`/`win64` 会归一为 `windows`,`x64`/`amd64`/`x86_64` 会归一为 `x64`,然后拼成 `windows_x64`(同文件 `:125-153`)。
|
||||
- evaluate 从 `config.packages[windows_x64]` 取包;服务端 `canInstall` 只要求 `url !== '' && sha256 !== ''`(同文件 `:75-86`)。
|
||||
- `hasUpdate` 独立由启用状态和版本比较决定(`:86-88`),响应中只有 `canInstall` 为真才返回 `package`,最终 `can_install = hasUpdate && canInstall`(`:90-103`)。
|
||||
|
||||
这直接解释了核心现象:`latest_version` 配置正确会让客户端看到新版本,但 `packages.windows_x64.url` 或 `packages.windows_x64.sha256` 任一为空,响应仍会是 `has_update: true`、`package: null`、`can_install: false`。
|
||||
|
||||
管理端保存的真实字段是嵌套结构 `packages.windows_x64.{url,sha256,size,filename,type}`(`admin/src/api/setting/desktop_workstation.ts:5-24`、`admin/src/views/setting/desktop_workstation/index.vue:330-344`),而不是把 Windows 包放在 macOS 槽位或任意自定义键下。管理页默认 Windows 类型为 `inno_setup`(Vue 文件 `:198-218`),上传 `.exe` 也会设置为 `inno_setup` 并在浏览器计算 SHA-256(`:292-315`)。
|
||||
|
||||
当前服务端校验允许整行安装包为空:空值/空行会继续通过(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:83-110`),所以“自动检测已启用、最新版本有效、Windows 包未完整配置”是被允许保存的状态。外部 URL 缺 SHA 会被拒绝,但站内相对 URL 对应文件不存在且 SHA 为空的情形仍可能保存;服务端只会在本地文件确实存在时自动补 SHA、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
|
||||
|
||||
### 3. Python 客户端会再做一轮更严格的判定
|
||||
|
||||
`parse_update_offer()` 的规则位于 `app/src/doctor_workstation/services/app_update.py:140-215`:
|
||||
|
||||
- `package` 必须是字典;`url` 必须非空(`:152-167`)。
|
||||
- `type` 缺省为 `archive`,只接受 `archive` / `inno_setup`;`inno_setup` 只允许 Windows(`:158-167`)。
|
||||
- 响应平台、架构必须与请求时的期望值完全一致;同时必须满足服务端 `has_update`、`enabled`、合法且更高的版本(`:175-184`)。
|
||||
- SHA-256 必须恰好 64 个十六进制字符(`:185-189`)。
|
||||
- `inno_setup` URL 必须是 HTTPS,唯一例外是 HTTP localhost/loopback(`:190-194`,具体 URL 规则在 `:371-376`)。
|
||||
- 最终 `can_install` 是服务端 `can_install`、有效 package、有效 SHA、安全安装器传输、`has_update` 五者的合取(`:195-201`)。判失败后返回对象会清除 `package`,并把 `force` 一并降为 false(`:202-215`)。
|
||||
|
||||
因此若原始 API 返回 `can_install: true`,客户端仍可能因以下字段得到 false:
|
||||
|
||||
| 字段/状态 | 拒绝条件 | Windows x64 症状是否吻合 |
|
||||
|---|---|---|
|
||||
| `package` | `null`、数组、字符串等非对象 | 是 |
|
||||
| `package.url` | 空字符串 | 是 |
|
||||
| `package.sha256` | 空、长度不是 64、包含非十六进制字符 | 是 |
|
||||
| `package.type` | 非 `archive` / `inno_setup` | 是,但当前第一方后端通常会归一为 `archive` |
|
||||
| `package.type=inno_setup` + URL | 非 localhost 的 `http://` 或相对 URL | 是 |
|
||||
| `package.filename` | 空或扩展名不匹配 | **不会在 offer 阶段令 `can_install=False`**;可能在下载/应用阶段失败 |
|
||||
| `package.size` | 空、0、不可转整数 | **不会在 offer 阶段令 `can_install=False`**;解析为 0 |
|
||||
| 缺少 `package.type` | 默认 `archive` | **不会单独导致 false**;EXE 被误当 archive 会在稍后解压失败 |
|
||||
|
||||
一个重要的不一致是:PHP evaluate 目前只检查 SHA 非空(`DesktopWorkstationLogic.php:85`),Python 检查完整格式(`app_update.py:185-189`)。管理端正常保存会校验 64 位十六进制(`DesktopWorkstationValidate.php:147-151`),但旧数据、直接写配置或绕过校验的导入仍可能造成“后端说可安装、客户端说不可安装”。
|
||||
|
||||
### 4. UI session 还会因本机 TLS 设置二次降级
|
||||
|
||||
即使 `fetch_update_offer()` 返回的 Inno Setup offer 已经 `can_install=True`,`AppUpdateSession._on_offer()` 仍会以本机 `config.verify_ssl` 调用安装器下载策略;失败时把 `force=False`、`package=None`、`can_install=False`(`app/src/doctor_workstation/ui/dialogs/app_update.py:371-392`)。
|
||||
|
||||
本机配置默认 `verify_ssl=True`(`app/src/doctor_workstation/config.py:88-97`、`:124-130`),但登录页勾选“信任自签名证书(仅内网调试)”会把它反转为 false(`app/src/doctor_workstation/ui/login.py:831-844`、`:937-944`、`:1041-1053`)。`validate_installer_download_policy()` 明确拒绝 `verify_ssl=False`,也拒绝非安全的 Inno Setup URL(`app/src/doctor_workstation/services/app_update.py:379-385`)。
|
||||
|
||||
这是最容易被误判为“后台没包”的非后台原因。诊断时应比较两个时点:
|
||||
|
||||
1. `fetch_update_offer()` 刚返回时是否 `can_install=True`;
|
||||
2. `_on_offer()` 传给 `_present()` 时是否已经变成 false。
|
||||
|
||||
若只有第 2 个时点为 false,按当前代码唯一的正常降级入口就是 Inno Setup 下载策略,优先检查 `verify_ssl`。
|
||||
|
||||
审阅时工作树中已存在一项并非本文创建的未提交改善:`UpdateOffer` 增加 `install_unavailable_reason`,TLS 策略降级时生成具体原因,对话框优先展示该原因(`app_update.py` service `:53-67`;UI `:200-206`、`:379-391`)。兜底文案仍用于服务端/解析阶段没有原因的 `can_install=False`,所以根因判别和补测仍有必要。
|
||||
|
||||
### 5. 为什么平台或版本通常不是这个弹窗的根因
|
||||
|
||||
- 客户端要求响应 `platform`/`arch` 与请求期望值精确相等,错配会让 `has_update=False`(`app_update.py:175-184`)。
|
||||
- session 对 `has_update=False` 直接显示“当前已是最新版本”或静默返回,不创建更新对话框(UI `:360-368`)。
|
||||
- 当前第一方后端会把 Windows/x64 常见别名归一为响应中的 `windows`/`x64`(`DesktopWorkstationLogic.php:125-153`)。
|
||||
|
||||
所以对于已经出现该对话框的标准 Windows x64 客户端,优先查 `packages.windows_x64`,而不是先怀疑 `AMD64` 与 `x64` 名称差异。例外是非标准/旧后端没有按当前契约归一,或实际机器是 Windows ARM64;服务端没有 `windows_arm64` 包槽位,后者会天然没有对应包。
|
||||
|
||||
同理,`enabled=false`、最新版本无效、当前版本不低于最新版本都会使 `has_update=False`,与“更新弹窗出现但不可安装”不吻合。源码运行也不是该兜底文案的成因:源码模式只会取消强制属性,点击安装后才显示“当前为源码运行”(UI `:393-394`、`:401-410`)。
|
||||
|
||||
## 最短现场排查路径
|
||||
|
||||
1. 用发生问题的当前版本请求实际 API,并保留解包后的 `data`:
|
||||
|
||||
```text
|
||||
/adminapi/setting.desktop_workstation/check?current_version=<version>&platform=windows&arch=x64
|
||||
```
|
||||
|
||||
2. 若响应已经是 `can_install:false` 且 `package:null`,读取管理端配置并核对 `packages.windows_x64.url` 与 `.sha256` 是否同时非空;确认包没有误填到 `macos_x64`,也没有只保存最新版本而未保存包。
|
||||
3. 若响应是 `can_install:true`,核对 `package` 是否为对象、SHA 是否 64 位十六进制、`type` 是否精确为 `archive` 或 `inno_setup`。若为 Inno Setup,URL 应为 HTTPS。
|
||||
4. 若解析后 offer 为 true、弹窗前变成 false,检查客户端 `preferences.json` 中的 `verify_ssl`,以及登录页“信任自签名证书”是否被勾选。
|
||||
5. 对 Windows 安装程序,期望响应至少应类似:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_update": true,
|
||||
"enabled": true,
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"can_install": true,
|
||||
"package": {
|
||||
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
|
||||
"sha256": "<64 lowercase hex chars>",
|
||||
"size": 123456789,
|
||||
"filename": "DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
|
||||
"type": "inno_setup"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 现有测试覆盖与缺口
|
||||
|
||||
已有客户端测试覆盖:
|
||||
|
||||
- 缺 SHA 会拒绝安装(`app/tests/test_app_update.py:43-63`)。
|
||||
- 合法 HTTPS Inno Setup 会接受(`:66-89`)。
|
||||
- HTTP Inno Setup 会拒绝(`:92-115`)。
|
||||
- 未知类型会拒绝(`:118-139`)。
|
||||
- 旧版本/错误平台响应不会成为 update(`:142-163`)。
|
||||
- 检查接口会发送 `platform=windows`、`arch=x64`(`:166-208`)。
|
||||
- UI 的可选/强制升级基本行为,以及“给定 policy reason 时展示该 reason”(`app/tests/test_app_update_ui.py:49-96`)。
|
||||
|
||||
已有 PHP 契约测试覆盖 `win32 + amd64 -> windows_x64`,以及完整 Windows Inno 包可安装(`server/tests/DesktopWorkstationUpdateContractTest.php:17-62`);缺包测试只覆盖 macOS 槽位(`:74-77`)。
|
||||
|
||||
建议新增以下测试:
|
||||
|
||||
1. **Windows x64 服务端缺字段矩阵(最高优先级)**:分别让 `packages.windows_x64.url` 为空、`sha256` 为空、整个键缺失;断言 `has_update=true`、`package=null`、`can_install=false`。这会直接固化本次症状。
|
||||
2. **服务端/客户端 SHA 契约一致性**:给 evaluate 一个“非空但不是 64 位十六进制”的 SHA。期望服务端也返回不可安装,或至少用共享 fixture 明确当前由客户端拒绝;避免两端一个 true、一个 false。
|
||||
3. **`AppUpdateSession` TLS 二次降级**:构造合法 HTTPS `inno_setup` offer,分别设置 `verify_ssl=True/False`,截获 `_present()`;true 时保持可安装,false 时断言 `can_install=False` 且原因明确指向证书策略而非后台缺包。
|
||||
4. **UI 兜底分支**:构造 `can_install=False` 且无 reason 的 offer,断言按钮禁用并展示后台/平台包缺失文案;与已有“注入 policy reason”的测试形成两条独立路径。
|
||||
5. **解析字段矩阵**:补充 `package=null`、非对象、空 URL、63 位 SHA、含非 hex SHA、缺少 type 默认 archive、Windows archive 使用 HTTP 仍可解析等边界测试。现有测试覆盖了部分,但没有把每个判定条件与原因一一锁定。
|
||||
6. **跨层契约 fixture**:把 PHP `check` 的 Windows x64 JSON 响应作为 Python `parse_update_offer()` 输入,验证 canonical `windows/x64`、包类型、SHA 和 `can_install` 不发生语义漂移。
|
||||
7. **管理端 payload 测试**:确认保存时始终发送 `packages.windows_x64` 嵌套对象,上传 `.exe` 后 `type=inno_setup` 且 URL、SHA、文件名、大小均落在同一槽位。
|
||||
|
||||
长期看,最稳妥的可观测性是让 `can_install=False` 同时带结构化原因(例如 `missing_package`、`invalid_digest`、`unsupported_type`、`insecure_installer_url`、`tls_verification_disabled`),并在客户端保留原因而不是立即清除所有包信息。这样 UI 不必用一个“后台未配置”文案覆盖所有安全门禁。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 根目录 `.trellis/` 不存在;本次按根 `AGENTS.md` 执行,只读检查生产代码,仅新增本文档。
|
||||
- `app/.venv/Scripts/python.exe -m pytest tests/test_app_update.py tests/test_app_update_ui.py -q`:通过。
|
||||
- 收集结果:`test_app_update.py` 21 项、`test_app_update_ui.py` 3 项,共 24 项。
|
||||
- `php tests/DesktopWorkstationUpdateContractTest.php`(工作目录 `server/`):`Desktop workstation update contract: OK`。
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# DEBUG_MODE 与线上 API 固定策略分析
|
||||
|
||||
## 结论
|
||||
|
||||
当前启动配置不是单一的“环境变量 -> `AppConfig`”链路,而是四层覆盖:
|
||||
|
||||
1. `python-dotenv` 先加载 `.env`,且 `override=False`,所以进程环境变量优先于 `.env`(`app/src/doctor_workstation/config.py:113-118`)。
|
||||
2. `DOCTOR_API_BASE_URL` 被规范化并传入 `AppConfig`(`config.py:118-132`)。
|
||||
3. 用户目录中的 `preferences.json` 再覆盖环境配置,因此当前实际上是 `进程环境/.env < preferences.json`(`config.py:133-153`)。
|
||||
4. 登录页另有一套 Qt `QSettings`:`server/base_url` 会覆盖已经合并好的 `config.api_base_url`,保存或正式登录时再通过 `config_changed` 写回 `AppConfig`,控制器随后保存 `preferences.json` 并重建远端仓库(`app/src/doctor_workstation/ui/login.py:917-927, 1026-1054, 1067-1077, 1079-1103`;`app/src/doctor_workstation/app.py:438-504`)。
|
||||
|
||||
所以,只在 `AppConfig.load()` 里把环境变量替换成线上域名是不完整的。`DEBUG_MODE=False` 时必须同时封住:
|
||||
|
||||
- 环境变量 / `.env`;
|
||||
- `preferences.json`;
|
||||
- Qt `QSettings` 的 `server/base_url`;
|
||||
- 登录页运行期 `with_updates(api_base_url=...)`。
|
||||
|
||||
建议目标契约为:
|
||||
|
||||
| 模式 | 最终 `AppConfig.api_base_url` | 本地地址设置 |
|
||||
| --- | --- | --- |
|
||||
| `DEBUG_MODE=False` | 始终为 `https://admin.zhenyangtang.com.cn/adminapi` | 环境、JSON preference、Qt `QSettings`、登录页编辑均不得改变 |
|
||||
| `DEBUG_MODE=True` | 保留当前规则:环境/.env 初始化,`preferences.json` 覆盖,登录页可再次编辑 | 完全保留现有可配置行为 |
|
||||
|
||||
仓库内线上地址最直接的证据是 `admin/.env.production:1-3`,生产管理端使用 `https://admin.zhenyangtang.com.cn/`;`admin/vite.config.ts:54-64` 的开发代理也指向同一主机。桌面端的 `normalize_api_base_url()` 会追加 `/adminapi`(`config.py:67-85`),因此建议常量保存主机根地址,最终有效地址由同一个规范化函数产生。`TongjiUniApp/main.js:3` 当前使用的是 `https://xt.zhenyangtang.com.cn/`,它属于另一客户端,不能替代管理 API 地址。
|
||||
|
||||
## 精确修改建议
|
||||
|
||||
### 1. 包级发布策略常量
|
||||
|
||||
在 `app/src/doctor_workstation/__init__.py:1-6` 添加两个普通源码常量,并更新 `__all__`。当前该文件已有未提交的版本升级 `1.1.0 -> 1.2.0`,实现时必须保留它,只做增量编辑。
|
||||
|
||||
建议名称和取值:
|
||||
|
||||
```python
|
||||
DEBUG_MODE = False
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
__all__ = ["__version__", "DEBUG_MODE", "ONLINE_API_BASE_URL"]
|
||||
```
|
||||
|
||||
`DEBUG_MODE` 不应来自 `DOCTOR_DEBUG_MODE` 或其他环境变量,否则已安装程序仍可被本地环境切回调试地址,直接违反需求。它应是发布代码/构建产物内的策略开关。线上常量不要带查询参数、凭据或 fragment;是否在常量里带 `/adminapi` 均可,但建议只放域名根地址,让 `normalize_api_base_url()` 保持路径的唯一规范化入口。
|
||||
|
||||
`config.py` 从包根导入这两个常量不会形成循环:包 `__init__.py` 不导入 `config.py`;现有 `services/app_update.py:21` 也已经用相同方式从包根导入 `__version__`。
|
||||
|
||||
### 2. 在 AppConfig 的两个入口执行同一发布策略
|
||||
|
||||
涉及 `app/src/doctor_workstation/config.py:17-30, 67-85, 113-153, 167-176`。
|
||||
|
||||
建议在 `normalize_api_base_url()` 之后增加一个小型策略函数(名称可调整):
|
||||
|
||||
```python
|
||||
def effective_api_base_url(candidate: str) -> str:
|
||||
if not DEBUG_MODE:
|
||||
# 源码常量无效属于发布错误,应显式失败,不要静默退回空地址。
|
||||
return normalize_api_base_url(ONLINE_API_BASE_URL)
|
||||
try:
|
||||
return normalize_api_base_url(candidate)
|
||||
except ValueError:
|
||||
return ""
|
||||
```
|
||||
|
||||
然后在两个入口复用:
|
||||
|
||||
1. `AppConfig.load()` 的 `api_url` 必须由该函数生成。这样进程环境和 `.env` 在 release 模式下即使含 `http://127.0.0.1` 也只会被读取而不会成为有效 API 地址。
|
||||
2. `_merge_preferences()` 在 `DEBUG_MODE=False` 时必须忽略 JSON 中的 `api_base_url`;也可以允许读取后在 `replace()` 前强制写回 `effective_api_base_url(...)`。关键是**发布策略必须在 preference 合并之后生效**。
|
||||
3. `with_updates()` 在 `DEBUG_MODE=False` 时必须把任何传入的 `api_base_url` 强制改为线上值,而不是仅做 URL 规范化。登录页运行期正是通过此入口更新配置。
|
||||
|
||||
实现上可选择“每个入口调用 `effective_api_base_url()`”,也可选择一个 `_apply_runtime_policy()` 在 `_merge_preferences()` 和 `with_updates()` 的 `replace()` 之后统一执行。后者更不容易遗漏,但需要保证两条返回路径都调用它。
|
||||
|
||||
不建议用 `AppConfig.__post_init__()` 强制改写所有直接构造的实例。仓库中大量单元/UI 测试直接构造带 `.test` 域名的 `AppConfig`(例如 `app/tests/test_ui_contract.py:408-412`);发布要求针对真实运行配置入口,没必要破坏依赖注入式测试。若希望更强的防御,可提供显式 `apply_runtime_policy()`,由 `load()`、`with_updates()` 和控制器接收外部 `AppConfig` 时调用。
|
||||
|
||||
### 3. 封住 Qt QSettings 的第二套本地 preference
|
||||
|
||||
涉及 `app/src/doctor_workstation/ui/login.py:451-468, 788-851, 917-950, 956-969, 1026-1054`。
|
||||
|
||||
这是满足“本地 preference 不应把它改回调试地址”的必需修改,不是纯 UI 优化:
|
||||
|
||||
- `_restore_settings()`:`DEBUG_MODE=False` 时,`server_url_edit` 只能显示 `config.api_base_url`,不得读取 `self.settings.value("server/base_url", ...)`;`DEBUG_MODE=True` 时保持现有读取逻辑。
|
||||
- `_apply_server_settings()`:`DEBUG_MODE=False` 时使用 `config.api_base_url` 作为 `base_url`,不得信任编辑框或旧 QSettings;也不要把旧调试地址重新写入 `server/base_url`。`DEBUG_MODE=True` 时保持现状。
|
||||
- 发布模式下至少将 `server_url_edit` 设为只读。也可隐藏地址编辑入口,但不要无意中一起删除超时设置;是否同时禁止“信任自签名证书”属于另一项发布安全策略。
|
||||
- `_credential_scope()` 当前优先使用地址编辑框(`login.py:956-969`)。因此必须先确保发布模式下编辑框显示线上地址,否则实际请求虽已被 `with_updates()` 锁到线上,密码却可能错误地按旧调试地址做凭据 scope,造成跨环境凭据恢复混乱。
|
||||
|
||||
不建议启动时删除旧的 `server/base_url` QSettings。发布模式忽略它即可,这样将来显式切到 `DEBUG_MODE=True` 时仍能保留既有调试配置,也避免无必要的数据清理。
|
||||
|
||||
### 4. bootstrap / repository 侧无需另建域名来源
|
||||
|
||||
实际启动入口是 `app/src/doctor_workstation/__main__.py:5-19 -> app.py:1166-1182`。`main()` 只调用一次 `AppConfig.load()`,随后 `ApplicationController.__init__()` 立即执行 `_rebuild_remote_repository()`(`app.py:402-424`);后者把 `self.config.api_base_url` 原样传给 `build_repository()`(`app.py:513-525`),再由 `ApiClient` 规范化为带结尾斜杠的 `/adminapi/` 地址(`services/factory.py:13-45`;`services/api_client.py:133-148`)。
|
||||
|
||||
因此 `app.py`、`factory.py`、`api_client.py` 不应复制线上域名常量。只要 `AppConfig` 在完成所有合并后保持不变量,这一段无需修改。
|
||||
|
||||
可选的纵深防御:`ApplicationController._on_config_changed()` 在收到一个完整 `AppConfig` payload 时目前直接接受(`app.py:460-489`),只有 dict payload 才经过 `self.config.with_updates()`。若未来可能有第二个发信者,建议让完整 `AppConfig` 同样经过显式 runtime policy;当前唯一连接来自 `LoginWindow`(`app.py:447`),且其正常路径会先调用 `with_updates()`,所以这不是本次最小改动的阻塞项。
|
||||
|
||||
## 当前覆盖顺序与修改后顺序
|
||||
|
||||
当前:
|
||||
|
||||
```text
|
||||
.env --(override=False)--> os.environ
|
||||
|
|
||||
v
|
||||
AppConfig(env)
|
||||
|
|
||||
v
|
||||
preferences.json 覆盖 env
|
||||
|
|
||||
v
|
||||
LoginWindow 的 QSettings/server/base_url 覆盖 config
|
||||
|
|
||||
v
|
||||
with_updates -> save_preferences -> rebuild repository
|
||||
```
|
||||
|
||||
建议修改后:
|
||||
|
||||
```text
|
||||
DEBUG_MODE=True : 保持上面的完整可配置链路
|
||||
|
||||
DEBUG_MODE=False:
|
||||
env/.env ----------- ignored for api_base_url ---+
|
||||
preferences.json --- ignored for api_base_url ---+--> ONLINE_API_BASE_URL
|
||||
QSettings ---------- ignored for api_base_url ---+ |
|
||||
runtime update ------ clamped for api_base_url ---+ v
|
||||
build_repository
|
||||
```
|
||||
|
||||
## 兼容风险与边界
|
||||
|
||||
1. **Demo 模式仍可覆盖“是否使用远端仓库”。** 当前 `demo_mode` 默认 `True`,且仍可被环境和 `preferences.json` 覆盖(`config.py:93, 126, 140-153`);登录页也会保存它(`login.py:1002-1006`)。本需求只要求固定 API 域名,所以不应顺手强制 `demo_mode=False`。如果产品语义其实是“发布版必须始终连接线上、不能进入 Demo”,需要单独明确并给 `demo_mode` 增加相同发布策略。
|
||||
2. **TLS 校验仍可被本地 preference 关闭。** `verify_ssl` 当前可由环境、JSON preference 和 QSettings 改为 `False`(`config.py:129, 151-152, 174-175`;`login.py:937-944, 1038-1048`)。固定线上域名但允许关闭证书校验仍有中间人风险。建议产品确认是否在 `DEBUG_MODE=False` 时也强制 `verify_ssl=True`,但它超出“域名不可改”的最小范围。
|
||||
3. **调试启动脚本不会自动打开源码 DEBUG_MODE。** `app/Debug_DoctorWorkstation.bat:16-23` 只设置独立配置目录、Demo 和日志级别,没有能力改变源码布尔常量。若常量提交为 `False`,脚本仍能跑 Demo,但不能用本地 URL。不要为方便而从环境读取 `DEBUG_MODE`;更安全的方案是开发者本地改为 `True`(不提交),或由明确区分的 debug 构建生成非发布模块。
|
||||
4. **已有 preference 不需要迁移或删除。** 发布模式会忽略旧调试 URL;切回 debug 后仍按现有优先级恢复。`save_preferences()` 当前把完整 dataclass 写入 JSON(`config.py:155-165`),发布运行后可能把线上 URL写回文件,这是可接受的,但测试应覆盖“旧文件存在时首次启动仍直接得到线上 URL”。
|
||||
5. **凭据按 URL scope 隔离。** `LoginWindow._credential_scope()` 和 `TokenStore` 使用 API scope。切到线上后旧调试 token/password 不应被用于线上,这是正确行为;但若只锁 `AppConfig` 而不锁登录页 QSettings,可能出现“请求发往线上、密码却按调试 URL scope 保存/恢复”的错配,因此第 3 节不能省略。
|
||||
6. **构建 smoke 环境目前注入 loopback API。** Windows/macOS 构建与安装 smoke 分别在 `app/scripts/build_windows.ps1:28-47`、`build_macos.sh:105-123`、`smoke_windows_installer.ps1:101-109` 注入 `https://127.0.0.1:9`。发布策略生效后该变量会被忽略。正常 smoke 不应访问线上:更新检查被 `DOCTOR_SMOKE_TEST`/`--smoke-test` 阻断(`ui/dialogs/app_update.py:332-340`),且这些脚本设置 `DOCTOR_DEMO_MODE=true`,会阻断 session restore(`app.py:530-540`)。仍建议增加“smoke 期间没有发起线上请求”的断言,避免未来启动流程变化造成生产流量。
|
||||
7. **不要把线上常量的错误静默转为空地址。** 调试环境输入无效时保持当前的空地址降级合理;源码内线上常量无效则应让测试/构建立即失败,否则发布包只会落入 `_UnconfiguredRepository`(`app.py:435-436, 513-525`),错误会拖到登录时才暴露。
|
||||
8. **版本读取兼容。** PyInstaller spec 用正则只读取 `__version__` 行(`app/packaging/doctor_workstation.spec:27-37`)。只要保留当前独立的 `__version__ = "1.2.0"` 赋值,新增常量与 `__all__` 不影响版本生成。
|
||||
|
||||
## 建议测试
|
||||
|
||||
优先在 `app/tests/test_config.py` 增加以下矩阵:
|
||||
|
||||
1. `DEBUG_MODE=False`,环境 `DOCTOR_API_BASE_URL=http://127.0.0.1:8000`,无 preference:`AppConfig.load().api_base_url == "https://admin.zhenyangtang.com.cn/adminapi"`。
|
||||
2. `DEBUG_MODE=False`,环境为线上、`preferences.json` 保存调试 URL:最终仍为线上。
|
||||
3. `DEBUG_MODE=False`,先 `AppConfig.load()`,再 `with_updates(api_base_url="http://localhost:8000")`:最终仍为线上。
|
||||
4. `DEBUG_MODE=True`,环境提供 A、preference 提供 B:最终仍为 B,证明现有“preference 覆盖 env”行为未回归。
|
||||
5. `DEBUG_MODE=True`,无 preference,仅环境提供 URL:继续规范化并自动追加 `/adminapi`。
|
||||
6. 将 `ONLINE_API_BASE_URL` 临时 monkeypatch 为非法值且 `DEBUG_MODE=False`:应显式抛错,避免发布误配置静默降级。
|
||||
|
||||
在 `app/tests/test_ui_contract.py` 增加:
|
||||
|
||||
1. 发布模式的 QSettings 预置 `server/base_url=http://127.0.0.1:8000`,创建 `LoginWindow` 后地址框显示线上 URL且不可编辑。
|
||||
2. 发布模式调用 `_save_server_settings()` / 非 Demo `submit()`,`config_changed` payload 的 `api_base_url` 仍为线上,远端仓库不会以 QSettings 地址重建。
|
||||
3. 上述场景下 `_credential_scope()` 返回线上 scope,防止凭据落在旧调试 scope。
|
||||
4. `DEBUG_MODE=True` 重跑同类场景,确认地址框仍从 QSettings 恢复、保存后仍能切换服务器。
|
||||
|
||||
在 bootstrap/构建层增加或保留以下回归:
|
||||
|
||||
1. `ApplicationController` 用 `AppConfig.load()` 启动时,传给 `build_repository()` 的 release base URL 精确为 `https://admin.zhenyangtang.com.cn/adminapi`。
|
||||
2. `--smoke-test` 和 `DOCTOR_SMOKE_TEST=1` 下,无论 release URL 是否存在,都不执行更新请求或 token restore 网络调用。
|
||||
3. 冻结包 smoke 继续通过;原 smoke 脚本中的 loopback `DOCTOR_API_BASE_URL` 被忽略是预期行为,不应把断言写成“最终 URL 等于 127.0.0.1”。
|
||||
|
||||
建议验证命令:
|
||||
|
||||
```powershell
|
||||
Set-Location D:\web\zyt\app
|
||||
uv run pytest tests/test_config.py tests/test_ui_contract.py -q
|
||||
uv run ruff check src/doctor_workstation/__init__.py src/doctor_workstation/config.py src/doctor_workstation/ui/login.py tests/test_config.py tests/test_ui_contract.py
|
||||
```
|
||||
|
||||
若还修改了 bootstrap 防御或 smoke 契约,再运行相关完整测试和冻结构建门禁;仅本分析任务未修改生产代码、也未执行会连接线上环境的测试。
|
||||
@@ -0,0 +1,143 @@
|
||||
# 登录页 DEBUG_MODE 门禁分析
|
||||
|
||||
## 结论
|
||||
|
||||
当前实现不存在 `DEBUG_MODE`(项目内唯一含 `debug_mode` 的命中只是一个测试函数名)。登录页始终创建并展示“演示模式”和“服务器设置”入口;`AppConfig.demo_mode` 默认又是 `True`,且 `preferences.json` 会覆盖环境配置。因此,仅对两个控件调用 `hide()` 不能满足目标:隐藏的 checkbox 仍可能保持 checked,普通登录仍会自动读取/写回残留 `QSettings`,控制器也会接受伪造或残留的 demo 状态。
|
||||
|
||||
建议把 `DEBUG_MODE` 设计成**非用户偏好、不可由 `QSettings` 或 `preferences.json` 覆盖的单一运行时门禁**,并在配置加载、LoginWindow 行为和 ApplicationController 三层同时收口:
|
||||
|
||||
- `DEBUG_MODE=True`:显示且允许演示仓库切换和登录页服务器设置,保留现有调试行为。
|
||||
- `DEBUG_MODE=False`:隐藏完整 UI 区块,强制 effective demo 为 `False`,忽略残留服务器 QSettings,登录只能使用 composition root 提供的远程仓库;直接调用槽函数、设置隐藏 checkbox、发信号或构造 demo payload 也不能绕过。
|
||||
|
||||
`demo_mode` 只能表示 DEBUG 模式下的默认选择/当前选择,不能再充当“是否有权使用 demo”的授权位。
|
||||
|
||||
## 当前实现与风险点
|
||||
|
||||
### 1. 配置与持久化
|
||||
|
||||
| 位置 | 当前行为 | DEBUG_MODE=False 的风险 |
|
||||
| --- | --- | --- |
|
||||
| `src/doctor_workstation/config.py:88-99` | `AppConfig.demo_mode` 默认 `True`,没有 debug gate | 直接构造 `AppConfig()` 就默认允许 demo |
|
||||
| `config.py:113-133` | `DOCTOR_DEMO_MODE` 未设置时也按 `True` 加载,然后调用 `_merge_preferences()` | 生产未显式注入环境变量时默认 demo;即使环境设为 false,后续偏好仍可覆盖 |
|
||||
| `config.py:135-153` | `preferences.json` 中所有 dataclass 字段均会合并,包括 `demo_mode`、`api_base_url`、`request_timeout`、`verify_ssl` | 旧 debug profile 的 demo/server 值可覆盖本次受控配置 |
|
||||
| `config.py:155-165` | `save_preferences()` 用 `asdict(self)` 保存完整配置 | demo 切换和服务器设置会持续残留在 JSON 中 |
|
||||
| `config.py:167-176` | `with_updates()` 可随时把 demo/服务器字段改回调试值 | UI 隐藏后仍可从信号/直接调用修改 |
|
||||
|
||||
需要特别区分两套持久化:demo 当前**不写 QSettings**,它通过 `config_changed -> ApplicationController._on_config_changed -> save_preferences()` 写入 `preferences.json`;服务器地址、超时和证书校验先写 `QSettings`,随后同一信号链又写入 `preferences.json`。相关位置是 `ui/login.py:1038-1054` 和 `app.py:460-491`。
|
||||
|
||||
当前 `Debug_DoctorWorkstation.bat:16-24` 只是设置 `DOCTOR_DEMO_MODE=true` 和 `DOCTOR_LOG_LEVEL=DEBUG`,没有提供独立 debug capability。若新门禁来自环境,调试启动器应显式设置专用值(例如 `DOCTOR_DEBUG_MODE=true`);普通/冻结启动不得设置。若门禁是构建期常量,则无需让用户偏好或 `.env` 参与。无论采用哪种来源,都不要把它作为普通 `AppConfig` dataclass 字段写入 `preferences.json`。
|
||||
|
||||
### 2. LoginWindow 组件和信号
|
||||
|
||||
| 位置 | 组件/信号链 | 当前行为与缺口 |
|
||||
| --- | --- | --- |
|
||||
| `ui/login.py:444-449` | `server_settings_changed(dict)`、`config_changed(object)`、`demo_mode_changed(bool)` | `server_settings_changed` 目前仅测试监听;另外两个信号由 controller 监听。所有发射点都无 debug gate |
|
||||
| `login.py:451-468` | 构造参数、`demo_repository`、`active_repository` | 只要传入 demo repository 就保留可切换能力;controller 当前总会传入 |
|
||||
| `login.py:739-755` | “记住密码”行和 `demo_check` | checkbox 始终加入布局;只有 repository 为空时 disabled,不会隐藏 |
|
||||
| `login.py:770-855` | “或”分隔线、`server_toggle`、`server_panel` 及 URL/timeout/self-signed/save 子控件 | toggle 始终显示,panel 只是在初始时折叠。若只隐藏 toggle,“或”分隔线和固定 spacing 仍会残留 |
|
||||
| `login.py:917-948` | `_restore_settings()` | 始终从 QSettings 恢复三项 server 值;只看 `config.demo_mode` 就勾选 demo。`setChecked(True)` 会触发已连接的 `_on_demo_toggled()` |
|
||||
| `login.py:956-969` | `_credential_scope()` | 优先读取 `server_url_edit`;即便控件隐藏,残留 QSettings URL 仍可改变凭据读取/保存 scope |
|
||||
| `login.py:1002-1013` | `demo_check.toggled -> _on_demo_toggled()` | 切换 `active_repository`,发射 `demo_mode_changed`,再经 `_emit_config_update` 发射 `config_changed`;没有权限判断 |
|
||||
| `login.py:1015-1027` | `server_toggle.clicked`、save button | 方法可被直接调用,隐藏控件并不能阻止 panel 展开或保存 |
|
||||
| `login.py:1029-1054` | `_apply_server_settings()` | 会持久化 QSettings、发射两个配置相关信号;没有权限判断 |
|
||||
| `login.py:1079-1126` | `submit()` | 直接以隐藏 checkbox 的 checked 状态决定 demo;非 demo 登录会**无条件自动应用服务器控件当前值**,所以旧 QSettings 即便不展开 panel 也会生效 |
|
||||
| `login.py:1140-1159` | `_set_loading()` | loading 结束会按 `demo_repository is not None` 重新 enable demo,并重新 enable server 子控件;需把 debug gate 合入 enable 条件 |
|
||||
| `login.py:1177-1216` | 登录成功与凭据保存 | `payload["demo_mode"]` 决定是否保存密码;凭据 scope 又可能来自隐藏的 server edit |
|
||||
| `login.py:1224-1234` | 证书错误 | 会直接勾选 toggle 并展开 panel;生产隐藏后仍可被错误路径重新显示 |
|
||||
|
||||
证书错误文案还在 `ui/widgets.py:376-383` 明确引导用户展开服务器设置、关闭证书校验。非 debug 模式必须改为不引用隐藏入口的运维提示,否则 UI 和文案契约矛盾。
|
||||
|
||||
### 3. ApplicationController 与登录可信边界
|
||||
|
||||
| 位置 | 当前行为 | 需要的防线 |
|
||||
| --- | --- | --- |
|
||||
| `app.py:402-423` | 总是实例化 `DemoDoctorRepository()`;`current_demo_mode=config.demo_mode` | 非 debug 不创建/不暴露 demo repository,并强制 current demo false |
|
||||
| `app.py:438-455` | 总把 demo repository 传给 LoginWindow;复用窗口时信任 `demo_check` | 传递显式 gate;非 debug 复用时重置 checkbox/active repository |
|
||||
| `app.py:460-506` | 接受 `demo_mode` 及全部 server 字段,保存 preferences 并重建 repository | 非 debug 拒绝 debug-only changes,避免伪造 `config_changed` 绕过 UI |
|
||||
| `app.py:508-511` | 任意 `demo_mode_changed(True)` 都会设置 current demo 并取消 session restore | 非 debug 忽略/纠正 true |
|
||||
| `app.py:530-540` | `config.demo_mode=True` 会跳过生产 token restore | 必须基于经过门禁归一化的 effective demo;残留 preference 不能阻止 restore |
|
||||
| `app.py:653-681` | 信任成功 payload 中的 `demo_mode` 与 repository | 非 debug 必须拒绝 demo payload/repository,或无条件把 effective demo 归零;这是 UI 之外的最后可信边界 |
|
||||
| `app.py:782-792` | `current_demo_mode` 决定是否打开离线 demo 视频窗 | 前述边界不收口时,伪造状态还会扩散到登录后的功能 |
|
||||
|
||||
## 精确修改建议
|
||||
|
||||
### A. 建立单一、不可持久化的 capability
|
||||
|
||||
在 `src/doctor_workstation/config.py` 定义唯一 `DEBUG_MODE`(或等价只读函数),由受控构建/专用调试启动器决定。不要从 `QSettings` 读取,不要让 `preferences.json` 覆盖,也不要随 `asdict(AppConfig)` 保存。
|
||||
|
||||
配置加载完成后必须做一次最终归一化:`effective_demo_mode = DEBUG_MODE and requested_demo_mode`。在 `DEBUG_MODE=False` 时,`_merge_preferences()` 至少忽略 `demo_mode`;若“服务器设置不可用”意味着生产连接完全由受控环境提供,还应同时忽略偏好中的 `api_base_url`、`request_timeout`、`verify_ssl`,否则旧登录页设置虽然 UI 不可见,仍会从 JSON 生效。`with_updates()` 也应拒绝或丢弃非 debug 下对这些 debug-only 字段的修改。
|
||||
|
||||
推荐把 debug capability 显式传给 `ApplicationController`/`LoginWindow` 或保存为只读实例属性,便于测试 True/False 两条路径。不要在多个模块各自复制一个可 monkeypatch 的常量,否则测试或运行时可能出现 config 判 false、UI 判 true 的分裂状态。
|
||||
|
||||
### B. LoginWindow:可见性和行为同时门禁
|
||||
|
||||
在 `ui/login.py:451-468` 记录 `self.debug_mode`,并把 `self.demo_repository` 设为 `demo_repository if debug_mode else None`。建议仍构造具名控件以保持测试和代码引用稳定,但所有状态转换都使用 `self.debug_mode` 判断。
|
||||
|
||||
UI 结构建议:
|
||||
|
||||
1. `demo_check` 仅在 debug 时 visible,并且 enabled 条件为 `debug_mode and demo_repository is not None and not loading`。
|
||||
2. 把 `login.py:770-855` 的“或”分隔线、server toggle、panel 和上下 spacing 包进一个 `self.debug_server_section` QWidget;整个 section 仅在 debug 时 visible。单独隐藏 `server_toggle` 会留下“或”和空白。
|
||||
3. panel 初始仍折叠;debug true 时保持现有 toggle 行为。
|
||||
|
||||
行为防线建议:
|
||||
|
||||
1. `_restore_settings()`:非 debug 不读取 `server/*` QSettings,不恢复 demo,明确令 demo unchecked、active repository 为 production repository;服务器控件若仍构造,只从受控 `config` 填充。是否删除旧键是迁移策略,**忽略它们才是安全要求**。
|
||||
2. `_credential_scope()`:非 debug 始终从受控 config URL 取 scope,不读取隐藏的 `server_url_edit`。
|
||||
3. `_on_demo_toggled(True)`:非 debug 立即用 signal blocker 恢复 unchecked/production repository,然后 return;不得发 `demo_mode_changed` 或 `config_changed`。
|
||||
4. `_toggle_server_panel()`、`_save_server_settings()`、`_apply_server_settings()`:非 debug 强制 panel 关闭且不写 QSettings、不发 `server_settings_changed/config_changed`。直接调用也必须无效。
|
||||
5. `submit()`:用 `demo_mode = self.debug_mode and self.demo_check.isChecked()`,并从这个 effective 值选择 repository。非 debug 跳过 `_apply_server_settings()`,只使用 composition root 已构造的 remote repository;否则会再次应用隐藏控件中的旧值。
|
||||
6. `_set_loading()`:所有 demo/server enabled 状态与 `self.debug_mode` 做 AND,防止 loading 完成后重新激活。
|
||||
7. `_on_login_error()`:仅 debug 时自动展开 certificate panel;非 debug 保持 section 隐藏,并显示“请联系管理员检查受控服务器/证书配置”之类不提供绕过证书校验的文案。
|
||||
|
||||
### C. Controller:不要信任 UI 状态或 payload
|
||||
|
||||
在 `app.py:402-423` 以同一 capability 计算 effective state;非 debug 最好根本不实例化 `DemoDoctorRepository`。`_show_login()` 显式传 gate,窗口复用时不要读取隐藏 checkbox 决定 repository。
|
||||
|
||||
`_on_config_changed()` 必须再次过滤 demo/server debug-only 字段;`_on_demo_mode_changed()` 非 debug 不接受 true;`_begin_session_restore()` 不得因未经门禁的旧 `config.demo_mode` 跳过;`_on_login_succeeded()` 应把 demo capability 作为可信边界,非 debug 收到 `demo_mode=True` 或 demo repository 时拒绝进入 shell并清理 session,而不是静默接受 payload。这样即使未来有其他代码直接调用槽函数,也不能重新开启演示路径。
|
||||
|
||||
## `tests/test_ui_contract.py` 现状与调整
|
||||
|
||||
实际文件是 `app/tests/test_ui_contract.py`,`app/tests` 下没有 `conftest.py`;这里使用的 `tmp_path`/`monkeypatch` 是 pytest 内置 fixture,相关 helper 都定义在测试函数内。
|
||||
|
||||
现有相关契约:
|
||||
|
||||
- `test_ui_contract.py:230-273`:真实 demo 登录,证明 `config.demo_mode=True` 会勾选 checkbox、使用空账号密码登录 demo,并发出 demo payload;未覆盖 debug capability。
|
||||
- `test_ui_contract.py:276-337`:同一 QSettings 跨窗口恢复账号/密码;不涉及 demo。
|
||||
- `test_ui_contract.py:340-375`:服务器 panel 在最小窗口的布局。
|
||||
- `test_ui_contract.py:378-399`:函数名虽含 `debug_mode`,实际仅验证 self-signed 值写入 QSettings,没有任何 `DEBUG_MODE` 判断。
|
||||
- `test_ui_contract.py:402-455`:普通登录前自动应用 server 值、经 `config_changed` 换成新 repository。
|
||||
- `test_ui_contract.py:458-463`:证书错误文案指向服务器设置。
|
||||
- `test_ui_contract.py:495-513`:证书错误会自动展开服务器 panel;这个契约只应在 debug true 成立。
|
||||
|
||||
引入 gate 后,`230`、`340`、`378`、`402`、`495` 这几组依赖 demo/server 的测试都应显式运行在 `DEBUG_MODE=True`,避免它们因测试默认值偶然通过。不要新增“把 demo 写入 QSettings”的契约;当前 demo 的持久化源是 AppConfig/preferences,目标反而要求非 debug 忽略该残留值。
|
||||
|
||||
## 建议回归测试矩阵
|
||||
|
||||
### `tests/test_ui_contract.py`
|
||||
|
||||
1. **debug true 可见且可用**:show 窗口后断言 demo checkbox、完整 server section/toggle 可见;原 demo 登录、panel 几何、自签名保存、登录前应用服务器设置均继续通过。
|
||||
2. **debug false 无视觉残件**:断言 demo checkbox、`debug_server_section`(包括“或”分隔线)、toggle、panel 都不可见;demo unchecked,`active_repository is remote_repository`。
|
||||
3. **残留 QSettings 不生效**:预写 `server/base_url=旧地址`、`server/read_timeout`、`server/verify_ssl=false`,用 debug false 构造窗口;断言 credential scope/实际登录 repository 使用受控 config,QSettings 值未被 `_apply_server_settings()` 写回或发射成配置更新。
|
||||
4. **直接调用不能绕过**:debug false 下程序化 `demo_check.setChecked(True)`、`_on_demo_toggled(True)`、`_toggle_server_panel(True)`、`_save_server_settings()`;断言仍 unchecked、production repository、panel hidden,`demo_mode_changed`、`server_settings_changed`、`config_changed` 均无 debug 更新。
|
||||
5. **提交强制 production**:给 debug false 窗口同时传 remote 和 demo repository,并让 stale config 的 `demo_mode=True`;输入账号密码后立即执行 worker,断言只有 remote `login()` 被调用,payload `demo_mode=False`。
|
||||
6. **证书错误分模式**:debug true 仍自动展开并给出 self-signed 指引;debug false 不展开/不显示 section,错误文案不再提隐藏的“服务器设置”或关闭证书校验。
|
||||
7. **loading 不重启入口**:debug false 执行 `_set_loading(True)` 再 `_set_loading(False)`,断言 demo/server 控件持续 hidden + disabled。
|
||||
|
||||
### `tests/test_config.py`
|
||||
|
||||
1. 在隔离 `DOCTOR_CONFIG_DIR` 写入旧 `preferences.json`(至少 `demo_mode:true`);DEBUG false 加载后必须 `demo_mode is False`,即使 `DOCTOR_DEMO_MODE=true` 也不能越权。
|
||||
2. DEBUG true 时确认 `DOCTOR_DEMO_MODE`/允许的 demo preference 仍能选择默认 demo 状态。
|
||||
3. 若生产服务器配置要求环境权威,再写入旧 JSON server 字段,断言 DEBUG false 仍采用环境的 URL/timeout/verify_ssl。
|
||||
4. `with_updates(demo_mode=True)` 在 DEBUG false 下不能产生 effective demo true;同理覆盖 server debug-only 更新的策略。
|
||||
|
||||
### `tests/test_ui_contract.py` 中的 controller 边界(或拆到 controller 专属测试)
|
||||
|
||||
1. DEBUG false 时 `_begin_session_restore()` 不因 stale `config.demo_mode=True` 而跳过远程恢复。
|
||||
2. DEBUG false 时直接调用 `_on_demo_mode_changed(True)` 不改变 `current_demo_mode`。
|
||||
3. DEBUG false 时把 `demo_mode=True`/demo repository 的伪造 payload 传给 `_on_login_succeeded()`,断言不能创建 ShellWindow。
|
||||
|
||||
若采用专用 `DOCTOR_DEBUG_MODE` 环境变量,还应在 `tests/test_one_click_entrypoints.py:102-108` 增加调试启动器显式开启、普通启动器/打包入口不开启的静态契约,并同步 `.env.example:8-17` 与 `README.md:56-72`,避免继续把 `DOCTOR_DEMO_MODE=true` 描述成足以启用演示能力。
|
||||
|
||||
## 最小验收标准
|
||||
|
||||
非 debug 模式应同时满足以下可观察结果:登录页看不到 demo、服务器入口、“或”分隔线或相关空白;旧 demo preference 不能阻止远程 token restore;旧 server QSettings 不能改变 URL、timeout、TLS 校验或凭据 scope;程序化调用隐藏控件/槽函数/信号也不能切换仓库或进入 demo shell。只有这四层都成立,才不是单纯的视觉隐藏。
|
||||
@@ -0,0 +1,79 @@
|
||||
# Windows 1.2.0 正式包重建结果(DEBUG_MODE=False)
|
||||
|
||||
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 总体结果:成功
|
||||
- 生产源码修改:无(本次仅重建产物并新增本记录)
|
||||
|
||||
## 发布配置核对
|
||||
|
||||
打包前解析 `src/doctor_workstation/__init__.py`,确认:
|
||||
|
||||
- `__version__ = "1.2.0"`
|
||||
- `DEBUG_MODE = False`
|
||||
- `ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"`
|
||||
|
||||
打包完成后,使用 PyInstaller 的归档读取器打开
|
||||
`dist/DoctorWorkstation/DoctorWorkstation.exe` 内嵌的 `PYZ.pyz`,提取
|
||||
`doctor_workstation` 模块并检查其顶层字节码常量,得到:
|
||||
|
||||
- `STORE_NAME __version__` 前的常量为 `"1.2.0"`
|
||||
- `STORE_NAME DEBUG_MODE` 前的常量为 `False`
|
||||
- `STORE_NAME ONLINE_API_BASE_URL` 前的常量为
|
||||
`"https://admin.zhenyangtang.com.cn"`
|
||||
|
||||
因此本次重建的 EXE 已包含正式模式和线上 API 域名配置。
|
||||
Windows 版本资源也核对为:应用 EXE 的 `FileVersion` / `ProductVersion`
|
||||
均为 `1.2.0`,安装器的 `ProductVersion` 为 `1.2.0`。
|
||||
|
||||
## 打包
|
||||
|
||||
执行命令:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- Vue/Vite 前端构建:通过(43 个模块)
|
||||
- PyInstaller 6.22.0 / Python 3.12.12:通过
|
||||
- Frozen Qt multimedia file gate:通过
|
||||
- Frozen Qt multimedia smoke gate:通过
|
||||
- Frozen application entry smoke gate:通过
|
||||
- 7-Zip ZIP 创建:通过(`Everything is Ok`)
|
||||
- Inno Setup 6.7.3:通过(`Successful compile (175.078 sec)`)
|
||||
|
||||
构建过程出现 Vite 大 chunk 提示、一个可选 Qt QML 插件缺失提示以及
|
||||
Windows 系统 DLL 解析警告;它们均未阻断构建,且上述冻结产物门禁全部通过。
|
||||
|
||||
## 安装器烟测
|
||||
|
||||
执行命令:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离测试目录:
|
||||
`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-4988cc3cc71346e49925445b9ae57fb9`
|
||||
- 覆盖项:安装器图标、静默安装、已安装 EXE 启动、静默卸载及卸载残留检查
|
||||
|
||||
## 产物与校验
|
||||
|
||||
| 文件 | 大小(字节) | 大小(MiB) | SHA-256 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| `dist/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,912,808 | 155.366 | `D0A9EED88F42F7FBBF31920D7B1ED82BD241481E98964F42313DF386CB746C0A` |
|
||||
| `dist/DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,810,092 | 220.118 | `9FAA8596AE0D233626B3D44676C51E6DB0BA3496B12D2EE6C984ABC0E51EB01B` |
|
||||
| `dist/SHA256SUMS.txt` | 220 | 0.000 | `2DA56F76458ACE040597916C68B265C737D270B335EA73F21C4A248A2FA41B78` |
|
||||
|
||||
`dist/SHA256SUMS.txt` 内容:
|
||||
|
||||
```text
|
||||
D0A9EED88F42F7FBBF31920D7B1ED82BD241481E98964F42313DF386CB746C0A DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||
9FAA8596AE0D233626B3D44676C51E6DB0BA3496B12D2EE6C984ABC0E51EB01B DoctorWorkstation-Windows-x64-1.2.0.zip
|
||||
```
|
||||
|
||||
独立使用 `Get-FileHash -Algorithm SHA256` 重算 EXE 和 ZIP 后,两项均与
|
||||
`SHA256SUMS.txt` 逐字符匹配;清单恰好包含两条记录。
|
||||
@@ -0,0 +1,57 @@
|
||||
# Debug mode full-suite verification
|
||||
|
||||
Verification date: 2026-08-28 (Asia/Shanghai)
|
||||
|
||||
Scope: read-only verification of the current shared worktree under `D:\web\zyt\app`. No production source was modified.
|
||||
|
||||
## Pytest
|
||||
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -m pytest
|
||||
```
|
||||
|
||||
- Exit code: `1`
|
||||
- Result: `2 failed, 684 passed`
|
||||
- Total collected/executed: `686`
|
||||
- Duration: `3195.85s` (`0:53:15`)
|
||||
|
||||
Failures:
|
||||
|
||||
1. `tests/test_diagnosis_order_video_visual.py::test_video_table_embeds_player_and_preserves_row_bound_upload`
|
||||
- Assertion location: `tests/test_diagnosis_order_video_visual.py:429`
|
||||
- Assertion: `table.rowHeight(0) >= playback.required_table_row_height()`
|
||||
- Actual: row height `246`; required row height `250`.
|
||||
|
||||
2. `tests/test_reception_parity_ui.py::test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geometry`
|
||||
- Assertion location: `tests/test_reception_parity_ui.py:1575`
|
||||
- Assertion: `expand_button.size().width() == expand_button.size().height() == 28`
|
||||
- Actual: `QSize(28, 34)`; expected `QSize(28, 28)`.
|
||||
|
||||
## Ruff
|
||||
|
||||
Command:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -m ruff check src tests
|
||||
```
|
||||
|
||||
- Exit code: `1`
|
||||
- Result: `9` errors, all `F401` unused imports and all reported as fixable with `--fix`.
|
||||
|
||||
Findings:
|
||||
|
||||
1. `src/doctor_workstation/ui/diagnosis_index_widgets.py:37:5` - unused `PySide6.QtGui.QPixmap`.
|
||||
2. `src/doctor_workstation/ui/dialogs/ai_consult.py:18:5` - unused `PySide6.QtGui.QFont`.
|
||||
3. `src/doctor_workstation/ui/dialogs/ai_consult.py:24:5` - unused `PySide6.QtGui.QPixmap`.
|
||||
4. `src/doctor_workstation/ui/dialogs/ai_consult.py:26:5` - unused `PySide6.QtGui.QTextBlockFormat`.
|
||||
5. `src/doctor_workstation/ui/dialogs/ai_consult.py:27:5` - unused `PySide6.QtGui.QTextCharFormat`.
|
||||
6. `src/doctor_workstation/ui/dialogs/ai_consult.py:28:5` - unused `PySide6.QtGui.QTextCursor`.
|
||||
7. `src/doctor_workstation/ui/dialogs/prescription.py:19:28` - unused `datetime.datetime`.
|
||||
8. `src/doctor_workstation/ui/pages/patients.py:11:75` - unused `PySide6.QtGui.QPixmap`.
|
||||
9. `src/doctor_workstation/ui/pages/prescriptions.py:10:65` - unused `PySide6.QtGui.QPixmap`.
|
||||
|
||||
## Overall result
|
||||
|
||||
The full verification gate is failing: both pytest and ruff returned exit code `1`.
|
||||
@@ -0,0 +1,166 @@
|
||||
# 强制更新对话框“退出软件”安全实现分析
|
||||
|
||||
## 结论
|
||||
|
||||
强制更新对话框可以提供“退出软件”,但不能把按钮直接连接到 `dialog.close()`、`reject()` 或 `QApplication.quit()`。当前更新下载由全局 `QThreadPool` 中的 `QRunnable` 执行,退出应用不会自动取消或等待该任务;安全的最小方案应是两阶段退出:
|
||||
|
||||
1. GUI 线程记录“退出已请求”,禁止再提交安装,并用线程安全的取消事件通知下载任务;
|
||||
2. 更新任务通过既有 `finished` 信号确认已结束后,再由 `ApplicationController` 调用 `application.quit()`;
|
||||
3. `aboutToQuit` 中的 `ApplicationController.shutdown()` 只做最终、幂等的资源清理,不能承担异步等待任务结束的职责。
|
||||
|
||||
这条顺序保证:用户选择“退出软件”后不会又启动更新助手;`.part` 文件能走现有异常清理;Qt 事件循环在工作线程仍可能发信号时不会提前消失。
|
||||
|
||||
## 当前实现与证据
|
||||
|
||||
### 1. 强制对话框目前没有退出路径
|
||||
|
||||
- `AppUpdateDialog` 对强制更新移除关闭按钮并设为应用级模态(`app/src/doctor_workstation/ui/dialogs/app_update.py:112-121`)。
|
||||
- 按钮区只有“稍后提醒”“取消下载”“立即更新”;“稍后提醒”在强制更新时隐藏(`:179-197`)。
|
||||
- `set_busy()` 只在“忙且非强制”时显示取消下载,所以强制更新下载过程中没有任何停止入口(`:208-213`)。
|
||||
- 强制更新或任意下载忙状态都会忽略窗口关闭事件,强制更新还会忽略 Escape(`:265-275`)。
|
||||
|
||||
因此新增能力应是独立的 `exit_requested` 语义,而不是复用 `download_cancelled`。后者当前在 session 中明确拒绝强制更新(`:411-415`),且它的既有语义只是“取消后留在应用内”。
|
||||
|
||||
### 2. session 没有“请求取消 -> 已经停稳”的闭环
|
||||
|
||||
- `_TaskSignals` 已声明 `finished`,`_Task.run()` 也一定会在 `finally` 发出它(`app_update.py:67-90`),但 `AppUpdateSession` 没有连接该信号。
|
||||
- session 只保留一个跨线程共享的 `_cancel: bool` 和最近一次 `_signals`,没有活动 worker/token、退出状态或完成回调(`:285-292`)。
|
||||
- 检查任务和安装准备任务均直接提交到 `QThreadPool.globalInstance()`(`:316-330`、`:467-472`);局部 `worker` 没被 session 用来跟踪生命周期。
|
||||
- 安装准备任务直接把进度/状态连到 dialog,把结果连到 `_finish_install()`(`:467-471`)。关闭事件循环前没有撤销或门控这些回调。
|
||||
- 新 offer 到达时,session 会对旧 dialog 调用 `close()` 后立刻 `deleteLater()`(`:397-405`)。如果旧 dialog 正在强制更新/下载,它的 `closeEvent()` 会拒绝关闭,但 `deleteLater()` 仍会排队;与此同时旧 worker 仍持有连接和捕获该 dialog 的 lambda。这也是需要用“活动操作 token”阻止重入/替换的理由。
|
||||
|
||||
### 3. 当前取消只能在收到下载分块以后生效
|
||||
|
||||
- `download_package()` 把 HTTP read timeout 设为 `None`(`app/src/doctor_workstation/services/app_update.py:311`)。服务器建立连接后若不再发送数据,worker 可以无限阻塞在读取中,GUI 写入 `_cancel=True` 也不能唤醒 socket。
|
||||
- 取消回调只在 `iter_bytes()` 产出一个 chunk 后检查(`:334-337`)。取消被观察到时会抛出 `AppUpdateError`,现有异常分支会删除 `.part` 文件(`:349-351`),这一清理机制可以继续复用。
|
||||
- 下载返回以后没有再次检查取消状态;job 会继续校验安装器,或调用不可取消的 `safe_extract_zip()`(UI `app_update.py:439-465`;service `app_update.py:247-260`)。
|
||||
- 下载全部完成后,文件在 `os.replace()` 前也没有最后一次取消检查(service `app_update.py:358-367`)。即使退出请求恰好到达末尾,job 仍可能返回 `_PreparedUpdate`。
|
||||
- 工作目录在下一次同版本尝试开始时会整体删除重建(service `app_update.py:722-727`),所以取消发生在下载完成或解压阶段时,保留完整 zip/部分解压目录不会污染下一次尝试;关键仍是不能继续提交安装。
|
||||
|
||||
### 4. 直接 `quit()` 存在安装竞态
|
||||
|
||||
当前 `_finish_install()` 收到任何合法 `_PreparedUpdate` 就先启动外部更新助手,再用 300 ms 定时器调用 `application.quit()`(UI `app_update.py:483-503`)。更新助手按设计等待当前 PID 消失后才覆盖/安装并重启:archive 路径见 service `app_update.py:517-525`,Inno Setup 路径见 `:594-625`。
|
||||
|
||||
若下载中“退出软件”直接调用 `quit()`,存在以下时序:
|
||||
|
||||
1. worker 已完成最后一个 chunk,并已把 `result` 排进 GUI 事件队列;
|
||||
2. 用户的退出点击与该 queued result 先后到达 GUI 线程;
|
||||
3. 若 result 仍被处理,当前 `_finish_install()` 没有“退出已请求”门禁,会启动更新助手;
|
||||
4. 应用随后退出,于是用户选择的“只退出”实际变成“退出并安装”。
|
||||
|
||||
反向时序也不安全:如果 `quit()` 先结束事件循环,worker 仍可能在独立 `httpx.Client` 中写 `.part`、解压或发射 Qt 信号。`QApplication.quit()` 是退出事件循环的请求,不是 `QRunnable` 的 cancel/join。进程最终可能等待 Qt 线程池析构、遗留中间文件,或丢弃已经排队的结果;不能把这些析构时机当成生命周期协议。
|
||||
|
||||
### 5. `ApplicationController.shutdown()` 目前不管理 updater
|
||||
|
||||
- `aboutToQuit` 在控制器构造时连接到 `shutdown()`(`app/src/doctor_workstation/app.py:402-427`)。
|
||||
- `shutdown()` 只置 `_shutting_down`、失效 session restore、关闭视频和远端 API client;没有调用 `self.app_updater.shutdown()`(`:1081-1097`)。
|
||||
- Qt 配置了 `setQuitOnLastWindowClosed(True)`(`:1147-1162`),因此单纯关闭/拒绝 dialog 也不是统一的退出协议:父 login/shell 仍存在时未必退出,最后窗口意外关闭时又会绕过 updater 的准备阶段。
|
||||
- `ApiClient.close()` 会无超时地等待所有活跃短请求归还连接(`app/src/doctor_workstation/services/api_client.py:414-450`,尤其 `:427-432`)。更新检查使用的正是共享 remote client(UI `app_update.py:310-330`),所以若退出恰逢检查请求,`aboutToQuit -> shutdown -> client.close()` 可能在 GUI 线程等待请求超时/重试结束。强制对话框的原始检查通常已经返回,但 session 仍应在最终 shutdown 时先递增 generation,使迟到的检查结果绝不能再创建窗口。
|
||||
|
||||
`aboutToQuit` 已经处于事件循环退出阶段,不适合再启动“取消后等 finished signal”的异步流程;finished queued signal 可能已没有下一轮事件可处理。因此必须在点击“退出软件”时先完成 quiesce,再真正调用 `quit()`。
|
||||
|
||||
## 建议的最小实现
|
||||
|
||||
### A. 对话框只发意图,不自行退出
|
||||
|
||||
在 `AppUpdateDialog` 增加独立信号 `exit_requested = Signal()` 和按钮:
|
||||
|
||||
- 文案为“退出软件”,仅 `offer.force` 时显示;非强制更新继续使用“稍后提醒/取消下载”。
|
||||
- 强制更新即使 `_busy=True` 也保持该按钮可用,因为这正是下载中唯一的离开路径。
|
||||
- 点击后只 emit;session 接管状态转换。对话框增加 `set_exiting()`,禁用所有按钮、显示“正在停止更新并退出…”,防止双击。
|
||||
- `closeEvent()` 和 Escape 的现有强制拦截继续保留。不要让窗口标题栏关闭绕开协调器。
|
||||
- 一旦外部安装助手已经成功启动,进入不可逆的 `APPLY_COMMITTED` 状态,禁用“退出软件”;此后退出必然表示“退出并安装”。
|
||||
|
||||
### B. 用 `threading.Event` 和活动操作身份建立闭环
|
||||
|
||||
`AppUpdateSession` 最少需要以下 GUI 线程状态:
|
||||
|
||||
```python
|
||||
self._cancel_event = Event()
|
||||
self._active_install_signals: _TaskSignals | None = None
|
||||
self._exit_requested = False
|
||||
self._quit_when_idle: Callable[[], None] | None = None
|
||||
self._apply_committed = False
|
||||
```
|
||||
|
||||
开始安装准备时 `clear()` event,保存本次 `signals`,并把 `signals.finished` 连到带 `signals` 身份参数的 `_on_install_finished()`。进度、状态、result、error 也不要再直接连接 dialog 方法;统一经过 session handler,并同时验证:
|
||||
|
||||
- `signals is self._active_install_signals`;
|
||||
- dialog 仍是 `self.dialog`;
|
||||
- 未处于 `_exit_requested`(finished handler 除外)。
|
||||
|
||||
这会同时解决迟到回调、旧 dialog 被替换、以及上一次任务影响下一次 `_cancel` 状态的问题。活动安装存在时,`check()`/`_present()` 应拒绝再替换 dialog,避免两个 job 同时删除和使用同一版本 workspace。
|
||||
|
||||
退出请求的最小状态机是:
|
||||
|
||||
```text
|
||||
IDLE/PREPARING --点击退出--> EXIT_PENDING
|
||||
EXIT_PENDING --cancel_event.set()--> 等待当前 install signals.finished
|
||||
无活动任务或 finished 到达 --> ApplicationController.request_quit()
|
||||
aboutToQuit --> ApplicationController.shutdown() 最终幂等清理
|
||||
```
|
||||
|
||||
`_finish_install()` 的第一条业务门禁必须是“如果退出已请求、event 已 set、或 signals 已不是当前操作,则直接返回,不调用 `apply_downloaded_update()`”。这是防止“退出反而安装”的关键断言。
|
||||
|
||||
### C. 让 job 在阶段边界观察取消,并给网络读取有限上界
|
||||
|
||||
现有 `download_package(cancelled=...)` 接口无需改变,改传 `self._cancel_event.is_set`。job 至少在以下边界调用统一的 `_raise_if_cancelled()`:
|
||||
|
||||
1. 创建 workspace 前;
|
||||
2. `download_package()` 返回后;
|
||||
3. 安装器校验/zip 解压前;
|
||||
4. 校验/解压后、构造 `_PreparedUpdate` 前。
|
||||
|
||||
同时把 `download_package()` 的 `read=None` 改成有限的“单次读空闲超时”,建议沿用配置的 `request_timeout` 或默认 30 秒。这个 timeout 不是总下载时长:只要持续收到 chunk,大文件仍可继续;服务器停止发数据后,退出等待则有确定上界。
|
||||
|
||||
如果希望解压中点击退出也能很快响应,可把 `safe_extract_zip()` 从一次性 `extractall()` 改为逐 member 提取并在每个 member 前检查同一个 cancel callback。若坚持最小改动,也可以让退出等待当前 `extractall()` 完成,但必须保持 event loop 和 dialog 存活,并在解压后门禁掉安装,不能先 `quit()`。
|
||||
|
||||
### D. 由控制器统一发起真正退出
|
||||
|
||||
在 `ApplicationController` 增加与 `_shutting_down` 分离的 `_quit_requested`,以及幂等 `request_quit()`:
|
||||
|
||||
1. 首次调用时设置 `_quit_requested`;
|
||||
2. 调用 `app_updater.prepare_to_quit(self.application.quit)`;
|
||||
3. updater 无活动安装时立即以 `QTimer.singleShot(0, callback)` 完成;有任务时保存 callback,待该任务 `finished` 后完成;
|
||||
4. 重复调用不做任何事。
|
||||
|
||||
不要提前设置 `_shutting_down`,否则真正触发 `aboutToQuit` 时现有 `shutdown()` 会在 `:1084-1086` 直接返回,跳过资源释放。
|
||||
|
||||
`ApplicationController.shutdown()` 中应在 `_cancel_session_restore()` 之后、关闭视频和 remote client 之前调用幂等的 `self.app_updater.shutdown()`。该方法应:递增 `_generation`、设置 cancel event、清除退出 callback、使所有迟到 callback 失效;它是兜底,不再等待 worker。正常的强制对话框退出路径到这里时,安装准备 worker 已经 finished。
|
||||
|
||||
成功更新也应复用同一完成门:`_finish_install()` 成功启动 helper 后只记录 `_apply_committed=True` 和“任务结束后退出”;由本次 `signals.finished` 再调用控制器的 `request_quit()`。这样可以删除当前依赖经验值的 300 ms 定时退出(UI `app_update.py:501-503`),并明确保证 worker 已离开 `run()`。
|
||||
|
||||
### E. 不建议的实现
|
||||
|
||||
- 不要在退出按钮中调用 `os._exit()`、`terminate()` 或强杀线程;这会绕过 controller 的视频/API 清理,并可能截断 `.part`/日志写入。
|
||||
- 不要在 `aboutToQuit` 中调用 `QThreadPool.globalInstance().waitForDone()`;它会等待整个应用的全局线程池,而不只是更新任务,当前无限 read timeout 还可能让 GUI 永久卡住。
|
||||
- 不要用循环 `processEvents()` 等待 worker;这会允许更新按钮、窗口关闭和 queued result 重入。
|
||||
- 不要只设置现有 `_cancel=True` 后立即 `quit()`;设置取消只是请求,`finished` 才是可退出的确认。
|
||||
|
||||
## 建议补测
|
||||
|
||||
在 `app/tests/test_app_update_ui.py` 现有强制对话框测试(`:68-83`)基础上补:
|
||||
|
||||
1. 强制更新显示“退出软件”,不显示“稍后提醒”,关闭按钮/Escape 仍不能绕过;非强制更新不显示该退出按钮。
|
||||
2. 空闲时点击退出只调用一次 controller `request_quit()`。
|
||||
3. 下载中点击退出会 set event、保持应用运行且不立即调用 `application.quit()`;手工 emit 当前 signals 的 `finished` 后才调用一次。
|
||||
4. 退出请求后再投递 `progress/status/result/error` 均不更新旧 dialog;特别断言 `_finish_install()` 不调用 `apply_downloaded_update()`。
|
||||
5. 模拟“result 已排队但退出点击先处理”的边界,断言不会启动 helper;模拟 result 已先完成 helper 提交,则退出按钮已禁用且最终走“安装后退出”。
|
||||
6. 新 offer 在活动安装期间不会 `deleteLater()` 当前 dialog,也不会启动第二个 workspace job。
|
||||
7. `ApplicationController.shutdown()` 调用 updater shutdown 早于 `remote_repository.client.close()`,并保持二次调用幂等。
|
||||
|
||||
在 `app/tests/test_app_update.py` 的下载测试(现有 `:235-293`)基础上补:
|
||||
|
||||
8. 流式响应在若干 chunk 后设置 `Event`,断言抛取消错误、目标文件和 `.part` 都不存在。
|
||||
9. 下载最后一个 chunk 后、`os.replace()`/job 返回前取消,断言 session 的阶段门禁不会产出可安装结果。
|
||||
10. 读空闲超时为有限值,并被转换为 `AppUpdateError`;避免退出永久等待。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
最小、低风险的提交顺序是:先加入 session 的 operation token、`Event`、finished 门和 controller `request_quit()`;再加对话框按钮;最后把 read timeout 改为有限值并补阶段取消检查。只有当“退出后 result 绝不会进入 `apply_downloaded_update()`”和“quit 只发生在 finished 以后”两条测试通过,才应开放强制更新下载中的退出按钮。
|
||||
|
||||
## 审阅说明
|
||||
|
||||
- 本次依据工作树当前内容只读分析;生产代码与现有测试均未修改。
|
||||
- 根目录 `AGENTS.md` 已读取;仓库当前不存在其中提到的 `.trellis/` 目录,因此没有额外的 workflow/spec 文件可读。
|
||||
- 工作树原本已有多项未提交修改;本次只新增本研究文档,没有覆盖或回退任何现有改动。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 强制更新“退出软件”Windows 正式包重建与验证结果
|
||||
|
||||
- 执行日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 总体结果:成功
|
||||
- 生产源码/测试修改:无(本次仅重建发布产物并新增本报告)
|
||||
- 工作树说明:执行前 `src/doctor_workstation/__init__.py` 及其他生产/测试文件已有用户修改;本次全部保留,未覆盖或回退
|
||||
- Trellis:仓库根目录不存在 `.trellis/`,因此无 Trellis 工作流文件可继续读取
|
||||
|
||||
## 发布配置核对
|
||||
|
||||
重建前后均读取 `src/doctor_workstation/__init__.py`,确认:
|
||||
|
||||
```powershell
|
||||
rg -n "(__version__|DEBUG_MODE)" src/doctor_workstation/__init__.py
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 输出:`6:__version__ = "1.2.0"`、`10:DEBUG_MODE = False`
|
||||
|
||||
- `__version__ = "1.2.0"`
|
||||
- `DEBUG_MODE = False`
|
||||
|
||||
最终 frozen 主程序的 Windows 版本资源也复核为:
|
||||
|
||||
- `FileVersion = 1.2.0`
|
||||
- `ProductVersion = 1.2.0`
|
||||
|
||||
最终安装器的 `ProductVersion = 1.2.0`。
|
||||
|
||||
## 正式包重建
|
||||
|
||||
执行命令:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`Windows package complete.`
|
||||
- Python 构建环境:PyInstaller 6.22.0 / Python 3.12.12
|
||||
- 视频伴侣构建:通过,Vite 转换 43 个模块
|
||||
- frozen Qt 多媒体文件门禁:通过
|
||||
- frozen Qt 多媒体 smoke(`--media-smoke-test`,隔离 offscreen 环境):通过
|
||||
- frozen 应用入口 smoke(`--smoke-test`,隔离 offscreen 环境):通过
|
||||
- ZIP:7-Zip 报告 `Everything is Ok`
|
||||
- 安装器:Inno Setup 6.7.3 编译成功,`Successful compile (140.609 sec)`
|
||||
|
||||
构建期间有非阻断警告:Vite 报告单个压缩后 chunk 超过 500 kB;PyInstaller 报告一个 Qt QML 插件二进制缺失及若干 Windows 系统 DLL 解析警告。脚本内置的 frozen 文件门禁与两个 smoke gate 均通过,构建最终退出码为 `0`。
|
||||
|
||||
## 最终安装器冒烟验证
|
||||
|
||||
执行命令:
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer 'D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe'
|
||||
```
|
||||
|
||||
- 退出码:`0`
|
||||
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离验证目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-55b6feebaa134d21ba0637549d55355b`
|
||||
- 覆盖范围:安装器品牌图标、静默安装、已安装 EXE 存在性与启动 smoke、静默卸载、卸载后主程序残留检查
|
||||
|
||||
## 产物与独立校验
|
||||
|
||||
使用以下命令模式逐项独立复核,退出码为 `0`:
|
||||
|
||||
```powershell
|
||||
Get-Item -LiteralPath <产物绝对路径>
|
||||
Get-FileHash -LiteralPath <产物绝对路径> -Algorithm SHA256
|
||||
```
|
||||
|
||||
| 产物 | 绝对路径 | 字节数 | SHA-256 |
|
||||
| --- | --- | ---: | --- |
|
||||
| 正式安装器 EXE | `D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,907,874 | `71B4ADF7B431A3BBC53818AA2D68089977D777D18F0969C930A0A6B1777F2BA9` |
|
||||
| 发布 ZIP | `D:\web\zyt\app\dist\DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,817,779 | `D41C0794568A59FB57A083425750F4A2CDBED9962B13D9EBBD42FC2EEF374604` |
|
||||
| frozen 主程序 EXE | `D:\web\zyt\app\dist\DoctorWorkstation\DoctorWorkstation.exe` | 6,300,842 | `550BF4434C6B2BEEB9DDA5A78107FB392C4FF442A6D62D01803A51197E59A893` |
|
||||
| 校验清单 | `D:\web\zyt\app\dist\SHA256SUMS.txt` | 220 | `9BD4CB7CD0A1FA7FBBED0E6D38BE2A4F98898224A6B97E0E38F35701CB8B8A20` |
|
||||
|
||||
`dist\SHA256SUMS.txt` 内容:
|
||||
|
||||
```text
|
||||
71B4ADF7B431A3BBC53818AA2D68089977D777D18F0969C930A0A6B1777F2BA9 DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
|
||||
D41C0794568A59FB57A083425750F4A2CDBED9962B13D9EBBD42FC2EEF374604 DoctorWorkstation-Windows-x64-1.2.0.zip
|
||||
```
|
||||
|
||||
独立复算的安装器与 ZIP 哈希均与脚本末尾输出及 `SHA256SUMS.txt` 逐字符一致。
|
||||
@@ -0,0 +1,182 @@
|
||||
# 强制更新“退出软件”回归测试设计
|
||||
|
||||
## 结论
|
||||
|
||||
建议在 `app/tests/test_app_update_ui.py` 把“退出软件”作为强制更新对话框的独立显式动作测试,不把它等同于关闭窗口或 `reject()`:
|
||||
|
||||
- 强制更新在初始状态和下载中状态都显示且启用“退出软件”。
|
||||
- 点击只发出一次专用信号(下文假定为 `exit_requested`);对话框自身不静默 `reject()`。
|
||||
- 普通更新仍显示“稍后提醒”,不显示“退出软件”,原有 `update_deferred` + `reject()` 行为不变。
|
||||
- 强制更新无论初始还是下载中,标题栏关闭和 Escape 都继续被拦截;用户只能通过明确的“退出软件”动作退出。
|
||||
|
||||
生产实现若采用独立控件,建议公开 `exit_button`;这比把强制退出语义塞进现有 `later_button` 更容易测试,也避免 `_defer()` 同时承担“稍后”和“退出”两种相反行为。若实现选择复用 `later_button`,下述断言可把 `exit_button` 替换为该控件,但至少应保留独立的 `exit_requested` 信号。
|
||||
|
||||
## 当前覆盖缺口
|
||||
|
||||
当前 `test_app_update_ui.py` 有以下相关覆盖:
|
||||
|
||||
- `test_optional_update_dialog_allows_later` 只断言普通更新的稍后按钮可见和更新文案,未点击按钮,也未验证 `update_deferred`。
|
||||
- `test_forced_update_dialog_hides_defer_and_blocks_escape` 断言稍后按钮隐藏,并用 `dialog.close()` 验证强更无法关闭;尽管测试名写有 `blocks_escape`,测试体没有发送 Escape。
|
||||
- 没有覆盖 `set_busy(True)`。当前 `set_busy()` 会禁用 `later_button`,因此若复用该按钮显示“退出软件”,下载中会直接回归为不可退出。
|
||||
- 没有覆盖退出信号的次数,也没有证明显式退出动作不会被当成普通 `reject()`。
|
||||
|
||||
现有 service 测试 `app/tests/test_app_update.py` 主要覆盖 offer 解析、下载、校验与更新应用,不适合承载 Qt 按钮和键盘行为;这些回归应继续留在 `test_app_update_ui.py`。
|
||||
|
||||
## 建议测试矩阵
|
||||
|
||||
| offer | 对话框状态 | 稍后按钮 | 退出按钮 | 更新按钮 | 取消下载 | 关闭 / Escape |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 强制 | 初始 | 隐藏 | 显示、启用 | 启用 | 隐藏 | 均拦截 |
|
||||
| 强制 | 下载中 | 隐藏 | 显示、启用 | 禁用 | 隐藏 | 均拦截 |
|
||||
| 普通 | 初始 | 显示、启用,文案“稍后提醒” | 隐藏 | 启用 | 隐藏 | 允许 |
|
||||
| 普通 | 下载中 | 保持现有禁用语义 | 隐藏 | 禁用 | 显示 | `closeEvent` 目前拦截;本次不要顺带定义 Escape 新语义 |
|
||||
|
||||
最后一格存在现有 Qt 行为不对称:普通更新下载中时 `closeEvent()` 会拦截标题栏关闭,但 `keyPressEvent()` 仅专门拦截强制更新的 Escape。除非产品需求明确要求调整普通更新下载中的 Escape,否则本次回归不要无意固化或改变该行为。
|
||||
|
||||
## 推荐测试拆分
|
||||
|
||||
测试文件增加:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
```
|
||||
|
||||
### 1. 强制更新在初始和下载中均可显式退出
|
||||
|
||||
用参数化覆盖两个状态,避免只测初始渲染:
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"])
|
||||
def test_forced_update_exit_action_stays_available(
|
||||
busy: bool,
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
dialog.show()
|
||||
if busy:
|
||||
dialog.set_busy(True)
|
||||
dialog.show_download_progress(256, 1024)
|
||||
app.processEvents()
|
||||
|
||||
assert not dialog.later_button.isVisible()
|
||||
assert dialog.exit_button.isVisible()
|
||||
assert dialog.exit_button.isEnabled()
|
||||
assert dialog.exit_button.text() == "退出软件"
|
||||
assert dialog.update_button.isEnabled() is (not busy)
|
||||
assert not dialog.cancel_button.isVisible()
|
||||
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
```
|
||||
|
||||
这里必须在 `set_busy(True)` 后断言,才能捕获“统一禁用底部按钮”导致强制更新无法退出的回归。调用 `show_download_progress()` 同时让测试更贴近真实 `_start_install()` 顺序:先 `set_busy(True)`,再进入下载进度态。
|
||||
|
||||
### 2. 点击退出按钮只发一次专用信号
|
||||
|
||||
```python
|
||||
def test_forced_update_exit_button_emits_request(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
requested: list[bool] = []
|
||||
dialog.exit_requested.connect(lambda: requested.append(True))
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
|
||||
dialog.exit_button.click()
|
||||
|
||||
assert requested == [True]
|
||||
assert dialog.isVisible()
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
```
|
||||
|
||||
`assert dialog.isVisible()` 有意证明按钮在 dialog 层只表达“请求退出软件”,而不是绕开应用级清理流程直接 `reject()`。真正退出应由 `AppUpdateSession`/应用层的 slot 完成。若最终设计明确由 dialog 自身关闭,则删除这一条,但仍要保留信号次数断言。
|
||||
|
||||
还可把该测试参数化为初始/下载中并在两种状态点击;若测试数量需要控制,则第一个参数化测试负责可用性,第二个测试负责一次信号已足够定位大部分回归。
|
||||
|
||||
### 3. 普通更新仍是“稍后提醒”
|
||||
|
||||
建议增强现有 optional 测试,而不是只检查可见性:
|
||||
|
||||
```python
|
||||
def test_optional_update_dialog_keeps_defer_action(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
dialog = AppUpdateDialog(_offer(force=False))
|
||||
deferred: list[bool] = []
|
||||
dialog.update_deferred.connect(lambda: deferred.append(True))
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
|
||||
assert dialog.later_button.isVisible()
|
||||
assert dialog.later_button.isEnabled()
|
||||
assert dialog.later_button.text() == "稍后提醒"
|
||||
assert not dialog.exit_button.isVisible()
|
||||
|
||||
dialog.later_button.click()
|
||||
|
||||
assert deferred == [True]
|
||||
assert not dialog.isVisible()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
```
|
||||
|
||||
这条会防止实现“退出软件”时误把普通更新的次按钮文案、信号或关闭行为一起改掉。
|
||||
|
||||
### 4. 强制更新明确拦截关闭与 Escape
|
||||
|
||||
把当前名不副实的测试改成真实事件测试,并参数化初始/下载中:
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"])
|
||||
def test_forced_update_only_allows_explicit_exit(
|
||||
busy: bool,
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
dialog.show()
|
||||
if busy:
|
||||
dialog.set_busy(True)
|
||||
dialog.show_download_progress(256, 1024)
|
||||
app.processEvents()
|
||||
|
||||
dialog.close()
|
||||
app.processEvents()
|
||||
assert dialog.isVisible()
|
||||
|
||||
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||
app.processEvents()
|
||||
assert dialog.isVisible()
|
||||
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
```
|
||||
|
||||
行为断言比检查 window flags 更稳定:不同平台可能规范化窗口标志,但 `closeEvent()`/`keyPressEvent()` 是否真正保留对话框才是用户可观察契约。
|
||||
|
||||
## 会话层边界
|
||||
|
||||
dialog 信号测试只能证明点击请求已发出,不能证明应用最终退出。生产接线还应满足:
|
||||
|
||||
- `AppUpdateSession._present()` 连接 `exit_requested` 到一个应用级退出入口。
|
||||
- 下载中退出时先设置取消标志,使 `download_package(..., cancelled=...)` 尽快结束并清理 `.part` 文件,再请求 `QApplication.quit()`;否则全局线程池任务可能拖延进程退出。
|
||||
- 应用级退出必须走既有 `QApplication.aboutToQuit -> ApplicationController.shutdown`,不要从 dialog 直接调用 `sys.exit()` 或跳过资源清理。
|
||||
|
||||
若实现为可替换的 session 方法(例如 `_request_exit()`),可另补一个 session 单测,mock/monkeypatch 该方法后验证 dialog 信号接线;不要在 pytest 共享的真实 `QApplication` 上直接调用 `quit()`,以免污染同进程后续 UI 测试。本次题目明确要求的四项回归,以上 dialog 测试已经可以独立、稳定覆盖。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 已读取根 `AGENTS.md`;工作树中不存在 `.trellis/workflow.md` 和 `.trellis/spec/`,因此无法应用额外 Trellis 分层规范。
|
||||
- 只读运行现有基线:`app/.venv/Scripts/python.exe -m pytest tests/test_app_update_ui.py -q`,结果 `4 passed`。
|
||||
- 本文之外未修改生产代码或测试代码;工作树中原有的 `app_update.py` 与 `test_app_update_ui.py` 未提交改动均已保留。
|
||||
@@ -0,0 +1,52 @@
|
||||
# Windows 1.2.0 正式打包结果
|
||||
|
||||
- 打包日期:2026-08-28(Asia/Shanghai)
|
||||
- 工作目录:`D:\web\zyt\app`
|
||||
- 版本源:`src/doctor_workstation/__init__.py`
|
||||
- 确认版本:`1.2.0`
|
||||
- 执行命令:`powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1`
|
||||
- 脚本退出码:`0`
|
||||
- 总体结果:成功
|
||||
|
||||
## 发布产物核对
|
||||
|
||||
| 文件 | 大小(字节) | 大小(MiB) | SHA-256 |
|
||||
| --- | ---: | ---: | --- |
|
||||
| `dist/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,914,042 | 155.367 | `1D74966B73005B30ECB4EB7E2101BECB57BB8C91D0981392ACD00F6800D37E7D` |
|
||||
| `dist/DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,810,707 | 220.118 | `A56057D95EFCE70E58FA54264CAC17A16E2F5BDB842FE515A5A153B073A4AFE9` |
|
||||
| `dist/SHA256SUMS.txt` | 220 | 0.000 | `08BD69A21BACEC86B8269ED665C50CDAF4D3ED6D252CEC4DD7BEE30E73573175` |
|
||||
|
||||
以上三个文件均存在。独立使用 `Get-FileHash -Algorithm SHA256` 重新计算 EXE 和 ZIP 哈希,结果与打包脚本末尾输出及 `SHA256SUMS.txt` 中的两条记录逐项一致。
|
||||
|
||||
## 脚本验证结果
|
||||
|
||||
- 锁定的 Python 构建依赖检查通过。
|
||||
- 视频伴侣依赖安装成功,`vue-tsc --noEmit && vite build` 成功;Vite 共转换 43 个模块。
|
||||
- PyInstaller 6.22.0 / Python 3.12.12 构建成功,输出 `dist/DoctorWorkstation`。
|
||||
- `Frozen Qt multimedia file gate passed.`
|
||||
- `Frozen Qt multimedia smoke gate passed (--media-smoke-test, isolated offscreen mode).`
|
||||
- `Frozen application entry smoke gate passed (--smoke-test, isolated offscreen mode).`
|
||||
- 7-Zip 创建 ZIP 成功,输出 `Everything is Ok`;归档包含 180 个目录、3,011 个文件。
|
||||
- Inno Setup 6.7.3 编译成功,输出 `Successful compile (165.062 sec)`。
|
||||
- 正式安装包、ZIP 和校验清单均生成,脚本最终退出码为 0。
|
||||
|
||||
## 非阻断警告
|
||||
|
||||
- Vite 报告单个压缩后 chunk 超过 500 kB,仅为体积优化提示。
|
||||
- PyInstaller 报告一个 Qt QML 插件二进制缺失,以及若干 Windows 系统 DLL 解析警告;这些警告未阻断构建,且脚本内置的多媒体文件门禁、多媒体离屏 smoke test 和应用入口 smoke test 全部通过。
|
||||
|
||||
## 真实安装器冒烟测试
|
||||
|
||||
- 执行命令:`powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer 'D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe'`
|
||||
- 脚本退出码:`0`
|
||||
- 脚本最终结果:`Installer icon/install/start/uninstall smoke test passed.`
|
||||
- 隔离测试目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-ac84581babe94e28a0d0ffab63dd0b5f`
|
||||
|
||||
### 分阶段核对
|
||||
|
||||
- 静默安装:通过。安装器以 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER` 运行到隔离目录;`setup.log` 记录 `Installation process succeeded.` 和 `Need to restart Windows? No`。
|
||||
- 安装内容:通过。脚本在启动前确认 `DoctorWorkstation.exe` 与 `unins000.exe` 均存在。
|
||||
- 图标:通过。脚本分别提取安装器、已安装主程序及卸载器的 32×32 关联图标并计算 SHA-256;主程序与卸载器图标均和安装器品牌图标一致,否则脚本会失败。
|
||||
- 启动 smoke:通过。已安装主程序在隔离配置、日志目录与 `QT_QPA_PLATFORM=offscreen` 环境下执行 `--smoke-test`,退出码为 0;应用日志记录 `doctor workstation starting`,未记录异常堆栈。
|
||||
- 静默卸载:通过。卸载器以 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART` 运行,退出码为 0;`uninstall.log` 记录 `Uninstallation process succeeded.`、`Removed all? Yes` 和 `Need to restart Windows? No`。
|
||||
- 残留检查:通过。卸载完成后独立确认隔离安装目录、`DoctorWorkstation.exe`、`unins000.exe`、当前用户开始菜单快捷方式及本次当前用户卸载注册表键均不存在。隔离测试根目录按设计保留,仅包含安装/卸载日志和隔离应用日志,便于审计。
|
||||
@@ -0,0 +1,233 @@
|
||||
# 医生工作站版本发布配置与更新 API 诊断
|
||||
|
||||
## 结论
|
||||
|
||||
当前仓库中,admin、server、app 三端的**现行契约是一致的**,但字段名不是扁平的
|
||||
`package_type` / `download_url`。正式 wire contract 是:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"data": {
|
||||
"has_update": true,
|
||||
"force": false,
|
||||
"enabled": true,
|
||||
"current_version": "1.1.0",
|
||||
"latest_version": "1.2.0",
|
||||
"min_version": "",
|
||||
"title": "...",
|
||||
"notes": "...",
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"package": {
|
||||
"url": "https://.../DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||
"sha256": "64 位十六进制值",
|
||||
"size": 123,
|
||||
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||
"type": "inno_setup"
|
||||
},
|
||||
"can_install": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- `latest_version` 是 `data` 下的顶层字段。
|
||||
- `platform`、`arch` 是检测请求参数,同时在 `data` 下回显规范化后的值;它们不保存在发布配置中。
|
||||
- 安装包对象叫 `package`。
|
||||
- 安装包类型叫 `package.type`,不是顶层或同级的 `package_type`。
|
||||
- 下载地址叫 `package.url`,不是 `download_url`。
|
||||
- 哈希叫 `package.sha256`。
|
||||
- 如果截图或线上响应实际出现的是扁平 `package_type`、`download_url`,当前 app 不会读取这些别名。这不是当前仓库 server 的输出,优先怀疑线上后端/代理为另一版本或只部署了部分提交。
|
||||
|
||||
当前工作区还存在一个已确认的发布物版本风险:运行时版本源已经是 `1.2.0`(`app/src/doctor_workstation/__init__.py:5-6`),但 `app/dist/SHA256SUMS.txt:1-2` 只登记了 `1.1.0` 的 EXE/ZIP,且本地 `DoctorWorkstation.exe` 文件版本也是 `1.1.0`。如果管理端把 `latest_version` 设为 `1.2.0`,却填入当前 `1.1.0` 安装包,客户端安装后仍会报告 `1.1.0`,下次启动会再次发现 `1.2.0`,形成重复升级提示。打包脚本明确从同一个 `__version__` 读取版本(`app/scripts/package_windows.ps1:68-76`),并用它生成 EXE/ZIP 名称(`:169-174`)及两者哈希(`:234-244`);发布前必须重新生成 `1.2.0` 产物。
|
||||
|
||||
> 本轮只读诊断没有修改生产代码。根目录 `AGENTS.md` 已读取;仓库中没有 `.trellis/` 目录。
|
||||
|
||||
## 1. Windows 64 位安装包字段如何进入保存请求
|
||||
|
||||
管理端类型定义把 Windows 包放在 `packages.windows_x64`,每个包固定包含
|
||||
`url`、`sha256`、`size`、`filename`、`type`;其中 `type` 只允许
|
||||
`archive | inno_setup`(`admin/src/api/setting/desktop_workstation.ts:3-24`)。保存接口是
|
||||
`POST /setting.desktop_workstation/setConfig`(同文件 `:27-34`)。
|
||||
|
||||
页面的 Windows 区块来自平台键 `windows_x64`(`admin/src/views/setting/desktop_workstation/index.vue:196-224`),默认类型是 `inno_setup`(`:198-218`)。UI 字段与请求体的对应关系如下:
|
||||
|
||||
| 截图/UI 字段 | 保存请求字段 | 证据 |
|
||||
|---|---|---|
|
||||
| Windows 64 位安装包 | `packages.windows_x64` | `index.vue:221-224, 339-343` |
|
||||
| 安装包类型 | `packages.windows_x64.type`,EXE 为 `inno_setup` | `index.vue:96-104, 305-309` |
|
||||
| 安装包地址 | `packages.windows_x64.url` | `index.vue:113-123, 318-324` |
|
||||
| SHA-256 | `packages.windows_x64.sha256` | `index.vue:144-150, 292-315` |
|
||||
| 文件名 | `packages.windows_x64.filename` | `index.vue:152-162, 300-304` |
|
||||
| 文件大小(字节) | `packages.windows_x64.size` | `index.vue:164-174, 300-304` |
|
||||
| 最新版本号 | 顶层 `latest_version` | `index.vue:34-42, 330-344` |
|
||||
|
||||
选择文件后,页面在浏览器本地读取原始文件名和字节数,`.exe` 自动切换为
|
||||
`inno_setup`,并用 Web Crypto 计算 SHA-256(`index.vue:292-315`)。上传成功后只把上传接口返回的 `data.uri`(次选 `data.url`)写入包的 `url`(`:318-328`)。最终点击保存时,页面显式组装三个平台的完整 `packages` 对象,而不是上传后自动发布(`:330-345`)。
|
||||
|
||||
虽然 API 封装调用写成 `request.post({ params })`,拦截器会在 POST 且没有 `data` 时把
|
||||
`params` 移入 JSON body(`admin/src/utils/request/index.ts:20-38`),所以 PHP 收到的是上述嵌套 JSON,而不是查询字符串。
|
||||
|
||||
## 2. 后端如何校验和持久化
|
||||
|
||||
控制器用 POST 校验器接收请求,再交给逻辑层保存(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:38-46`)。
|
||||
|
||||
正常管理端保存时的关键约束:
|
||||
|
||||
- 版本号需是纯数字分段格式(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:48-57`)。
|
||||
- 包类型只允许 `archive` / `inno_setup`,且 `inno_setup` 只允许 Windows x64(`:118-137`)。
|
||||
- Inno Setup 若填写文件名,必须以 `.exe` 结尾(`:138-140`)。
|
||||
- 显式 `http://` 的 Inno Setup 地址会被拒绝(`:141-143`)。
|
||||
- 外部 http(s) 地址必须带 SHA-256,SHA-256 若非空必须是 64 位十六进制(`:144-152`)。
|
||||
- 文件名最长 180 字节,size 必须是非负数(`:153-158`)。
|
||||
|
||||
逻辑层将标量分别保存为配置项,把所有平台包作为一个 `packages` 配置项保存:
|
||||
|
||||
- `enabled`
|
||||
- `latest_version`
|
||||
- `min_version`
|
||||
- `force_update`
|
||||
- `title`
|
||||
- `notes`
|
||||
- `packages`
|
||||
|
||||
证据为 `server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:45-55`。其中
|
||||
`latest_version` / `min_version` 会被正规化为三段版本,包则逐平台正规化
|
||||
`url/sha256/size/filename/type`(`:180-205, 226-254`)。
|
||||
|
||||
`ConfigService::set()` 对数组执行 `json_encode(..., JSON_UNESCAPED_UNICODE)` 后写入 Config 模型的 `value` 字段;标量直接写入(`server/app/common/service/ConfigService.php:32-50`)。因此数据库中的逻辑形态是:
|
||||
|
||||
```text
|
||||
type = desktop_workstation, name = latest_version, value = "1.2.0"
|
||||
type = desktop_workstation, name = packages, value =
|
||||
{"windows_x64":{"url":"...","sha256":"...","size":...,"filename":"...","type":"inno_setup"},...}
|
||||
```
|
||||
|
||||
读取时,`ConfigService::get()` 会对合法 JSON 自动 `json_decode(..., true)`(同文件
|
||||
`:65-85`),所以 `packages` 回到 PHP 数组。保存 URL 时会去掉当前站点/当前存储域名,读取给 API 时再补回绝对域名(`DesktopWorkstationLogic.php:231-252, 261-280`;`server/app/common/service/FileService.php:42-59, 69-78`)。本地 `uploads/...` 文件还会在缺失/无效时由 server 计算哈希、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
|
||||
|
||||
## 3. 检测 API 如何选择包和序列化响应
|
||||
|
||||
app 请求的端点是 `setting.desktop_workstation/check`(`app/src/doctor_workstation/services/app_update.py:29-35`)。控制器把 `check` 放进免登录列表(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:26-29`),并把 GET 参数直接交给逻辑层(`:48-55`)。
|
||||
|
||||
客户端发送:
|
||||
|
||||
```text
|
||||
current_version=<当前运行时版本>&platform=windows&arch=x64
|
||||
```
|
||||
|
||||
证据为 `app_update.py:217-243`。server 将 `windows/win/win32/win64` 统一成
|
||||
`windows`,把 `amd64/x86_64/x64` 统一成 `x64`,拼成配置键
|
||||
`windows_x64`(`DesktopWorkstationLogic.php:125-153`)。也就是说,`platform` / `arch`
|
||||
不是管理端发布字段,而是由客户端运行环境发给检测接口、用于选择
|
||||
`packages.windows_x64` 的请求维度。
|
||||
|
||||
server 的检测响应由 `evaluate()` 直接组成(`DesktopWorkstationLogic.php:75-103`):
|
||||
|
||||
- `latest_version` 来自已保存的配置并正规化。
|
||||
- `platform`、`arch` 是请求值正规化后的回显。
|
||||
- `package` 是匹配平台的单个包,只有 `url` 和 `sha256` 都非空才返回,否则为 `null`。
|
||||
- 包对象的键为 `url/sha256/size/filename/type`(`:272-280`)。
|
||||
- `can_install = has_update && url 非空 && sha256 非空`。
|
||||
- `force` 只有存在更新、命中强制策略并且有可安装包时才为 true。
|
||||
|
||||
控制器的 `data()` 最终封装为 `{code, show, msg, data}`(`server/app/common/controller/BaseLikeAdminController.php:50-60`;`server/app/common/service/JsonService.php:71-91`)。app 的 `ApiClient` 对 `code == 1` 返回 envelope 中的 `data`(`app/src/doctor_workstation/services/api_client.py:504-542`),因此 `parse_update_offer()` 收到的就是上面列出的 `data` 对象,而不是整个 envelope。
|
||||
|
||||
## 4. 与 app 客户端契约逐字段对照
|
||||
|
||||
| 语义 | server 实际输出 | app 实际读取 | 是否一致 |
|
||||
|---|---|---|---|
|
||||
| 最新版本 | `latest_version` | `data.get("latest_version")` | 一致(`DesktopWorkstationLogic.php:95`; `app_update.py:174-181`) |
|
||||
| 平台 | `platform` | `data.get("platform")` | 一致(server `:99`; app `:147-150, 176-183`) |
|
||||
| 架构 | `arch` | `data.get("arch")` | 一致(server `:100`; app `:147-150, 176-183`) |
|
||||
| 安装包 | `package` object/null | `data.get("package")` | 一致(server `:101`; app `:151-153`) |
|
||||
| 下载地址 | `package.url` | `package_payload.get("url")` | 一致(server `:275`; app `:154, 166-173`) |
|
||||
| SHA-256 | `package.sha256` | `package_payload.get("sha256")` | 一致(server `:276`; app `:155, 184-188`) |
|
||||
| 包类型 | `package.type` | `package_payload.get("type")` | 一致(server `:279`; app `:157-173`) |
|
||||
| 文件大小 | `package.size` | `package_payload.get("size")` | 一致(server `:277`; app `:158-173`) |
|
||||
| 文件名 | `package.filename` | `package_payload.get("filename")` | 一致(server `:278`; app `:156-173`) |
|
||||
| 可安装 | `can_install` | `data.get("can_install")` + 客户端二次校验 | 一致但客户端更严格(server `:85-102`; app `:184-200`) |
|
||||
|
||||
客户端只认可 `archive` / `inno_setup`,且 Inno 只允许 Windows;它还要求 SHA-256
|
||||
严格为 64 位小写十六进制、响应平台/架构必须与请求一致(`app_update.py:162-200`)。对于
|
||||
`inno_setup`,下载 URL 还必须是 HTTPS(localhost 调试例外),随后下载内容要通过 SHA-256、size、`.exe` 后缀和 PE `MZ` 头校验(`:287-300, 360-397`)。UI 根据 `package.type` 分流:`inno_setup` 直接走 Windows 安装器,`archive` 则按 ZIP 解压(`app/src/doctor_workstation/ui/dialogs/app_update.py:423-449`)。
|
||||
|
||||
现有自动化也明确锁定了这个嵌套契约:server contract test 要求 Windows 包返回
|
||||
`package.type == inno_setup`(`server/tests/DesktopWorkstationUpdateContractTest.php:34-62`);app test 用
|
||||
`package.{url,sha256,size,filename,type}` 构造响应并验证接收(`app/tests/test_app_update.py:66-90`)。本轮实跑:
|
||||
|
||||
```text
|
||||
php server/tests/DesktopWorkstationUpdateContractTest.php PASS
|
||||
uv run pytest app/tests/test_app_update.py -q PASS (21 tests)
|
||||
```
|
||||
|
||||
## 5. 根因候选(按优先级)
|
||||
|
||||
### A. `latest_version` 与实际安装包版本不一致(当前工作区已有直接证据)
|
||||
|
||||
当前版本源是 `1.2.0`,但现有 EXE/ZIP、SHA256SUMS 和冻结 exe 都是 `1.1.0`。如果截图中的管理端配置已经把最新版本发布为 `1.2.0`,当前 `1.1.0` 包不能作为它的安装包。表现为下载、安装可能成功,但应用重启后仍是旧版本并再次提示更新。
|
||||
|
||||
### B. 线上响应使用 `package_type` / `download_url` 扁平字段
|
||||
|
||||
当前 app 没有这两个 wire key 的兼容读取,仓库内也没有生成它们的 server 代码。如果截图中的实际网络响应是例如:
|
||||
|
||||
```json
|
||||
{"latest_version":"1.2.0","package_type":"inno_setup","download_url":"...","sha256":"..."}
|
||||
```
|
||||
|
||||
app 会因为没有 `package.url` 而得到 `package=None`,最终 `can_install=false`;即使把包放在
|
||||
`package` 中但只给 `package_type`,客户端也会默认当成 `archive`,对 EXE 执行 ZIP 解压并失败。该情形应视为明确的协议不一致。
|
||||
|
||||
### C. admin / server / app 部署版本分叉,或 PHP OPcache 未刷新
|
||||
|
||||
Git 历史显示提交 `43e5411b6a8d2e625140c5dca8ddeb8492ba7daa` 才同步把
|
||||
`type=inno_setup` 加入 admin、server 和 app。它之前的 server 会在保存/读取包时丢掉 `type`。
|
||||
因此“管理页面已有 Inno Setup 下拉框,但 check 响应没有 `package.type`”最符合部分部署或旧 PHP 代码仍在运行,而不是当前源码的逻辑错误。
|
||||
|
||||
### D. SHA-256 非空但无效,server 与 app 的可安装判定强度不同
|
||||
|
||||
`evaluate()` 只检查 URL/哈希非空;app 要求恰好 64 位十六进制。通过正常管理端保存不会发生,因为 validator 会拦截;但旧数据、手工改库、另一服务写入配置时,可能出现 server 返回 `can_install=true`、app 最终降级为不可安装。
|
||||
|
||||
### E. 相对上传路径在 server 输出时被扩成 HTTP
|
||||
|
||||
validator 只对输入字符串显式以 `http://` 开头的 Inno URL 拒绝;`uploads/...` 相对路径可通过。响应时 `FileService::getFileUrl()` 按 `request()->domain()` 补域名。如果生产位于 HTTPS 反向代理后但 PHP 未正确识别代理协议,响应可能变成 `http://...`。app 会安全地拒绝自动执行这个 EXE。若截图中的 `package.url` 为 HTTP,应核对反向代理的 forwarded proto / trusted proxy 配置,而不是放宽客户端安全校验。
|
||||
|
||||
## 6. 建议修复与验证顺序
|
||||
|
||||
1. **先重新打 1.2.0 正式包再发布。** 保持 `app/src/doctor_workstation/__init__.py`、EXE 的 FileVersion/ProductVersion、安装包文件名、管理端 `latest_version` 四者全部为 `1.2.0`;从新生成的 `SHA256SUMS.txt` 复制 EXE 对应哈希,不要复用 1.1.0 的值。
|
||||
2. **直接抓线上 check 响应。** 用与 app 一样的参数请求:
|
||||
`GET /adminapi/setting.desktop_workstation/check?current_version=1.1.0&platform=windows&arch=x64`。确认有效数据位于 `data`,并且字段精确为 `data.package.url/type/sha256`。
|
||||
3. **如果看到 `download_url/package_type`,统一契约。** 首选修 server 采用当前仓库的嵌套结构并整体部署;若必须兼容历史服务,可在 app 解析层短期接受别名,但 canonical 输出仍应只有 `package.{url,type,...}`,并补契约测试。
|
||||
4. **如果 `package.type` 缺失,做完整部署并清 OPcache。** 同时部署 admin 静态资源、PHP controller/logic/validator 和新 app;不要只替换管理页面。
|
||||
5. **核验 URL 与哈希。** `package.url` 必须是客户端可达的 HTTPS 绝对地址;下载文件 SHA-256 必须与 `package.sha256` 完全一致,size 若填写也必须一致。
|
||||
6. **补一条跨端端到端 fixture。** 固化一个 Windows `inno_setup` 响应,既让 PHP `evaluate()` 产出 JSON,也让 Python `parse_update_offer()` 消费同一 fixture;另外增加扁平别名必须被拒绝(或在决定兼容后明确接受)的测试,避免字段名再次漂移。
|
||||
|
||||
## 最小正确发布样例
|
||||
|
||||
管理端保存体中的 Windows 部分:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": 1,
|
||||
"latest_version": "1.2.0",
|
||||
"min_version": "",
|
||||
"force_update": 0,
|
||||
"title": "医生工作站 1.2.0",
|
||||
"notes": "...",
|
||||
"packages": {
|
||||
"windows_x64": {
|
||||
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||
"sha256": "<新 1.2.0 EXE 的 64 位 SHA-256>",
|
||||
"size": 0,
|
||||
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
|
||||
"type": "inno_setup"
|
||||
},
|
||||
"macos_arm64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"},
|
||||
"macos_x64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不要把同一内容改名为顶层 `download_url` / `package_type`;当前 app 不消费该形态。
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Read-only-ish timing probe for the desktop update commit sequence.
|
||||
|
||||
The probe imports production code and replaces only its external download/apply
|
||||
edges in memory. It does not modify production sources or existing tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QEvent, QObject, QThreadPool, QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import app_update as update_service
|
||||
from doctor_workstation.services.app_update import (
|
||||
PACKAGE_TYPE_INNO_SETUP,
|
||||
UpdateOffer,
|
||||
UpdatePackage,
|
||||
)
|
||||
from doctor_workstation.ui.dialogs import app_update as update_ui
|
||||
|
||||
|
||||
def _record(events: list[dict[str, Any]], name: str, started: float) -> None:
|
||||
events.append(
|
||||
{
|
||||
"event": name,
|
||||
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||
"thread_id": threading.get_ident(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def exercise_session(iterations: int = 25) -> dict[str, Any]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
main_thread = threading.get_ident()
|
||||
original_edges = {
|
||||
"is_frozen_install": update_ui.is_frozen_install,
|
||||
"frozen_install_root": update_ui.frozen_install_root,
|
||||
"download_package": update_ui.download_package,
|
||||
"apply_downloaded_update": update_ui.apply_downloaded_update,
|
||||
}
|
||||
failures: list[dict[str, Any]] = []
|
||||
samples: list[list[dict[str, Any]]] = []
|
||||
collected_signals = 0
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="zyt-update-commit-") as raw_tmp:
|
||||
temp_root = Path(raw_tmp)
|
||||
install_root = temp_root / "installed"
|
||||
install_root.mkdir()
|
||||
(install_root / "DoctorWorkstation.exe").write_bytes(b"MZ")
|
||||
update_ui.is_frozen_install = lambda: True
|
||||
update_ui.frozen_install_root = lambda: install_root
|
||||
|
||||
for index in range(iterations):
|
||||
run_root = temp_root / f"run-{index}"
|
||||
run_root.mkdir()
|
||||
events: list[dict[str, Any]] = []
|
||||
started = time.perf_counter()
|
||||
|
||||
def fake_download(
|
||||
_url: str,
|
||||
destination: Path,
|
||||
*,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
**kwargs: Any,
|
||||
) -> Path:
|
||||
_record(_events, "download_enter", _started)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(b"MZ" + b"probe")
|
||||
progress = kwargs.get("progress")
|
||||
if callable(progress):
|
||||
progress(7, 7)
|
||||
_record(_events, "download_return", _started)
|
||||
return destination
|
||||
|
||||
def fake_apply(
|
||||
_payload: Path,
|
||||
*,
|
||||
package_type: str,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
) -> None:
|
||||
assert package_type == PACKAGE_TYPE_INNO_SETUP
|
||||
_record(_events, "apply_enter", _started)
|
||||
_record(_events, "apply_return", _started)
|
||||
|
||||
update_ui.download_package = fake_download
|
||||
update_ui.apply_downloaded_update = fake_apply
|
||||
|
||||
host = QObject()
|
||||
host.config = SimpleNamespace( # type: ignore[attr-defined]
|
||||
config_dir=run_root,
|
||||
verify_ssl=True,
|
||||
)
|
||||
|
||||
def request_quit(
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
) -> None:
|
||||
_record(_events, "request_quit", _started)
|
||||
|
||||
host.request_quit = request_quit # type: ignore[attr-defined]
|
||||
session = update_ui.AppUpdateSession(host)
|
||||
offer = UpdateOffer(
|
||||
has_update=True,
|
||||
force=True,
|
||||
enabled=True,
|
||||
current_version="1.0.0",
|
||||
latest_version=f"1.0.{index + 1}",
|
||||
min_version="",
|
||||
title="probe",
|
||||
notes="probe",
|
||||
platform="windows",
|
||||
arch="x64",
|
||||
package=UpdatePackage(
|
||||
url="https://example.invalid/DoctorWorkstation-Setup.exe",
|
||||
sha256="a" * 64,
|
||||
size=7,
|
||||
filename="DoctorWorkstation-Setup.exe",
|
||||
type=PACKAGE_TYPE_INNO_SETUP,
|
||||
),
|
||||
can_install=True,
|
||||
)
|
||||
dialog = update_ui.AppUpdateDialog(offer)
|
||||
session.dialog = dialog
|
||||
|
||||
original_finish = session._finish_install
|
||||
original_finished = session._on_install_finished
|
||||
|
||||
def finish_probe(
|
||||
*args: Any,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
_original: Any = original_finish,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_record(_events, "result_slot_enter", _started)
|
||||
_original(*args, **kwargs)
|
||||
_record(_events, "result_slot_return", _started)
|
||||
|
||||
def finished_probe(
|
||||
*args: Any,
|
||||
_events: list[dict[str, Any]] = events,
|
||||
_started: float = started,
|
||||
_original: Any = original_finished,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
_record(_events, "finished_slot_enter", _started)
|
||||
_original(*args, **kwargs)
|
||||
_record(_events, "finished_slot_return", _started)
|
||||
|
||||
session._finish_install = finish_probe # type: ignore[method-assign]
|
||||
session._on_install_finished = finished_probe # type: ignore[method-assign]
|
||||
session._start_install(dialog, offer)
|
||||
signal_ref = weakref.ref(session._active_install_signals)
|
||||
|
||||
deadline = time.perf_counter() + 3.0
|
||||
while time.perf_counter() < deadline:
|
||||
app.processEvents()
|
||||
if any(item["event"] == "request_quit" for item in events):
|
||||
break
|
||||
time.sleep(0.001)
|
||||
QThreadPool.globalInstance().waitForDone(3000)
|
||||
app.processEvents()
|
||||
|
||||
names = [item["event"] for item in events]
|
||||
expected = [
|
||||
"download_enter",
|
||||
"download_return",
|
||||
"result_slot_enter",
|
||||
"apply_enter",
|
||||
"apply_return",
|
||||
"result_slot_return",
|
||||
"finished_slot_enter",
|
||||
"request_quit",
|
||||
"finished_slot_return",
|
||||
]
|
||||
slot_threads = {
|
||||
item["thread_id"]
|
||||
for item in events
|
||||
if item["event"] in {"result_slot_enter", "finished_slot_enter", "request_quit"}
|
||||
}
|
||||
if names != expected or slot_threads != {main_thread}:
|
||||
failures.append(
|
||||
{
|
||||
"iteration": index,
|
||||
"events": events,
|
||||
"main_thread_id": main_thread,
|
||||
}
|
||||
)
|
||||
if index < 3:
|
||||
samples.append(events)
|
||||
|
||||
session.deleteLater()
|
||||
dialog.deleteLater()
|
||||
del session, dialog, host
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
app.processEvents()
|
||||
gc.collect()
|
||||
app.processEvents()
|
||||
if signal_ref() is None:
|
||||
collected_signals += 1
|
||||
finally:
|
||||
update_ui.is_frozen_install = original_edges["is_frozen_install"]
|
||||
update_ui.frozen_install_root = original_edges["frozen_install_root"]
|
||||
update_ui.download_package = original_edges["download_package"]
|
||||
update_ui.apply_downloaded_update = original_edges["apply_downloaded_update"]
|
||||
|
||||
return {
|
||||
"iterations": iterations,
|
||||
"failures": failures,
|
||||
"signals_collected_after_iteration": collected_signals,
|
||||
"main_thread_id": main_thread,
|
||||
"samples": samples,
|
||||
}
|
||||
|
||||
|
||||
def exercise_real_popen() -> dict[str, Any]:
|
||||
if sys.platform != "win32":
|
||||
return {"skipped": f"requires win32, got {sys.platform}"}
|
||||
with tempfile.TemporaryDirectory(prefix="zyt-update-helper-") as raw_tmp:
|
||||
temp_root = Path(raw_tmp)
|
||||
script = temp_root / "probe_helper.ps1"
|
||||
script.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"param(",
|
||||
" [int]$TargetPid, [string]$Installer, [string]$RestartExe,",
|
||||
" [string]$HelperLogFile, [string]$InstallerLogFile",
|
||||
")",
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
"Start-Sleep -Milliseconds 1200",
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-complete'",
|
||||
]
|
||||
),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
installer = temp_root / "Setup.exe"
|
||||
installer.write_bytes(b"MZ")
|
||||
restart = temp_root / "DoctorWorkstation.exe"
|
||||
restart.write_bytes(b"MZ")
|
||||
helper_log = temp_root / "helper.log"
|
||||
fixed_log = temp_root / "fixed.log"
|
||||
installer_log = temp_root / "installer.log"
|
||||
original_script = script.read_text(encoding="utf-8-sig")
|
||||
fixed_log_literal = str(fixed_log).replace("'", "''")
|
||||
script.write_text(
|
||||
original_script.replace(
|
||||
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
"Set-Content -LiteralPath '"
|
||||
+ fixed_log_literal
|
||||
+ "' -Value (\"helper=<{0}> args=<{1}>\" -f $HelperLogFile, ($args -join '|'))\n"
|
||||
+ "Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
|
||||
),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
captured: list[subprocess.Popen[Any]] = []
|
||||
captured_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
real_popen = update_service.subprocess.Popen
|
||||
|
||||
def capture_popen(*args: Any, **kwargs: Any) -> subprocess.Popen[Any]:
|
||||
captured_calls.append((args, kwargs.copy()))
|
||||
process = real_popen(*args, **kwargs)
|
||||
captured.append(process)
|
||||
return process
|
||||
|
||||
update_service.subprocess.Popen = capture_popen
|
||||
try:
|
||||
started = time.perf_counter()
|
||||
update_service._spawn_inno_setup_applier(
|
||||
script,
|
||||
installer=installer,
|
||||
restart_exe=restart,
|
||||
helper_log_file=helper_log,
|
||||
installer_log_file=installer_log,
|
||||
)
|
||||
returned_ms = round((time.perf_counter() - started) * 1000, 3)
|
||||
finally:
|
||||
update_service.subprocess.Popen = real_popen
|
||||
deadline = time.perf_counter() + 2.5
|
||||
child_log = ""
|
||||
while time.perf_counter() < deadline:
|
||||
if helper_log.exists():
|
||||
child_log = helper_log.read_text(encoding="utf-8").strip()
|
||||
if child_log == "child-complete":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
return_code = captured[0].poll() if captured else None
|
||||
if captured and return_code is None:
|
||||
return_code = captured[0].wait(timeout=2.0)
|
||||
matrix: dict[str, Any] = {}
|
||||
if captured_calls:
|
||||
command = list(captured_calls[0][0][0])
|
||||
flag_cases = {
|
||||
"zero": 0,
|
||||
"detached": getattr(subprocess, "DETACHED_PROCESS", 0),
|
||||
"new_process_group": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||
"no_window": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"detached_new_group": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
|
||||
"detached_no_window": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"new_group_no_window": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"production_all": getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
}
|
||||
running: dict[str, tuple[subprocess.Popen[Any], Path]] = {}
|
||||
helper_parameter = command.index("-HelperLogFile") + 1
|
||||
for name, flags in flag_cases.items():
|
||||
case_command = command.copy()
|
||||
case_log = temp_root / f"matrix-{name}.log"
|
||||
case_command[helper_parameter] = str(case_log)
|
||||
process = real_popen(
|
||||
case_command,
|
||||
close_fds=True,
|
||||
creationflags=flags,
|
||||
cwd=str(temp_root),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
running[name] = (process, case_log)
|
||||
production_flags = flag_cases["production_all"]
|
||||
detached_flags = flag_cases["detached"]
|
||||
extra_cases = {
|
||||
"production_close_false": (production_flags, False, False),
|
||||
"production_all_devnull": (production_flags, True, True),
|
||||
"detached_close_false": (detached_flags, False, False),
|
||||
"detached_all_devnull": (detached_flags, True, True),
|
||||
}
|
||||
for name, (flags, close_fds, all_devnull) in extra_cases.items():
|
||||
case_command = command.copy()
|
||||
case_log = temp_root / f"matrix-{name}.log"
|
||||
case_command[helper_parameter] = str(case_log)
|
||||
stream_kwargs = (
|
||||
{
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
if all_devnull
|
||||
else {}
|
||||
)
|
||||
process = real_popen(
|
||||
case_command,
|
||||
close_fds=close_fds,
|
||||
creationflags=flags,
|
||||
cwd=str(temp_root),
|
||||
**stream_kwargs,
|
||||
)
|
||||
flag_cases[name] = flags
|
||||
running[name] = (process, case_log)
|
||||
matrix_deadline = time.perf_counter() + 3.0
|
||||
while time.perf_counter() < matrix_deadline:
|
||||
if all(case_log.exists() for _, case_log in running.values()):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
for name, (process, case_log) in running.items():
|
||||
matrix[name] = {
|
||||
"flags": flag_cases[name],
|
||||
"log_created": case_log.exists(),
|
||||
"return_code": process.poll(),
|
||||
}
|
||||
control_return_code = None
|
||||
control_stdout = ""
|
||||
control_stderr = ""
|
||||
if not fixed_log.exists() and captured_calls:
|
||||
call_args, call_kwargs = captured_calls[0]
|
||||
call_kwargs.update(
|
||||
creationflags=0,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
control = real_popen(*call_args, **call_kwargs)
|
||||
control_stdout, control_stderr = control.communicate(timeout=5.0)
|
||||
control_return_code = control.returncode
|
||||
return {
|
||||
"command": captured_calls[0][0][0] if captured_calls else [],
|
||||
"spawn_returned_ms": returned_ms,
|
||||
"child_completed": child_log == "child-complete",
|
||||
"child_log": child_log,
|
||||
"fixed_log": fixed_log.read_text(encoding="utf-8").strip()
|
||||
if fixed_log.exists()
|
||||
else "",
|
||||
"child_return_code": return_code,
|
||||
"flag_matrix": matrix,
|
||||
"control_return_code": control_return_code,
|
||||
"control_stdout": control_stdout,
|
||||
"control_stderr": control_stderr,
|
||||
}
|
||||
|
||||
|
||||
def exercise_controller_quit() -> dict[str, Any]:
|
||||
"""Run the production request_quit method against a real Qt event loop."""
|
||||
|
||||
from doctor_workstation.app import ApplicationController
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
events: list[str] = []
|
||||
holder = SimpleNamespace(application=app, _shutting_down=False)
|
||||
app.aboutToQuit.connect(lambda: events.append("aboutToQuit"))
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: (
|
||||
events.append("request_quit_enter"),
|
||||
ApplicationController.request_quit(holder),
|
||||
events.append("request_quit_return"),
|
||||
),
|
||||
)
|
||||
watchdog = QTimer()
|
||||
watchdog.setSingleShot(True)
|
||||
watchdog.timeout.connect(lambda: (events.append("watchdog"), app.quit()))
|
||||
watchdog.start(1000)
|
||||
started = time.perf_counter()
|
||||
return_code = app.exec()
|
||||
return {
|
||||
"events": events,
|
||||
"return_code": return_code,
|
||||
"returned_ms": round((time.perf_counter() - started) * 1000, 3),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
iteration_count = int(sys.argv[1]) if len(sys.argv) > 1 else 25
|
||||
output = {
|
||||
"session": exercise_session(iteration_count),
|
||||
"real_popen": exercise_real_popen(),
|
||||
"controller_quit": exercise_controller_quit(),
|
||||
}
|
||||
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,200 @@
|
||||
# AppUpdateSession 更新提交时序复现报告
|
||||
|
||||
日期:2026-08-28
|
||||
环境:Windows、Python 3.12.13、PySide6 6.11.1、uv 0.11.8
|
||||
范围:只读检查生产源码和现有测试;新增的唯一测试资产是
|
||||
`app/research/update_commit_repro.py`,未修改生产源码和既有测试。
|
||||
|
||||
## 结论
|
||||
|
||||
1. **当前工作树中的 `result -> finished -> request_quit` 时序可以稳定复现为正确。**
|
||||
100 次真实 `QThreadPool` 跨线程循环没有一次乱序或丢失:下载/prepare 在 worker
|
||||
线程,`_finish_install()`、`_on_install_finished()` 和 `request_quit()` 都在 GUI 主线程。
|
||||
2. **`_TaskSignals` 不会因局部变量释放而提前消失。** 安装期间它同时被 `_Task`、
|
||||
`AppUpdateSession._signals` 和 `_active_install_signals` 强引用;`finished` 到达后才清空
|
||||
session 引用。探针在主动 `gc.collect()` 后仍观察到 wrapper 存活,风险方向是残留/泄漏,
|
||||
不是过早 GC 导致信号丢失。
|
||||
3. **`ApplicationController.request_quit()` 本身有效。** 在真实 Qt 事件循环里,调用顺序是
|
||||
`request_quit_enter -> request_quit_return -> aboutToQuit`,没有触发 1 秒 watchdog;本次
|
||||
测量从进入事件循环到退出约 0.1 ms。
|
||||
4. **`apply_downloaded_update()` 的 `Popen` 调用不阻塞 GUI。** 生产参数下
|
||||
`_spawn_inno_setup_applier()` 约 2.7--4.7 ms 返回。
|
||||
5. **真正可复现的安装失败位于 Windows helper 启动。** 当前代码组合
|
||||
`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` 启动 Windows
|
||||
PowerShell。调用会快速返回,PowerShell 进程退出码甚至是 0,但脚本没有执行第一条写日志
|
||||
命令。标志矩阵表明:本机上任何包含 `DETACHED_PROCESS` 的组合都失败;去掉它后,
|
||||
`CREATE_NEW_PROCESS_GROUP`、`CREATE_NO_WINDOW` 以及两者组合均能执行脚本。
|
||||
6. **本机真实 18:08 更新尝试与复现完全一致。** 安装包和 `install_update.ps1` 都在
|
||||
18:08:37 生成,证明 Qt `result` 已投递且 `_finish_install()` 已进入
|
||||
`apply_downloaded_update()`;但同目录没有 `update_helper.log` 和 `inno_setup.log`,说明
|
||||
helper 没有运行到脚本第 20 行的第一条 `Write-Log`。
|
||||
|
||||
因此,“下载完成后没有进入安装”的首要根因不是 `finished` 信号丢失,也不是
|
||||
`request_quit()` 失效,而是 **`DETACHED_PROCESS` 令 PowerShell helper 静默不执行**。
|
||||
当前源码仍会在 `finished` 后请求退出,所以如果现场描述为“窗口也一直不关闭”,这部分在当前
|
||||
工作树中未能复现;现有日志更符合“应用已走到提交/退出路径,但安装器从未启动,因此没有安装
|
||||
和重启”的用户观感。成功路径没有阶段日志,无法仅凭旧日志证明窗口具体关闭时刻。
|
||||
|
||||
## 源码时序
|
||||
|
||||
相关位置:
|
||||
|
||||
- `app/src/doctor_workstation/ui/dialogs/app_update.py:68-91`:`_Task.run()` 在同一个
|
||||
`try/else/finally` 中先 `result.emit(result)`,再 `finished.emit()`。
|
||||
- `app/src/doctor_workstation/ui/dialogs/app_update.py:503-555`:安装任务创建
|
||||
`_TaskSignals`,保存到 `_signals` 和 `_active_install_signals`,然后连接
|
||||
`progress/status/result/error/finished`。
|
||||
- `app/src/doctor_workstation/ui/dialogs/app_update.py:601-641`:`result` 槽先设置
|
||||
`_apply_committed=True` 并同步调用 `apply_downloaded_update()`;`finished` 槽随后清理活动
|
||||
signals,并在 `_apply_committed` 或 `_exit_requested` 时调用 `_complete_quit()`。
|
||||
- `app/src/doctor_workstation/app.py:1081-1086`:controller 通过
|
||||
`QTimer.singleShot(0, application.quit)` 请求正常退出。
|
||||
- `app/src/doctor_workstation/app.py:427,1088-1105`:`aboutToQuit` 同步进入幂等
|
||||
`shutdown()`,更新 session 先被 invalidated,再清理视频和 remote client。
|
||||
- `app/src/doctor_workstation/services/app_update.py:637-672`:Inno helper 的 Windows
|
||||
`Popen` 和三个 creation flags。
|
||||
|
||||
必须注意一个 Qt 细节:worker 发出 `result` 后不会等待 GUI 槽执行,紧接着就发出
|
||||
`finished`;两者作为同一 sender 的跨线程事件按连接顺序排入 GUI 队列。本次 100 次实测均为:
|
||||
|
||||
```text
|
||||
worker: download/prepare return
|
||||
-> GUI: result slot enter
|
||||
-> GUI: apply_downloaded_update enter/return
|
||||
-> GUI: result slot return
|
||||
-> GUI: finished slot enter
|
||||
-> GUI: request_quit
|
||||
```
|
||||
|
||||
这也意味着:如果 `apply_downloaded_update()` 真正阻塞,排在它后面的 `finished` 和 quit 会一起
|
||||
延迟。但本机真实 `Popen` 返回只需数毫秒,未观察到阻塞。
|
||||
|
||||
## 复现结果
|
||||
|
||||
### 1. 现有测试
|
||||
|
||||
```powershell
|
||||
$env:QT_QPA_PLATFORM='offscreen'
|
||||
uv run --project app pytest app/tests/test_app_update_ui.py app/tests/test_app_update.py -q
|
||||
```
|
||||
|
||||
结果:`28 passed`。
|
||||
|
||||
现有 `test_session_waits_for_update_worker_before_quitting` 是直接调用私有槽的同步单元测试,能够
|
||||
检查状态门禁,但没有经过 `QThreadPool`/Qt queued delivery;这正是独立探针需要补足的部分。
|
||||
|
||||
### 2. 独立跨线程与 helper 探针
|
||||
|
||||
```powershell
|
||||
$env:QT_QPA_PLATFORM='offscreen'
|
||||
uv run --project app python app/research/update_commit_repro.py 100
|
||||
```
|
||||
|
||||
关键结果:
|
||||
|
||||
```text
|
||||
session.iterations = 100
|
||||
session.failures = []
|
||||
|
||||
worker thread != GUI thread
|
||||
result_slot_enter.thread_id == finished_slot_enter.thread_id
|
||||
request_quit.thread_id == GUI main thread
|
||||
|
||||
_spawn_inno_setup_applier return = 约 3 ms
|
||||
production flags script log created = false
|
||||
production PowerShell return code = 0
|
||||
|
||||
controller quit events =
|
||||
request_quit_enter, request_quit_return, aboutToQuit
|
||||
```
|
||||
|
||||
Windows 创建标志矩阵:
|
||||
|
||||
| creation flags | 脚本是否执行 |
|
||||
|---|---:|
|
||||
| `0` | 是 |
|
||||
| `CREATE_NEW_PROCESS_GROUP` | 是 |
|
||||
| `CREATE_NO_WINDOW` | 是 |
|
||||
| `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` | 是 |
|
||||
| `DETACHED_PROCESS` | 否 |
|
||||
| `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` | 否 |
|
||||
| `DETACHED_PROCESS | CREATE_NO_WINDOW` | 否 |
|
||||
| 当前生产三标志组合 | 否 |
|
||||
|
||||
将 `close_fds` 改为 false,或给 stdin/stdout/stderr 全部接 `DEVNULL`,均不能挽救包含
|
||||
`DETACHED_PROCESS` 的组合。
|
||||
|
||||
### 3. 真实运行残留
|
||||
|
||||
本机目录:
|
||||
|
||||
```text
|
||||
C:\Users\pc\AppData\Local\ZhenYangTang\ZhenyangDoctor\updates\1_3_0\
|
||||
```
|
||||
|
||||
18:08:37 已有:
|
||||
|
||||
- `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe`
|
||||
- `install_update.ps1`
|
||||
|
||||
不存在:
|
||||
|
||||
- `update_helper.log`
|
||||
- `inno_setup.log`
|
||||
|
||||
`install_update.ps1` 只会在 `_finish_install() -> apply_downloaded_update() ->
|
||||
apply_inno_setup_update()` 中生成,所以这组残留直接排除了“result 未投递”和
|
||||
“`_TaskSignals` 被提前回收”。脚本第一项业务动作就是写 `waiting for pid ...`;没有 helper log
|
||||
则失败发生在脚本业务逻辑之前。
|
||||
|
||||
另有一个独立的发布数据风险:workspace 名为 `1_3_0`,下载文件名却是 `1.1.0`,而请求中的
|
||||
当前版本是 `1.2.0`。即使 helper 启动成功,也可能尝试降级安装。服务端 offer 的
|
||||
`latest_version`、package filename、安装器 FileVersion/产品版本需要在发布端和客户端都做一致性
|
||||
校验。这不是本次 helper 不启动的直接原因,但上线前必须处理。
|
||||
|
||||
## 建议修复方向
|
||||
|
||||
1. Windows helper 不使用 `DETACHED_PROCESS`;先验证保留
|
||||
`CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` 时,父进程结束后 helper 仍能存活并运行。
|
||||
2. 不把 `Popen()` 成功等同于 helper 已启动。让 helper 在等待目标 PID 前先原子写一个
|
||||
`ready`/`waiting` 标记,应用收到握手后才设置最终 commit 并退出;握手超时则留在应用内显示
|
||||
明确错误。
|
||||
3. 给成功路径补结构化日志:`prepare_result_received`、`helper_spawn_requested`、
|
||||
`helper_ready`、`worker_finished`、`request_quit`、`about_to_quit`。当前只有异常日志,现场无法
|
||||
区分“100% 后仍在 fsync/校验”“helper 启动失败”和“quit 清理较慢”。
|
||||
4. 对 package 声明版本和安装器版本做一致性校验,拒绝低于当前版本或不同于
|
||||
`latest_version` 的安装器。
|
||||
|
||||
## 建议回归测试
|
||||
|
||||
### Qt/session 测试
|
||||
|
||||
1. **真实 queued delivery 成功路径**:用 `QThreadPool` 启动 `_Task`,以 event loop 等待,断言
|
||||
`result slot -> apply return -> finished slot -> request_quit` 严格顺序,并断言所有 UI/controller
|
||||
槽都在 GUI 线程。
|
||||
2. **signals 生命周期**:启动任务后删除局部 worker/signals 引用并强制 GC,仍应收到 result 和
|
||||
finished;finished 后断开连接并 `deleteLater()`,最终 weakref 应释放,避免长期检查导致残留。
|
||||
3. **apply 门控**:用 `Event` 暂停 fake apply,断言暂停期间不会调用 quit;释放后 finished 只触发
|
||||
一次 quit。
|
||||
4. **apply 失败**:`apply_downloaded_update()` 抛 `AppUpdateError` 时不 quit、
|
||||
`_apply_committed` 恢复 false;另外补非 `AppUpdateError` 异常,避免意外异常留下 commit=true 后
|
||||
仍被 finished 退出。
|
||||
5. **controller 集成**:在真实 `QApplication.exec()` 中调用 controller `request_quit()`,spy
|
||||
`aboutToQuit`、`app_updater.shutdown()` 和远程 client close,断言各一次且总时长有上界。
|
||||
|
||||
### Windows helper 测试
|
||||
|
||||
1. **sentinel 启动测试(当前代码应失败)**:生成只写 sentinel 的 PowerShell 文件,使用生产
|
||||
`_spawn_inno_setup_applier()` 启动,2 秒内必须看到 sentinel;不能只断言 `Popen` 被调用。
|
||||
2. **父进程退出测试**:子 Python 进程启动 helper 后立即退出;helper 应先记录 waiting/ready,
|
||||
再观察父 PID 消失并写第二个 sentinel,证明去掉 `DETACHED_PROCESS` 后不会被父退出连带杀死。
|
||||
3. **完整握手测试**:应用只有在 helper ready 后才调用 quit;helper 未 ready、提前退出或无法写
|
||||
日志时,应用保留并展示可重试错误。
|
||||
4. **打包 smoke**:从实际 PyInstaller onedir/installer 环境执行上述测试,不能只在源码虚拟环境
|
||||
mock `subprocess.Popen`。
|
||||
|
||||
## 仓库说明
|
||||
|
||||
根 `AGENTS.md` 声明项目由 Trellis 管理,但当前工作区没有 `.trellis/` 目录,因而无法读取
|
||||
`.trellis/workflow.md` 或 layer spec;本次按根指令执行并在此记录。工作树原本已有大量未提交
|
||||
修改,本次没有改动其中任何生产源码或既有测试。
|
||||
@@ -0,0 +1,115 @@
|
||||
# Windows 更新安装交接故障诊断
|
||||
|
||||
## 结论
|
||||
|
||||
“下载完成后显示即将关闭,但程序不退出/不安装”不是下载失败。当前现场同时存在三个问题,其中第 1 项能够确定性复现用户看到的主症状,第 2 项是本次现场已经发生但被代码吞掉的 helper 启动失败,第 3 项是必须立即纠正的发布配置错误。
|
||||
|
||||
1. **确定性根因:强制更新对话框拒绝了 `QApplication.quit()` 触发的关闭事件。** 下载 job 完成后,session 启动 helper、设置 `_apply_committed=True`,随后在 `finished` 回调中调用应用级 `request_quit()`;但对话框此时仍是 `offer.force=True` 且 `_busy=True`,其 `closeEvent()` 无条件 `ignore()`。Qt 明确允许窗口通过 Close event 阻止 `quit()`,所以事件循环不退出,`aboutToQuit`/`ApplicationController.shutdown()` 也不会发生。helper 又先等待当前 PID 消失,于是交接形成闭环等待。
|
||||
2. **独立的已确认问题:PowerShell helper 进程被创建后,在执行脚本首条日志之前就退出了,而父进程把“CreateProcess 成功”误判成“helper 已接管”。** 现场有精确的 PowerShell 启动事件,但没有 `update_helper.log`、没有 `inno_setup.log`、没有存活 helper/installer 进程。代码丢弃 `Popen` 句柄、不检查早退、也没有 ready handshake,因此 UI 仍停在“即将关闭”。现有证据不足以还原该次子进程的退出码;这正是当前可观测性缺口。
|
||||
3. **发布配置错误:服务端宣称最新/最低版本为 `1.3.0`,实际下发的安装器却是 `1.1.0`。** 本机当前运行 `1.2.0`,所以即使退出与 helper 均修复,也会尝试降级安装,而不是升级到 `1.3.0`。修复客户端前应先停止这条强制更新配置。
|
||||
|
||||
## 代码交接链路与根因证据
|
||||
|
||||
### 1. UI 已经请求退出,但强制对话框否决退出
|
||||
|
||||
当前交接顺序是:
|
||||
|
||||
1. `_start_install()` 设置 busy、启动 QThreadPool 下载 job(`app/src/doctor_workstation/ui/dialogs/app_update.py:481-555`)。
|
||||
2. job 下载并校验 Inno Setup EXE,返回 `_PreparedUpdate`(`:511-545`)。
|
||||
3. `_finish_install()` 先显示“即将关闭程序并自动安装”,再调用 `dialog.set_apply_committed()`;该方法把 `_busy` 保持为 true(`:616-628`、`:275-283`)。
|
||||
4. `apply_downloaded_update()` 进入 `apply_inno_setup_update()`,写脚本并 `Popen` PowerShell helper(`app/src/doctor_workstation/services/app_update.py:429-472`)。
|
||||
5. QRunnable 随后发送 `finished`,`_on_install_finished()` 清理 active signals 并调用 `_complete_quit()`(UI `:634-641`)。
|
||||
6. `_complete_quit()` 调用 controller `request_quit()`;controller 用 `QTimer.singleShot(0, self.application.quit)` 请求正常退出(UI `:472-479`;`app/src/doctor_workstation/app.py:1081-1086`)。
|
||||
7. 但是更新对话框的 `closeEvent()` 在 `offer.force` **或** `_busy` 为真时执行 `event.ignore()`(UI `:296-300`)。此时两个条件都为真。
|
||||
|
||||
使用当前 PySide6 做了不改文件的最小事件循环复现:显示一个 modal dialog,其 `closeEvent()` 执行 `ignore()`,50 ms 后调用 `QApplication.quit()`,并设置 2 秒进程级 watchdog。输出为:
|
||||
|
||||
```text
|
||||
calling quit
|
||||
closeEvent ignored
|
||||
CODE=9
|
||||
```
|
||||
|
||||
也就是 `quit()` 确实到达了窗口,但被 Close event 否决,事件循环直到 watchdog 才终止。Qt 官方文档同样说明 `QCoreApplication.quit()` 可能被仍未关闭的窗口或被忽略的 Quit/Close event 阻止:<https://doc.qt.io/qt-6/qcoreapplication.html#quit>。
|
||||
|
||||
这也解释了为何 controller 的 `aboutToQuit -> shutdown()` 接线本身没有帮助:`aboutToQuit` 只有在退出请求未被阻止时才会发出(`app.py:425-427`、`:1088-1105`)。
|
||||
|
||||
### 2. Inno helper 的等待设计放大了 UI 退出缺陷
|
||||
|
||||
生成的 `install_update.ps1` 首先写 `waiting for pid ...`,然后无限轮询 `Get-Process -Id $TargetPid`;只有目标 PID 消失后才启动安装器(service `:566-627`)。父进程传入的是 `os.getpid()`(`:637-672`)。因此,只要 Qt 主进程被对话框留下,安装器就不可能启动。
|
||||
|
||||
安装器参数本身与 Inno 静默更新意图一致:`/VERYSILENT`、`/SUPPRESSMSGBOXES`、`/NORESTART`、`/CLOSEAPPLICATIONS`、`/NOFORCECLOSEAPPLICATIONS`、`/NORESTARTAPPLICATIONS`、`/LOG=...`;返回 `0`/`3010` 后才重启已安装的 `DoctorWorkstation.exe`,其他返回码重启旧程序(`:599-627`)。`powershell.exe -File <script> <script args>` 的排列也符合 Windows PowerShell 5.1 的 `-File` 契约:<https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe?view=powershell-5.1>。
|
||||
|
||||
不过当前等待还有两个健壮性问题:
|
||||
|
||||
- 循环无超时、只按整数 PID 查询;极端情况下 PID 被复用会继续等待无关进程。更安全的是先取得特定 `Process` 对象/句柄,再等待该对象退出,而不是每 400 ms 重新按 ID 查找。
|
||||
- helper 的成功标准只是 `subprocess.Popen(...)` 没有同步抛出 `OSError`。`Popen` 返回后句柄立即丢失,没有 child PID 日志、早退检测、stderr 捕获或 ready handshake(`:637-672`)。
|
||||
|
||||
当前还同时设置 `DETACHED_PROCESS`、`CREATE_NEW_PROCESS_GROUP`、`CREATE_NO_WINDOW`(`:645-670`)。Microsoft 文档指出 `CREATE_NO_WINDOW` 与 `DETACHED_PROCESS` 同用时会被忽略,因此这组 flag 至少是冗余且不能证明 helper 已进入脚本:<https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags>。本次现场只能确认 PowerShell 在脚本体前早退,不能仅凭事件日志断言具体是 flag、stdio、执行策略还是主机初始化中的哪一个原因;修复应以“可确认接管”为契约,而不是猜一个退出原因。
|
||||
|
||||
### 3. `apply_downloaded_update()` 的路由本身正确
|
||||
|
||||
`package_type=inno_setup` 会进入 `apply_inno_setup_update()`;后者要求 Windows、可解析的 frozen install root、存在的已安装 EXE,以及扩展名为 `.exe` 且 DOS header 为 `MZ` 的安装器(service `:390-401`、`:429-483`)。现场已经生成 `install_update.ps1`,证明路由、install root、EXE/PE 基本校验均已通过;故障发生在 helper 启动及应用退出交接之后。
|
||||
|
||||
## 本机现场证据(2026-08-28,Asia/Shanghai)
|
||||
|
||||
- 运行进程:PID `36304`,`C:\Program Files\ZYT\DoctorWorkstation\DoctorWorkstation.exe`,启动于 `18:08:13`;检查时仍 `Responding=True`、主窗口可见,文件 `ProductVersion=1.2.0`。
|
||||
- 应用日志:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\Logs\doctor-workstation.log`。
|
||||
- `18:08:13`:应用启动。
|
||||
- `18:08:15`:以 `current_version=1.2.0&platform=windows&arch=x64` 检查更新成功。
|
||||
- `18:08:32`:下载 `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe` 返回 HTTP 200。
|
||||
- 此后没有安装/helper/退出阶段日志,也没有 Python 异常。
|
||||
- 工作区:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\updates\1_3_0`。
|
||||
- 安装器大小 `162,873,123` 字节,SHA-256 `0CDD38DB6EEF5E7B7380FFAE3FBC407B602EB916C68AE4C8F29496B621F50789`,`ProductVersion=1.1.0`,未签名。
|
||||
- `install_update.ps1` 在 `18:08:37` 生成,PowerShell AST 解析无语法错误。
|
||||
- `update_helper.log` 不存在;`inno_setup.log` 不存在。
|
||||
- Windows PowerShell Operational 日志:`18:08:37.828` 有 Event `40961`“Powershell 控制台正在启动”,没有配对的 ready `40962`;检查时也没有命令行指向 `install_update.ps1` 的 PowerShell 或 Inno Setup 进程。这证明子进程被创建过,但没有进入脚本首条 `Write-Log`。
|
||||
- crash log 只有各次进程启动标记,本次没有崩溃堆栈。
|
||||
- 实时只读请求更新接口得到:`latest_version=1.3.0`、`min_version=1.3.0`、`force=true`,但 URL、filename、size、sha256 全都对应上述 `1.1.0` 安装器。本机实测文件 hash 与接口 SHA 一致,说明下载正确,错误在发布元数据/产物绑定。
|
||||
|
||||
## 最小安全修复
|
||||
|
||||
### P0:先修发布配置
|
||||
|
||||
在正确的 `1.3.0` 安装器上传并核对 `ProductVersion`、filename、size、SHA-256 前,立即关闭这条强制更新或将其设为不可安装。不要让 `latest_version=1.3.0` 继续绑定 `1.1.0` 安装器。客户端后续应增加“offer 版本与包版本”的发布门禁;仅校验 HTTPS、SHA 和 `MZ` 不能防止签名正确的旧包被错误发布。
|
||||
|
||||
### P0:让应用级退出能够越过“用户不可关闭”的对话框门禁
|
||||
|
||||
不要把“禁止用户关闭强制更新弹窗”和“禁止应用已提交后的受控退出”共用同一个 `closeEvent` 条件。建议增加明确的 `_allow_application_exit` 状态:
|
||||
|
||||
- 用户点击标题栏关闭/Escape 时仍然拒绝;
|
||||
- helper 已确认接管,或用户点击专用“退出软件”且更新 worker 已安全结束时,session 先设置 allow 状态并关闭/隐藏该 dialog,再调用 controller `request_quit()`;
|
||||
- `closeEvent()` 仅在 `not _allow_application_exit and (offer.force or _busy)` 时 ignore。
|
||||
|
||||
直接在主线程调用 `QCoreApplication.exit(0)`也能绕过 Close event,但会绕过其他窗口的正常 close 协议;相比之下,显式放行本更新对话框后继续走既有 `QApplication.quit -> aboutToQuit -> shutdown` 更小、更安全。
|
||||
|
||||
### P0:把 helper“已接管”变成可验证状态
|
||||
|
||||
`_spawn_inno_setup_applier()` 应返回并保留 `Popen`/child PID,且 helper 在做任何 PID 等待前原子写入 ready 标记(或首条结构化 bootstrap log)。父进程应异步等待一个很短且有界的 ready 窗口:
|
||||
|
||||
- ready 到达且 child 仍存活后,才设置 `_apply_committed=True`、放行 dialog close、请求主程序退出;
|
||||
- child 在 ready 前退出时,读取退出码/bootstrap stderr,留在当前 UI 显示错误,不退出主程序;
|
||||
- 使用 `-NonInteractive`,明确重定向 stdin/stdout/stderr 到日志或 `DEVNULL`;规范化 creation flags,不同时依赖会被忽略的 `DETACHED_PROCESS + CREATE_NO_WINDOW`;
|
||||
- helper 顶层捕获脚本初始化、首条日志、PID wait、安装器启动等所有异常,并写明阶段和退出码。
|
||||
|
||||
这样即使本次 PowerShell 早退的底层原因在另一台机器上不同,也不会再出现“UI 已锁死并宣称即将关闭,但其实无人接管”的假成功。
|
||||
|
||||
## 建议测试
|
||||
|
||||
现有 `app/tests/test_app_update.py` 与 `app/tests/test_app_update_ui.py` 共 `28 passed`,但没有覆盖真实交接:service 测试只截获 `_spawn_inno_setup_applier` 并检查脚本文本;UI 测试只断言 mock `host.request_quit` 被调用,没有运行 `QApplication` 事件循环,也没有验证强制 dialog 是否会否决 quit。现有 installer smoke 直接执行安装器,也绕过了应用退出与 helper。
|
||||
|
||||
建议至少增加:
|
||||
|
||||
1. **Qt 子进程回归(必须)**:显示 `force=True` 且 busy/apply committed 的真实 `AppUpdateDialog`,触发 session 完成退出,以 watchdog 保底;断言事件循环正常返回 `0`、`aboutToQuit` 发生,而不是被 `closeEvent` 卡住。
|
||||
2. **显式退出路径**:强制更新下载中点击“退出软件”,worker `finished` 后同样能退出;result 已排队但退出先处理时不得启动 helper。
|
||||
3. **helper ready 成功**:使用临时 noop PowerShell fixture 和真实生产 creation flags,断言 child PID、ready、等待目标进程退出、后续阶段按顺序发生,并覆盖路径含空格。
|
||||
4. **helper 早退**:脚本缺失/解析失败/首条日志失败时,断言父进程取得非零退出码、不设置 committed、不退出、UI 可重试且有明确日志路径。
|
||||
5. **PID 身份**:目标进程退出后即使整数 PID 被模拟复用,也不会等待或误伤新进程;等待有诊断超时但绝不在无法确认旧进程退出时启动安装。
|
||||
6. **隔离端到端 Inno 更新**:从一个测试 frozen app 发起 handoff,确认旧 PID 消失、helper log 产生、安装器 log 产生、目标版本真正安装、只重启一次。不要只测试安装器单独运行。
|
||||
7. **发布契约**:服务端 `latest_version`、package filename/manifest `ProductVersion`、SHA/size 必须属于同一版本;构造 `latest=1.3.0 + package=1.1.0` 时发布或客户端安装必须失败。
|
||||
|
||||
## 审阅边界
|
||||
|
||||
- 已读取根 `AGENTS.md`;仓库不存在 `.trellis/`,因此没有额外 workflow/spec 可读取。
|
||||
- 工作树原本已有大量未提交改动,包括本报告涉及的生产文件和测试;本次未修改、覆盖或回退它们。
|
||||
- 除新增本文档外,没有修改生产代码或测试。
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Zhenyang doctor workstation."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
DEBUG_MODE = False
|
||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||
|
||||
@@ -403,13 +403,14 @@ class ApplicationController(QObject):
|
||||
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()
|
||||
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.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] = {}
|
||||
@@ -506,8 +507,9 @@ class ApplicationController(QObject):
|
||||
self.login_window.config = self.config
|
||||
|
||||
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
||||
self.current_demo_mode = enabled
|
||||
if enabled:
|
||||
allowed = self.debug_mode and enabled
|
||||
self.current_demo_mode = allowed
|
||||
if allowed:
|
||||
self._cancel_session_restore()
|
||||
|
||||
def _rebuild_remote_repository(self) -> None:
|
||||
@@ -668,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()
|
||||
@@ -1075,6 +1078,13 @@ class ApplicationController(QObject):
|
||||
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."""
|
||||
|
||||
@@ -1082,6 +1092,7 @@ class ApplicationController(QObject):
|
||||
return
|
||||
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())
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
@@ -5,10 +5,12 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
@@ -64,6 +66,7 @@ class UpdateOffer:
|
||||
arch: str
|
||||
package: UpdatePackage | None
|
||||
can_install: bool
|
||||
install_unavailable_reason: str = ""
|
||||
|
||||
|
||||
def current_app_version() -> str:
|
||||
@@ -122,6 +125,16 @@ def compare_version(left: str, right: str) -> int:
|
||||
return (left_parts > right_parts) - (left_parts < right_parts)
|
||||
|
||||
|
||||
def package_declared_version(package: UpdatePackage | None) -> str:
|
||||
"""Return a semantic version embedded in a package filename, when present."""
|
||||
|
||||
if package is None:
|
||||
return ""
|
||||
name = Path(package.filename or urlsplit(package.url).path).name
|
||||
match = re.search(r"(?<!\d)(\d+(?:\.\d+){1,3})(?!\d)", name)
|
||||
return normalize_version(match.group(1)) if match is not None else ""
|
||||
|
||||
|
||||
def frozen_install_root() -> Path | None:
|
||||
if not is_frozen_install():
|
||||
return None
|
||||
@@ -191,13 +204,24 @@ def parse_update_offer(
|
||||
or package.type != PACKAGE_TYPE_INNO_SETUP
|
||||
or _is_secure_installer_url(package.url)
|
||||
)
|
||||
declared_package_version = package_declared_version(package)
|
||||
package_version_matches = bool(
|
||||
not declared_package_version or declared_package_version == latest_version
|
||||
)
|
||||
can_install = (
|
||||
bool(data.get("can_install"))
|
||||
and package is not None
|
||||
and digest_is_valid
|
||||
and installer_transport_is_valid
|
||||
and package_version_matches
|
||||
and has_update
|
||||
)
|
||||
install_unavailable_reason = ""
|
||||
if has_update and declared_package_version and not package_version_matches:
|
||||
install_unavailable_reason = (
|
||||
f"安装包版本 {declared_package_version} 与发布版本 {latest_version} 不一致,"
|
||||
"请管理员重新上传正确版本的安装包。"
|
||||
)
|
||||
return UpdateOffer(
|
||||
has_update=has_update,
|
||||
force=bool(data.get("force")) and can_install,
|
||||
@@ -211,6 +235,7 @@ def parse_update_offer(
|
||||
arch=response_arch,
|
||||
package=package if can_install else None,
|
||||
can_install=can_install,
|
||||
install_unavailable_reason=install_unavailable_reason,
|
||||
)
|
||||
|
||||
|
||||
@@ -307,7 +332,9 @@ def download_package(
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
partial = destination.with_name(f"{destination.name}.part")
|
||||
partial.unlink(missing_ok=True)
|
||||
timeout = httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0)
|
||||
# Keep each network read bounded so an explicit application exit cannot be
|
||||
# held forever by a server that accepted the connection and then stalled.
|
||||
timeout = httpx.Timeout(connect=30.0, read=30.0, write=30.0, pool=30.0)
|
||||
hasher = hashlib.sha256()
|
||||
received = 0
|
||||
try:
|
||||
@@ -453,12 +480,15 @@ def apply_inno_setup_update(
|
||||
restart_exe = _installed_restart_executable(target_root)
|
||||
helper_log_file = installer.parent / "update_helper.log"
|
||||
installer_log_file = installer.parent / "inno_setup.log"
|
||||
ready_file = installer.parent / "update_helper.ready"
|
||||
try:
|
||||
ready_file.unlink(missing_ok=True)
|
||||
script = _write_inno_setup_script(
|
||||
installer=installer,
|
||||
restart_exe=restart_exe,
|
||||
helper_log_file=helper_log_file,
|
||||
installer_log_file=installer_log_file,
|
||||
ready_file=ready_file,
|
||||
)
|
||||
_spawn_inno_setup_applier(
|
||||
script,
|
||||
@@ -466,6 +496,7 @@ def apply_inno_setup_update(
|
||||
restart_exe=restart_exe,
|
||||
helper_log_file=helper_log_file,
|
||||
installer_log_file=installer_log_file,
|
||||
ready_file=ready_file,
|
||||
)
|
||||
except OSError as error:
|
||||
raise AppUpdateError(f"无法启动 Windows 更新助手:{error}") from error
|
||||
@@ -566,6 +597,7 @@ def _write_inno_setup_script(
|
||||
restart_exe: Path,
|
||||
helper_log_file: Path,
|
||||
installer_log_file: Path,
|
||||
ready_file: Path,
|
||||
) -> Path:
|
||||
script = installer.parent / "install_update.ps1"
|
||||
script.write_text(
|
||||
@@ -576,7 +608,8 @@ def _write_inno_setup_script(
|
||||
" [Parameter(Mandatory=$true)][string]$Installer,",
|
||||
" [Parameter(Mandatory=$true)][string]$RestartExe,",
|
||||
" [Parameter(Mandatory=$true)][string]$HelperLogFile,",
|
||||
" [Parameter(Mandatory=$true)][string]$InstallerLogFile",
|
||||
" [Parameter(Mandatory=$true)][string]$InstallerLogFile,",
|
||||
" [Parameter(Mandatory=$true)][string]$ReadyFile",
|
||||
")",
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
"function Write-Log([string]$Message) {",
|
||||
@@ -590,7 +623,8 @@ def _write_inno_setup_script(
|
||||
' Write-Log "restart executable missing: $RestartExe"',
|
||||
" }",
|
||||
"}",
|
||||
'Write-Log "waiting for pid $TargetPid"',
|
||||
'Set-Content -LiteralPath $ReadyFile -Value ("ready target_pid={0} helper_pid={1}" -f $TargetPid, $PID) -Encoding UTF8',
|
||||
'Write-Log "helper ready; waiting for pid $TargetPid"',
|
||||
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }",
|
||||
"Start-Sleep -Milliseconds 600",
|
||||
"$arguments = @(",
|
||||
@@ -627,7 +661,7 @@ def _write_inno_setup_script(
|
||||
),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
del restart_exe, helper_log_file, installer_log_file
|
||||
del restart_exe, helper_log_file, installer_log_file, ready_file
|
||||
return script
|
||||
|
||||
|
||||
@@ -638,11 +672,11 @@ def _spawn_inno_setup_applier(
|
||||
restart_exe: Path,
|
||||
helper_log_file: Path,
|
||||
installer_log_file: Path,
|
||||
ready_file: Path,
|
||||
) -> None:
|
||||
flags = getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
subprocess.Popen(
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
@@ -662,11 +696,26 @@ def _spawn_inno_setup_applier(
|
||||
str(helper_log_file),
|
||||
"-InstallerLogFile",
|
||||
str(installer_log_file),
|
||||
"-ReadyFile",
|
||||
str(ready_file),
|
||||
],
|
||||
close_fds=True,
|
||||
creationflags=flags,
|
||||
cwd=str(script.parent),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline:
|
||||
if ready_file.is_file():
|
||||
return
|
||||
exit_code = process.poll()
|
||||
if exit_code is not None:
|
||||
raise OSError(f"更新助手启动后提前退出(代码 {exit_code})")
|
||||
time.sleep(0.05)
|
||||
process.terminate()
|
||||
raise OSError(f"更新助手启动超时,未生成就绪标记:{ready_file}")
|
||||
|
||||
|
||||
def _spawn_applier(
|
||||
@@ -679,8 +728,7 @@ def _spawn_applier(
|
||||
) -> None:
|
||||
pid = os.getpid()
|
||||
if sys.platform == "win32":
|
||||
flags = getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
subprocess.Popen(
|
||||
[
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -12,7 +12,19 @@ from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
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 +35,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 +62,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 {
|
||||
@@ -2467,7 +2481,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 +2522,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 +3142,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 +3180,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": "离异"}
|
||||
|
||||
# 后台 yesNoText:1 有,其余 无。
|
||||
_YES_NO_FIELDS = frozenset(
|
||||
{
|
||||
"trauma_history",
|
||||
"surgery_history",
|
||||
"allergy_history",
|
||||
"family_history",
|
||||
"pregnancy_history",
|
||||
}
|
||||
)
|
||||
|
||||
_CREATE_SOURCE_LABELS: dict[str, str] = {
|
||||
"mnp": "小程序建档",
|
||||
"mnp_daily": "小程序快捷建档",
|
||||
"admin": "后台创建",
|
||||
"doctor": "医生创建",
|
||||
}
|
||||
|
||||
_RECORD_SOURCE_LABELS: dict[str, str] = {"0": "医生录入", "1": "患者自录"}
|
||||
|
||||
# 纯内部列:启停标记、统计端展示位、排班偏移与软删除时间对医生没有意义。
|
||||
INTERNAL_FIELDS = frozenset(
|
||||
{
|
||||
"status",
|
||||
"show_card",
|
||||
"revisit_slot_start_offset",
|
||||
"delete_time",
|
||||
"is_delete",
|
||||
"is_deleted",
|
||||
"sort",
|
||||
"assistant_id",
|
||||
"doctor_id",
|
||||
"admin_id",
|
||||
}
|
||||
)
|
||||
|
||||
# 附件字段单独渲染成缩略图,不再以 URL 文本出现在字段网格里。
|
||||
IMAGE_FIELDS = frozenset(
|
||||
{
|
||||
"tongue_images",
|
||||
"report_files",
|
||||
"images",
|
||||
"breakfast_images",
|
||||
"lunch_images",
|
||||
"dinner_images",
|
||||
}
|
||||
)
|
||||
|
||||
_TIMESTAMP_SUFFIXES = ("_time", "_at", "_date")
|
||||
_TIMESTAMP_MIN = 10**9 # 2001-09-09,早于本项目任何真实数据
|
||||
_TIMESTAMP_MAX = 4 * 10**9 # 2096 年,之后按普通数字显示
|
||||
|
||||
|
||||
# 只读页的单位,与 admin PatientCaseCard / BloodRecordList 一致。
|
||||
_UNIT_SUFFIXES: dict[str, str] = {
|
||||
"height": " cm",
|
||||
"weight": " kg",
|
||||
"systolic_pressure": " mmHg",
|
||||
"diastolic_pressure": " mmHg",
|
||||
"fasting_blood_sugar": " mmol/L",
|
||||
"postprandial_blood_sugar": " mmol/L",
|
||||
"other_blood_sugar": " mmol/L",
|
||||
"blood_sugar": " mmol/L",
|
||||
"duration": " 分钟",
|
||||
"diabetes_discovery_year": "年",
|
||||
}
|
||||
|
||||
|
||||
def unit_suffix(field: str, value: Any) -> str:
|
||||
"""Return the unit a numeric readonly field should carry, if any."""
|
||||
|
||||
suffix = _UNIT_SUFFIXES.get(str(field or "").strip())
|
||||
if not suffix:
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text or text.endswith(suffix.strip()):
|
||||
return ""
|
||||
# 只给纯数字补单位,"17多"、"五年" 这类自由文本保持原样。
|
||||
normalized = text.replace(".", "", 1)
|
||||
return suffix if normalized.isdigit() else ""
|
||||
|
||||
|
||||
def format_timestamp(value: Any, *, with_time: bool = True) -> str | None:
|
||||
"""把后端的 Unix 秒级时间戳转成中文界面用的日期时间。"""
|
||||
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
seconds = int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not _TIMESTAMP_MIN <= seconds <= _TIMESTAMP_MAX:
|
||||
return None
|
||||
moment = datetime.fromtimestamp(seconds)
|
||||
return moment.strftime("%Y-%m-%d %H:%M" if with_time else "%Y-%m-%d")
|
||||
|
||||
|
||||
def is_timestamp_field(field: str) -> bool:
|
||||
return str(field or "").endswith(_TIMESTAMP_SUFFIXES)
|
||||
|
||||
|
||||
def gender_label(value: Any) -> str | None:
|
||||
return _GENDER_LABELS.get(str(value or "").strip().lower())
|
||||
|
||||
|
||||
def split_values(value: Any) -> list[str]:
|
||||
"""按后台 enrichDiagnosisLabels 的方式拆多值字段。"""
|
||||
|
||||
if value in (None, "", []):
|
||||
return []
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
items = [str(item or "").strip() for item in value]
|
||||
else:
|
||||
items = [part.strip() for part in _split_text(str(value))]
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
def _split_text(text: str) -> list[str]:
|
||||
normalized = text.replace(",", ",").replace("、", ",")
|
||||
return normalized.split(",")
|
||||
|
||||
|
||||
class TermIndex:
|
||||
"""字典翻译表:实时字典覆盖内置种子,两者都缺就回显原值。"""
|
||||
|
||||
def __init__(self, dictionaries: Mapping[str, Any] | None = None) -> None:
|
||||
self._dictionaries: dict[str, dict[str, str]] = {
|
||||
dictionary_type: dict(options)
|
||||
for dictionary_type, options in SEED_DICTIONARIES.items()
|
||||
}
|
||||
self.merge(dictionaries)
|
||||
|
||||
def merge(self, dictionaries: Mapping[str, Any] | None) -> None:
|
||||
"""并入 ``config/dict`` 下发的 ``{type: [{name, value}, ...]}``。"""
|
||||
|
||||
if not isinstance(dictionaries, Mapping):
|
||||
return
|
||||
for dictionary_type, rows in dictionaries.items():
|
||||
options = _options_from_rows(rows)
|
||||
if options:
|
||||
self._dictionaries.setdefault(str(dictionary_type), {}).update(options)
|
||||
|
||||
def dictionary(self, dictionary_type: str) -> dict[str, str]:
|
||||
return dict(self._dictionaries.get(str(dictionary_type), {}))
|
||||
|
||||
def dictionary_label(self, field: str, value: Any) -> str | None:
|
||||
"""翻译一个字典字段;不是字典字段或没有可翻译内容时返回 None。"""
|
||||
|
||||
dictionary_type = FIELD_DICTIONARIES.get(str(field or "").strip())
|
||||
if dictionary_type is None:
|
||||
return None
|
||||
options = self._dictionaries.get(dictionary_type, {})
|
||||
items = split_values(value)
|
||||
if not items:
|
||||
return None
|
||||
labels = [options.get(item, item) for item in items]
|
||||
return "、".join(label for label in labels if label) or None
|
||||
|
||||
def value_label(self, field: str, value: Any) -> str | None:
|
||||
"""翻译字典项或枚举;无法翻译时返回 None,由调用方回显原值。"""
|
||||
|
||||
key = str(field or "").strip()
|
||||
if value in (None, "", [], {}):
|
||||
return None
|
||||
dictionary_label = self.dictionary_label(key, value)
|
||||
if dictionary_label is not None:
|
||||
return dictionary_label
|
||||
if key in {"gender", "patient_gender", "sex"}:
|
||||
return gender_label(value)
|
||||
if key in {"marital_status", "marriage"}:
|
||||
return _MARITAL_LABELS.get(str(value).strip())
|
||||
if key in _YES_NO_FIELDS:
|
||||
text = str(value).strip()
|
||||
if text in {"0", "1"}:
|
||||
return "有" if text == "1" else "无"
|
||||
return None
|
||||
if key == "create_source":
|
||||
return _CREATE_SOURCE_LABELS.get(str(value).strip().lower())
|
||||
if key == "source":
|
||||
return _RECORD_SOURCE_LABELS.get(str(value).strip())
|
||||
if is_timestamp_field(key):
|
||||
return format_timestamp(value, with_time=not key.endswith("_date"))
|
||||
return None
|
||||
|
||||
def display(self, source: Any, field: str, *, default: str = "") -> str:
|
||||
"""按 admin ``textOf`` 的口径取值:先 ``<field>_text``,再字典/枚举,最后原值。"""
|
||||
|
||||
mapping = source if isinstance(source, Mapping) else {}
|
||||
key = str(field or "").strip()
|
||||
translated = mapping.get(f"{key}_text")
|
||||
if translated in (None, "", []):
|
||||
translated = mapping.get(f"{key}_desc")
|
||||
if translated not in (None, "", []):
|
||||
return _join(translated) + unit_suffix(key, translated)
|
||||
raw = mapping.get(key)
|
||||
if raw in (None, "", [], {}):
|
||||
return default
|
||||
labelled = self.value_label(key, raw)
|
||||
if labelled is not None:
|
||||
return labelled
|
||||
rendered = _join(raw)
|
||||
return rendered + unit_suffix(key, rendered)
|
||||
|
||||
|
||||
_SHARED_INDEX = TermIndex()
|
||||
|
||||
|
||||
def shared_terms() -> TermIndex:
|
||||
"""The process-wide index every readonly screen renders through.
|
||||
|
||||
只读界面只用它翻译展示文案,所以共享一份即可:任何界面取回实时字典后,
|
||||
处方快照那种拿不到 ``*_text`` 的旧数据也能跟着翻译正确。
|
||||
"""
|
||||
|
||||
return _SHARED_INDEX
|
||||
|
||||
|
||||
def merge_shared_dictionaries(dictionaries: Mapping[str, Any] | None) -> None:
|
||||
_SHARED_INDEX.merge(dictionaries)
|
||||
|
||||
|
||||
def _join(value: Any) -> str:
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return "、".join(str(item).strip() for item in value if str(item).strip())
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _options_from_rows(rows: Any) -> dict[str, str]:
|
||||
"""从 ``config/dict`` 的行里取 ``value -> name``。"""
|
||||
|
||||
options: dict[str, str] = {}
|
||||
if isinstance(rows, Mapping):
|
||||
for value, name in rows.items():
|
||||
code = str(value).strip()
|
||||
label = str(name).strip()
|
||||
if code and label:
|
||||
options[code] = label
|
||||
return options
|
||||
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes, bytearray)):
|
||||
return options
|
||||
for row in rows:
|
||||
if not isinstance(row, Mapping):
|
||||
continue
|
||||
code = str(row.get("value", "")).strip()
|
||||
label = str(row.get("name", "")).strip()
|
||||
if code and label:
|
||||
options[code] = label
|
||||
return options
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DICTIONARY_TYPES",
|
||||
"FIELD_DICTIONARIES",
|
||||
"IMAGE_FIELDS",
|
||||
"INTERNAL_FIELDS",
|
||||
"MULTI_VALUE_DICTIONARIES",
|
||||
"SEED_DICTIONARIES",
|
||||
"SINGLE_VALUE_DICTIONARIES",
|
||||
"TermIndex",
|
||||
"format_timestamp",
|
||||
"merge_shared_dictionaries",
|
||||
"shared_terms",
|
||||
"gender_label",
|
||||
"is_timestamp_field",
|
||||
"split_values",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import traceback
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
@@ -96,11 +97,13 @@ class AppUpdateDialog(QDialog):
|
||||
update_accepted = Signal()
|
||||
update_deferred = Signal()
|
||||
download_cancelled = Signal()
|
||||
exit_requested = Signal()
|
||||
|
||||
def __init__(self, offer: UpdateOffer, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.offer = offer
|
||||
self._busy = False
|
||||
self._allow_application_exit = False
|
||||
self.setObjectName("AppUpdateDialog")
|
||||
self.setProperty("businessDialog", True)
|
||||
title = offer.title.strip() or (
|
||||
@@ -190,6 +193,12 @@ class AppUpdateDialog(QDialog):
|
||||
self.cancel_button.clicked.connect(self.download_cancelled.emit)
|
||||
self.cancel_button.hide()
|
||||
buttons.addWidget(self.cancel_button)
|
||||
self.exit_button = QPushButton("退出软件")
|
||||
self.exit_button.setObjectName("UpdateExitButton")
|
||||
self.exit_button.setProperty("variant", "secondary")
|
||||
self.exit_button.clicked.connect(self.exit_requested.emit)
|
||||
self.exit_button.setVisible(offer.force)
|
||||
buttons.addWidget(self.exit_button)
|
||||
self.update_button = QPushButton("立即更新" if offer.can_install else "暂不可安装")
|
||||
self.update_button.setObjectName("UpdateNowButton")
|
||||
self.update_button.setProperty("variant", "primary")
|
||||
@@ -198,7 +207,10 @@ class AppUpdateDialog(QDialog):
|
||||
root.addLayout(buttons)
|
||||
|
||||
if not offer.can_install:
|
||||
self.status_label.setText("后台尚未配置当前系统对应的安装包,请联系管理员上传后再试。")
|
||||
self.status_label.setText(
|
||||
offer.install_unavailable_reason
|
||||
or "后台尚未配置当前系统对应的安装包,请联系管理员上传后再试。"
|
||||
)
|
||||
self.status_label.show()
|
||||
self.update_button.setEnabled(False)
|
||||
|
||||
@@ -207,6 +219,7 @@ class AppUpdateDialog(QDialog):
|
||||
self.update_button.setEnabled((not busy) and self.offer.can_install)
|
||||
self.later_button.setEnabled(not busy)
|
||||
self.cancel_button.setVisible(busy and not self.offer.force)
|
||||
self.exit_button.setEnabled(self.offer.force)
|
||||
self.notes.setEnabled(not busy)
|
||||
|
||||
def show_download_progress(self, received: int, total: int) -> None:
|
||||
@@ -246,8 +259,35 @@ class AppUpdateDialog(QDialog):
|
||||
self.update_button.setEnabled(self.offer.can_install)
|
||||
self.later_button.setEnabled(True)
|
||||
self.cancel_button.hide()
|
||||
self.exit_button.setEnabled(self.offer.force)
|
||||
self.notes.setEnabled(True)
|
||||
|
||||
def set_exiting(self) -> None:
|
||||
"""Lock the prompt while the update worker is being stopped."""
|
||||
|
||||
self._busy = True
|
||||
self.update_button.setEnabled(False)
|
||||
self.later_button.setEnabled(False)
|
||||
self.cancel_button.hide()
|
||||
self.exit_button.setEnabled(False)
|
||||
self.notes.setEnabled(False)
|
||||
self.show_status("正在停止更新并退出软件…")
|
||||
|
||||
def set_apply_committed(self) -> None:
|
||||
"""Prevent an exit request from racing an already-started installer."""
|
||||
|
||||
self._busy = True
|
||||
self.update_button.setEnabled(False)
|
||||
self.later_button.setEnabled(False)
|
||||
self.cancel_button.hide()
|
||||
self.exit_button.setEnabled(False)
|
||||
self.notes.setEnabled(False)
|
||||
|
||||
def allow_application_exit(self) -> None:
|
||||
"""Allow the session's controlled quit without weakening user close guards."""
|
||||
|
||||
self._allow_application_exit = True
|
||||
|
||||
def _accept_update(self) -> None:
|
||||
if self._busy or not self.offer.can_install:
|
||||
return
|
||||
@@ -260,7 +300,7 @@ class AppUpdateDialog(QDialog):
|
||||
self.reject()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
if self.offer.force or self._busy:
|
||||
if not self._allow_application_exit and (self.offer.force or self._busy):
|
||||
event.ignore()
|
||||
return
|
||||
super().closeEvent(event)
|
||||
@@ -285,8 +325,12 @@ class AppUpdateSession(QObject):
|
||||
self.dialog: AppUpdateDialog | None = None
|
||||
self._generation = 0
|
||||
self._dismissed: set[str] = set()
|
||||
self._cancel = False
|
||||
self._cancel_event = Event()
|
||||
self._signals: _TaskSignals | None = None
|
||||
self._active_install_signals: _TaskSignals | None = None
|
||||
self._exit_requested = False
|
||||
self._apply_committed = False
|
||||
self._shutting_down = False
|
||||
|
||||
def schedule(self, *, interactive: bool = False, delay_ms: int = 700) -> None:
|
||||
self._generation += 1
|
||||
@@ -297,6 +341,8 @@ class AppUpdateSession(QObject):
|
||||
)
|
||||
|
||||
def check(self, *, interactive: bool = False, generation: int | None = None) -> None:
|
||||
if self._shutting_down or self._exit_requested:
|
||||
return
|
||||
if generation is None:
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
@@ -355,7 +401,12 @@ class AppUpdateSession(QObject):
|
||||
show_toast(parent, f"检查更新失败:{friendly_error(error)}", "warning", 4200)
|
||||
|
||||
def _on_offer(self, offer: object, *, interactive: bool, generation: int) -> None:
|
||||
if generation != self._generation or not isinstance(offer, UpdateOffer):
|
||||
if (
|
||||
self._shutting_down
|
||||
or self._exit_requested
|
||||
or generation != self._generation
|
||||
or not isinstance(offer, UpdateOffer)
|
||||
):
|
||||
return
|
||||
parent = self._parent_window()
|
||||
if not offer.has_update:
|
||||
@@ -367,18 +418,33 @@ class AppUpdateSession(QObject):
|
||||
return
|
||||
if offer.package is not None and offer.package.type == PACKAGE_TYPE_INNO_SETUP:
|
||||
config = getattr(self.host, "config", None)
|
||||
verify_ssl = bool(getattr(config, "verify_ssl", True))
|
||||
try:
|
||||
validate_installer_download_policy(
|
||||
offer.package.url,
|
||||
verify_ssl=bool(getattr(config, "verify_ssl", True)),
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
except AppUpdateError as error:
|
||||
reason = f"无法自动安装:{error}。"
|
||||
if not verify_ssl:
|
||||
reason += (
|
||||
"请在登录页的“服务器设置”中取消勾选"
|
||||
"“信任自签名证书(仅内网调试)”,保存后重试。"
|
||||
)
|
||||
offer = replace(
|
||||
offer,
|
||||
force=False,
|
||||
package=None,
|
||||
can_install=False,
|
||||
install_unavailable_reason=reason,
|
||||
)
|
||||
except AppUpdateError:
|
||||
offer = replace(offer, force=False, package=None, can_install=False)
|
||||
if offer.force and not is_frozen_install():
|
||||
offer = replace(offer, force=False)
|
||||
self._present(offer)
|
||||
|
||||
def _present(self, offer: UpdateOffer) -> None:
|
||||
if self._shutting_down or self._exit_requested or self._active_install_signals is not None:
|
||||
return
|
||||
parent = self._parent_window()
|
||||
if self.dialog is not None:
|
||||
self.dialog.close()
|
||||
@@ -387,6 +453,7 @@ class AppUpdateSession(QObject):
|
||||
dialog.update_accepted.connect(lambda: self._start_install(dialog, offer))
|
||||
dialog.update_deferred.connect(lambda: self._dismissed.add(offer.latest_version))
|
||||
dialog.download_cancelled.connect(lambda: self._request_cancel(dialog))
|
||||
dialog.exit_requested.connect(lambda: self._request_exit(dialog))
|
||||
self.dialog = dialog
|
||||
dialog.show()
|
||||
dialog.raise_()
|
||||
@@ -395,10 +462,35 @@ class AppUpdateSession(QObject):
|
||||
def _request_cancel(self, dialog: AppUpdateDialog) -> None:
|
||||
if dialog.offer.force:
|
||||
return
|
||||
self._cancel = True
|
||||
self._cancel_event.set()
|
||||
dialog.show_status("正在取消…")
|
||||
|
||||
def _request_exit(self, dialog: AppUpdateDialog) -> None:
|
||||
if not dialog.offer.force or self._apply_committed or self._exit_requested:
|
||||
return
|
||||
self._exit_requested = True
|
||||
self._generation += 1
|
||||
self._cancel_event.set()
|
||||
dialog.set_exiting()
|
||||
if self._active_install_signals is None:
|
||||
self._complete_quit()
|
||||
|
||||
def _complete_quit(self) -> None:
|
||||
dialog = self.dialog
|
||||
if dialog is not None:
|
||||
dialog.allow_application_exit()
|
||||
dialog.close()
|
||||
request_quit = getattr(self.host, "request_quit", None)
|
||||
if callable(request_quit):
|
||||
request_quit()
|
||||
return
|
||||
application = QApplication.instance()
|
||||
if application is not None:
|
||||
QTimer.singleShot(0, application.quit)
|
||||
|
||||
def _start_install(self, dialog: AppUpdateDialog, offer: UpdateOffer) -> None:
|
||||
if self._shutting_down or self._exit_requested or self._active_install_signals is not None:
|
||||
return
|
||||
package = offer.package
|
||||
if package is None:
|
||||
return
|
||||
@@ -414,14 +506,22 @@ class AppUpdateSession(QObject):
|
||||
return
|
||||
config_dir = Path(config.config_dir)
|
||||
verify = bool(getattr(config, "verify_ssl", True))
|
||||
self._cancel = False
|
||||
self._cancel_event.clear()
|
||||
self._apply_committed = False
|
||||
dialog.set_busy(True)
|
||||
dialog.show_download_progress(0, package.size)
|
||||
signals = _TaskSignals()
|
||||
self._signals = signals
|
||||
self._active_install_signals = signals
|
||||
|
||||
def raise_if_cancelled() -> None:
|
||||
if self._cancel_event.is_set():
|
||||
raise AppUpdateError("更新已取消。")
|
||||
|
||||
def job() -> _PreparedUpdate:
|
||||
raise_if_cancelled()
|
||||
workspace = prepare_update_workspace(config_dir, offer.latest_version)
|
||||
raise_if_cancelled()
|
||||
downloaded = workspace / package_filename(package, offer.latest_version)
|
||||
if package.type == PACKAGE_TYPE_INNO_SETUP:
|
||||
validate_installer_download_policy(package.url, verify_ssl=verify)
|
||||
@@ -432,39 +532,94 @@ class AppUpdateSession(QObject):
|
||||
verify=verify,
|
||||
expected_size=package.size,
|
||||
progress=lambda received, total: signals.progress.emit(received, total),
|
||||
cancelled=lambda: self._cancel,
|
||||
cancelled=self._cancel_event.is_set,
|
||||
)
|
||||
raise_if_cancelled()
|
||||
if package.type == PACKAGE_TYPE_INNO_SETUP:
|
||||
signals.status.emit("正在校验 Windows 安装程序…")
|
||||
return _PreparedUpdate(
|
||||
prepared = _PreparedUpdate(
|
||||
package_type=package.type,
|
||||
payload=validate_windows_installer(downloaded),
|
||||
)
|
||||
raise_if_cancelled()
|
||||
return prepared
|
||||
signals.status.emit("正在校验并解压安装包…")
|
||||
extracted = workspace / "payload"
|
||||
safe_extract_zip(downloaded, extracted)
|
||||
return _PreparedUpdate(
|
||||
raise_if_cancelled()
|
||||
prepared = _PreparedUpdate(
|
||||
package_type=package.type,
|
||||
payload=discover_payload(extracted),
|
||||
)
|
||||
raise_if_cancelled()
|
||||
return prepared
|
||||
|
||||
worker = _Task(job, signals)
|
||||
signals.progress.connect(dialog.show_download_progress)
|
||||
signals.status.connect(dialog.show_status)
|
||||
signals.result.connect(lambda prepared: self._finish_install(dialog, prepared))
|
||||
signals.error.connect(lambda error, _tb: self._install_failed(dialog, error))
|
||||
signals.progress.connect(
|
||||
lambda received, total: self._show_download_progress(signals, dialog, received, total)
|
||||
)
|
||||
signals.status.connect(lambda message: self._show_install_status(signals, dialog, message))
|
||||
signals.result.connect(lambda prepared: self._finish_install(signals, dialog, prepared))
|
||||
signals.error.connect(lambda error, _tb: self._install_failed(signals, dialog, error))
|
||||
signals.finished.connect(lambda: self._on_install_finished(signals))
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
|
||||
def _install_failed(self, dialog: AppUpdateDialog, error: Exception) -> None:
|
||||
def _is_current_install(self, signals: _TaskSignals, dialog: AppUpdateDialog) -> bool:
|
||||
return (
|
||||
not self._shutting_down
|
||||
and signals is self._active_install_signals
|
||||
and dialog is self.dialog
|
||||
)
|
||||
|
||||
def _show_download_progress(
|
||||
self,
|
||||
signals: _TaskSignals,
|
||||
dialog: AppUpdateDialog,
|
||||
received: int,
|
||||
total: int,
|
||||
) -> None:
|
||||
if self._is_current_install(signals, dialog) and not self._exit_requested:
|
||||
dialog.show_download_progress(received, total)
|
||||
|
||||
def _show_install_status(
|
||||
self,
|
||||
signals: _TaskSignals,
|
||||
dialog: AppUpdateDialog,
|
||||
message: str,
|
||||
) -> None:
|
||||
if self._is_current_install(signals, dialog) and not self._exit_requested:
|
||||
dialog.show_status(message)
|
||||
|
||||
def _install_failed(
|
||||
self,
|
||||
signals: _TaskSignals,
|
||||
dialog: AppUpdateDialog,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
if not self._is_current_install(signals, dialog):
|
||||
return
|
||||
LOGGER.exception("desktop update failed")
|
||||
if self._cancel and not dialog.offer.force:
|
||||
if self._exit_requested:
|
||||
return
|
||||
if self._cancel_event.is_set() and not dialog.offer.force:
|
||||
dialog.show_error("已取消下载。")
|
||||
self._cancel = False
|
||||
self._cancel_event.clear()
|
||||
return
|
||||
message = str(error) if isinstance(error, AppUpdateError) else friendly_error(error)
|
||||
dialog.show_error(f"更新失败:{message}")
|
||||
|
||||
def _finish_install(self, dialog: AppUpdateDialog, prepared: object) -> None:
|
||||
def _finish_install(
|
||||
self,
|
||||
signals: _TaskSignals,
|
||||
dialog: AppUpdateDialog,
|
||||
prepared: object,
|
||||
) -> None:
|
||||
if (
|
||||
not self._is_current_install(signals, dialog)
|
||||
or self._exit_requested
|
||||
or self._cancel_event.is_set()
|
||||
):
|
||||
return
|
||||
if not isinstance(prepared, _PreparedUpdate):
|
||||
dialog.show_error("安装包准备结果无效。")
|
||||
return
|
||||
@@ -474,14 +629,37 @@ class AppUpdateSession(QObject):
|
||||
else "即将关闭并完成安装…"
|
||||
)
|
||||
dialog.show_status(message, determinate=True)
|
||||
dialog.set_apply_committed()
|
||||
self._apply_committed = True
|
||||
LOGGER.info("desktop update package prepared; starting install helper")
|
||||
try:
|
||||
apply_downloaded_update(
|
||||
prepared.payload,
|
||||
package_type=prepared.package_type,
|
||||
)
|
||||
except AppUpdateError as error:
|
||||
dialog.show_error(str(error))
|
||||
except Exception as error:
|
||||
self._apply_committed = False
|
||||
LOGGER.exception("desktop update helper handoff failed")
|
||||
message = str(error) if isinstance(error, AppUpdateError) else friendly_error(error)
|
||||
dialog.show_error(f"无法启动更新安装:{message}")
|
||||
return
|
||||
application = QApplication.instance()
|
||||
if application is not None:
|
||||
QTimer.singleShot(300, application.quit)
|
||||
LOGGER.info("desktop update helper ready; waiting for worker shutdown")
|
||||
|
||||
def _on_install_finished(self, signals: _TaskSignals) -> None:
|
||||
if signals is not self._active_install_signals:
|
||||
return
|
||||
self._active_install_signals = None
|
||||
if signals is self._signals:
|
||||
self._signals = None
|
||||
if self._exit_requested or self._apply_committed:
|
||||
LOGGER.info("desktop update worker finished; requesting application quit")
|
||||
self._complete_quit()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Invalidate callbacks and ask any update worker to stop."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._shutting_down = True
|
||||
self._generation += 1
|
||||
self._cancel_event.set()
|
||||
|
||||
@@ -89,6 +89,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..diagnosis_terms import shared_terms
|
||||
from ..widgets import (
|
||||
MessageBanner,
|
||||
display_text,
|
||||
@@ -3970,6 +3971,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 +3980,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -41,6 +41,12 @@ 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
|
||||
@@ -898,6 +904,11 @@ class ShellWindow(QMainWindow):
|
||||
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()
|
||||
@@ -1297,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("设置中心")
|
||||
@@ -1898,6 +1907,8 @@ class ShellWindow(QMainWindow):
|
||||
|
||||
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
|
||||
@@ -1907,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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1506,9 +1508,17 @@ def test_sectioned_reply_becomes_a_structured_report(application: QApplication)
|
||||
titles = [
|
||||
label.text()
|
||||
for label in panel.findChildren(QLabel)
|
||||
if label.objectName() == "AiConsultClinicalSectionTitle"
|
||||
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()
|
||||
|
||||
|
||||
@@ -1522,3 +1532,128 @@ def test_sectioned_reply_becomes_a_structured_report(application: QApplication)
|
||||
)
|
||||
def test_unsectioned_replies_stay_plain_text(reply: str, application: QApplication) -> None:
|
||||
assert ai_consult_module.parse_structured_report(reply) is None
|
||||
|
||||
|
||||
def test_report_points_split_into_a_scannable_label_and_body() -> None:
|
||||
split = ai_consult_module.split_report_lead
|
||||
|
||||
assert split("糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖为 8.5 mmol/L。") == (
|
||||
"糖尿病管理缺失",
|
||||
"患者确诊糖尿病3年,空腹血糖为 8.5 mmol/L。",
|
||||
)
|
||||
assert split("结论:由于患者当前未使用任何药物,无需复核。")[0] == "结论"
|
||||
# 冒号前是一整句话,或正文太短,都按普通要点整段显示。
|
||||
assert split("尽管无需复核用药,但基于患者病史,以下临床风险点需重点关注。以下为要点:细节")[0] == ""
|
||||
assert split("空腹血糖: 8.5") == ("", "空腹血糖: 8.5")
|
||||
assert split("没有冒号的一条要点") == ("", "没有冒号的一条要点")
|
||||
|
||||
|
||||
def test_report_section_titles_drop_the_number_the_chip_already_shows() -> None:
|
||||
strip = ai_consult_module._strip_leading_ordinal
|
||||
|
||||
assert strip("1. 当前用药状态评估") == "当前用药状态评估"
|
||||
assert strip("二、临床风险与干预提示") == "临床风险与干预提示"
|
||||
assert strip("建议下一步行动") == "建议下一步行动"
|
||||
|
||||
|
||||
def test_report_body_escapes_markup_and_carries_reading_rhythm() -> None:
|
||||
html = ai_consult_module._reading_html('血糖 <7.0 mmol/L 且 "达标" & 稳定')
|
||||
|
||||
assert "line-height" in html
|
||||
assert "<7.0" in html
|
||||
assert "&" in html
|
||||
assert "<7.0" not in html
|
||||
|
||||
|
||||
def test_structured_report_uses_a_readable_column_width(application: QApplication) -> None:
|
||||
reply = (
|
||||
"针对该患者的用药复核评估如下:\n\n"
|
||||
"### 1. 当前用药状态评估\n"
|
||||
"- 无当前处方药物: 病例数据中明确记录患者目前没有服药,系统内也没有有效处方记录。\n"
|
||||
"- 结论: 患者当前未使用任何药物,不存在药物相互作用或配伍禁忌风险。\n"
|
||||
"### 2. 临床风险与干预提示\n"
|
||||
"- 糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖高于一般控制目标且未接受药物治疗。\n"
|
||||
"重要提示: 本分析不能替代执业医师的面诊与完整病历评估。\n"
|
||||
)
|
||||
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 16:32")
|
||||
|
||||
assert bubble.finalize_clinical_analysis() is True
|
||||
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
|
||||
assert panel is not None
|
||||
# 报告收窄到易读行宽,而不是继续用多栏面板的 1080。
|
||||
assert panel.PREFERRED_MAX_WIDTH == 880
|
||||
assert bubble._bubble_frame.maximumWidth() == 880
|
||||
sections = panel.findChildren(ai_consult_module.QFrame, "AiConsultReportSection")
|
||||
assert len(sections) == 2
|
||||
assert panel.findChildren(ai_consult_module.QFrame, "AiConsultGapRow") == []
|
||||
bubble.deleteLater()
|
||||
|
||||
|
||||
def _fitted_bubble(reply: str, width: int = 900) -> Any:
|
||||
host = QDialog()
|
||||
host.setObjectName("AiConsultDialog")
|
||||
host.setStyleSheet(ai_consult_module.AI_CONSULT_QSS)
|
||||
layout = QVBoxLayout(host)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 16:32")
|
||||
assert bubble.finalize_clinical_analysis() is True
|
||||
layout.addWidget(bubble)
|
||||
layout.addStretch(1)
|
||||
host.setFixedWidth(width)
|
||||
host.show()
|
||||
for _ in range(12):
|
||||
QApplication.processEvents()
|
||||
host.adjustSize()
|
||||
for _ in range(6):
|
||||
QApplication.processEvents()
|
||||
return host, bubble
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reply",
|
||||
[
|
||||
LONG_CLINICAL_REPLY,
|
||||
(
|
||||
"针对该患者的用药复核评估如下:\n\n"
|
||||
"### 1. 当前用药状态评估\n"
|
||||
"- 无当前处方药物: 病例数据中明确记录患者目前没有服药,系统内也没有有效处方记录,"
|
||||
"因此不存在药物相互作用或配伍禁忌风险,无需再做安全性复核。\n"
|
||||
"### 2. 临床风险与干预提示\n"
|
||||
"- 糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖高于一般控制目标且未接受任何药物治疗,"
|
||||
"存在长期高血糖导致微血管及大血管并发症的风险,需要尽快评估。\n"
|
||||
),
|
||||
],
|
||||
ids=["clinical", "structured"],
|
||||
)
|
||||
def test_report_bubbles_report_the_height_they_actually_paint(
|
||||
reply: str,
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""否则聊天区会按高估的高度撑出滚动空白,打开就是一片空白要往上滑。"""
|
||||
|
||||
host, bubble = _fitted_bubble(reply)
|
||||
|
||||
assert bubble.height() > 0
|
||||
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
|
||||
|
||||
def test_risk_block_uses_the_red_alert_palette() -> None:
|
||||
qss = ai_consult_module.AI_CONSULT_QSS
|
||||
|
||||
risk_card = qss.split("QFrame#AiConsultRiskCard {", 1)[1].split("}", 1)[0]
|
||||
assert "#FEF3F2" in risk_card
|
||||
assert "#F1B35C" not in risk_card # 旧的橙色描边
|
||||
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
|
||||
assert "#C0392B" in marker
|
||||
|
||||
|
||||
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
|
||||
qss = ai_consult_module.AI_CONSULT_QSS
|
||||
|
||||
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1)
|
||||
assert len(body) == 2 or "font-size: 13px" in qss
|
||||
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
|
||||
assert "font-size: 13px" in block
|
||||
assert "font-size: 11px" not in block
|
||||
|
||||
@@ -29,6 +29,19 @@ def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
@@ -89,6 +89,36 @@ def test_parse_offer_accepts_explicit_inno_setup_type() -> 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(
|
||||
{
|
||||
@@ -342,6 +372,7 @@ def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
|
||||
restart_exe: Path,
|
||||
helper_log_file: Path,
|
||||
installer_log_file: Path,
|
||||
ready_file: Path,
|
||||
) -> None:
|
||||
spawned.update(
|
||||
script=script,
|
||||
@@ -349,6 +380,7 @@ 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)
|
||||
@@ -362,9 +394,156 @@ def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
|
||||
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,
|
||||
|
||||
@@ -3,17 +3,30 @@
|
||||
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 UpdateOffer, UpdatePackage
|
||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog
|
||||
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) -> UpdateOffer:
|
||||
def _offer(
|
||||
*,
|
||||
force: bool,
|
||||
can_install: bool = True,
|
||||
install_unavailable_reason: str = "",
|
||||
) -> UpdateOffer:
|
||||
package = (
|
||||
UpdatePackage(
|
||||
url="https://cdn.example.com/DoctorWorkstation.zip",
|
||||
@@ -37,6 +50,7 @@ def _offer(*, force: bool, can_install: bool = True) -> UpdateOffer:
|
||||
arch="x64",
|
||||
package=package,
|
||||
can_install=can_install,
|
||||
install_unavailable_reason=install_unavailable_reason,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,13 +60,22 @@ def test_optional_update_dialog_allows_later(application: QApplication | None =
|
||||
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.close()
|
||||
dialog.later_button.click()
|
||||
assert deferred == [True]
|
||||
assert not dialog.isVisible()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_forced_update_dialog_hides_defer_and_blocks_escape(
|
||||
def test_forced_update_dialog_has_explicit_exit_and_blocks_implicit_close(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
@@ -61,10 +84,167 @@ def test_forced_update_dialog_hides_defer_and_blocks_escape(
|
||||
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()
|
||||
dialog.offer = _offer(force=False)
|
||||
dialog._busy = False
|
||||
|
||||
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||
app.processEvents()
|
||||
assert dialog.isVisible()
|
||||
|
||||
dialog.set_busy(True)
|
||||
dialog.show_download_progress(256, 1024)
|
||||
app.processEvents()
|
||||
assert dialog.exit_button.isVisible()
|
||||
assert dialog.exit_button.isEnabled()
|
||||
assert not dialog.update_button.isEnabled()
|
||||
assert not dialog.cancel_button.isVisible()
|
||||
|
||||
dialog.close()
|
||||
app.processEvents()
|
||||
assert dialog.isVisible()
|
||||
QTest.keyClick(dialog, Qt.Key.Key_Escape)
|
||||
app.processEvents()
|
||||
assert dialog.isVisible()
|
||||
|
||||
dialog.allow_application_exit()
|
||||
dialog.close()
|
||||
app.processEvents()
|
||||
assert not dialog.isVisible()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_forced_update_exit_button_emits_dedicated_request(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
requests: list[bool] = []
|
||||
dialog.exit_requested.connect(lambda: requests.append(True))
|
||||
dialog.show()
|
||||
dialog.set_busy(True)
|
||||
app.processEvents()
|
||||
|
||||
dialog.exit_button.click()
|
||||
|
||||
assert requests == [True]
|
||||
assert dialog.isVisible()
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_session_quits_immediately_when_update_has_not_started(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
host = QObject()
|
||||
quit_requests: list[bool] = []
|
||||
host.request_quit = lambda: quit_requests.append(True) # type: ignore[attr-defined]
|
||||
session = AppUpdateSession(host)
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
session.dialog = dialog
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
|
||||
session._request_exit(dialog)
|
||||
|
||||
assert session._cancel_event.is_set()
|
||||
assert quit_requests == [True]
|
||||
assert not dialog.exit_button.isEnabled()
|
||||
assert not dialog.isVisible()
|
||||
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_session_waits_for_update_worker_before_quitting(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
host = QObject()
|
||||
quit_requests: list[bool] = []
|
||||
host.request_quit = lambda: quit_requests.append(True) # type: ignore[attr-defined]
|
||||
session = AppUpdateSession(host)
|
||||
dialog = AppUpdateDialog(_offer(force=True))
|
||||
session.dialog = dialog
|
||||
active_signals = QObject()
|
||||
session._signals = active_signals # type: ignore[assignment]
|
||||
session._active_install_signals = active_signals # type: ignore[assignment]
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
|
||||
session._request_exit(dialog)
|
||||
|
||||
assert session._cancel_event.is_set()
|
||||
assert quit_requests == []
|
||||
assert not dialog.exit_button.isEnabled()
|
||||
assert "退出软件" in dialog.status_label.text()
|
||||
assert dialog.isVisible()
|
||||
|
||||
session._finish_install(active_signals, dialog, object()) # type: ignore[arg-type]
|
||||
assert quit_requests == []
|
||||
|
||||
session._on_install_finished(active_signals) # type: ignore[arg-type]
|
||||
assert quit_requests == [True]
|
||||
assert session._active_install_signals is None
|
||||
assert not dialog.isVisible()
|
||||
|
||||
dialog.hide()
|
||||
dialog.deleteLater()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_unavailable_update_dialog_shows_policy_reason(
|
||||
application: QApplication | None = None,
|
||||
) -> None:
|
||||
app = application or QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
reason = "无法自动安装:自动安装 Windows 更新必须开启 HTTPS 证书校验。"
|
||||
dialog = AppUpdateDialog(
|
||||
_offer(
|
||||
force=False,
|
||||
can_install=False,
|
||||
install_unavailable_reason=reason,
|
||||
)
|
||||
)
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
assert dialog.status_label.text() == reason
|
||||
assert not dialog.update_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_session_explains_disabled_certificate_verification() -> None:
|
||||
host = QObject()
|
||||
host.config = SimpleNamespace(verify_ssl=False) # type: ignore[attr-defined]
|
||||
session = AppUpdateSession(host)
|
||||
session._generation = 1
|
||||
presented: list[UpdateOffer] = []
|
||||
session._present = presented.append # type: ignore[method-assign]
|
||||
offer = replace(
|
||||
_offer(force=True),
|
||||
package=UpdatePackage(
|
||||
url="https://cdn.example.com/DoctorWorkstation-Setup.exe",
|
||||
sha256="a" * 64,
|
||||
size=1024,
|
||||
filename="DoctorWorkstation-Setup.exe",
|
||||
type=PACKAGE_TYPE_INNO_SETUP,
|
||||
),
|
||||
)
|
||||
|
||||
session._on_offer(offer, interactive=True, generation=1)
|
||||
|
||||
assert len(presented) == 1
|
||||
assert presented[0].can_install is False
|
||||
assert presented[0].force is False
|
||||
assert presented[0].package is None
|
||||
assert "开启 HTTPS 证书校验" in presented[0].install_unavailable_reason
|
||||
assert "取消勾选“信任自签名证书" in presented[0].install_unavailable_reason
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""登录后聊天通知的契约:轮询、卡片、点击去向。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui import chat_notifications as chat_module
|
||||
from doctor_workstation.ui.chat_notifications import (
|
||||
CONSULTATION_COMPLETE,
|
||||
PATIENT_LEFT_CHAT,
|
||||
PATIENT_OPENED_CHAT,
|
||||
ChatNotificationCenter,
|
||||
parse_notification,
|
||||
relative_time,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Callable[..., Any],
|
||||
*args: Any,
|
||||
on_success: Callable[[Any], Any] | None = None,
|
||||
on_error: Callable[[Exception], Any] | None = None,
|
||||
on_finished: Callable[[], Any] | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args)
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(chat_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
class _NotifyRepository:
|
||||
def __init__(self, *batches: list[dict[str, Any]]) -> None:
|
||||
self.batches = list(batches)
|
||||
self.calls = 0
|
||||
|
||||
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||
self.calls += 1
|
||||
# 服务端取一次即消费,这里同样只发一次。
|
||||
return self.batches.pop(0) if self.batches else []
|
||||
|
||||
|
||||
def _row(identifier: str, kind: str = PATIENT_OPENED_CHAT, **extra: Any) -> dict[str, Any]:
|
||||
row = {
|
||||
"id": identifier,
|
||||
"type": kind,
|
||||
"doctor_id": 7,
|
||||
"patient_id": "11676",
|
||||
"patient_name": "甘先生",
|
||||
"created_at": 1787882294,
|
||||
}
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
def test_rows_normalize_into_admin_equivalent_cards() -> None:
|
||||
opened = parse_notification(_row("a1"))
|
||||
assert opened is not None
|
||||
assert opened.title == "患者打开会话"
|
||||
assert opened.description == "甘先生 已打开与您的会话,请及时查看"
|
||||
assert opened.action_text == "去接诊台"
|
||||
|
||||
left = parse_notification(_row("a2", PATIENT_LEFT_CHAT))
|
||||
assert left is not None
|
||||
assert left.description == "甘先生 已离开问诊会话页面"
|
||||
|
||||
complete = parse_notification(
|
||||
_row("a3", CONSULTATION_COMPLETE, doctor_name="陈医生", diagnosis_id="8169")
|
||||
)
|
||||
assert complete is not None
|
||||
assert complete.diagnosis_id == 8169
|
||||
assert complete.description == "甘先生 的面诊已由 陈医生 完成,请及时跟进"
|
||||
|
||||
# 缺 id、未知 type、非映射行都不该变成卡片。
|
||||
assert parse_notification(_row("", PATIENT_OPENED_CHAT)) is None
|
||||
assert parse_notification(_row("a4", "unknown_business")) is None
|
||||
assert parse_notification("not-a-row") is None
|
||||
|
||||
|
||||
def test_relative_time_matches_the_admin_wording() -> None:
|
||||
now = 1787882294 + 0.0
|
||||
assert relative_time(1787882294, now=now) == "刚刚"
|
||||
assert relative_time(1787882294 - 120, now=now) == "2 分钟前"
|
||||
assert relative_time(1787882294 - 7200, now=now) == "2 小时前"
|
||||
assert relative_time(0, now=now) == ""
|
||||
|
||||
|
||||
def test_center_polls_once_per_tick_and_never_repeats_a_card(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
host.resize(1280, 800)
|
||||
repository = _NotifyRepository([_row("a1"), _row("a1")], [_row("a2", PATIENT_LEFT_CHAT)])
|
||||
center = ChatNotificationCenter(repository, host)
|
||||
|
||||
center.poll()
|
||||
assert [item.id for item in center.pending] == ["a1"]
|
||||
center.poll()
|
||||
# 同一条通知重复下发也只留一张卡片,新的排在最前面。
|
||||
assert [item.id for item in center.pending] == ["a2", "a1"]
|
||||
assert repository.calls == 2
|
||||
assert center.isVisible() is False or len(center.pending) == 2
|
||||
|
||||
center.dismiss("a1")
|
||||
assert [item.id for item in center.pending] == ["a2"]
|
||||
center.clear()
|
||||
assert center.pending == []
|
||||
host.deleteLater()
|
||||
|
||||
|
||||
def test_center_keeps_only_the_newest_five_cards(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
repository = _NotifyRepository([_row(f"n{index}") for index in range(8)])
|
||||
center = ChatNotificationCenter(repository, host)
|
||||
|
||||
center.poll()
|
||||
|
||||
assert [item.id for item in center.pending] == ["n7", "n6", "n5", "n4", "n3"]
|
||||
host.deleteLater()
|
||||
|
||||
|
||||
def test_activating_a_card_emits_it_once_and_removes_it(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
repository = _NotifyRepository([_row("a1", CONSULTATION_COMPLETE, diagnosis_id=8169)])
|
||||
center = ChatNotificationCenter(repository, host)
|
||||
activated: list[Any] = []
|
||||
center.notification_activated.connect(activated.append)
|
||||
|
||||
center.poll()
|
||||
card = next(iter(center._cards.values()))
|
||||
card.open_button.click()
|
||||
|
||||
assert [item.diagnosis_id for item in activated] == [8169]
|
||||
assert center.pending == []
|
||||
host.deleteLater()
|
||||
|
||||
|
||||
def test_center_stays_silent_when_the_source_cannot_answer(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
class _Failing:
|
||||
def list_chat_notifications(self) -> list[dict[str, Any]]:
|
||||
raise RuntimeError("服务暂时不可用")
|
||||
|
||||
host = QWidget()
|
||||
center = ChatNotificationCenter(_Failing(), host)
|
||||
center.poll()
|
||||
assert center.pending == []
|
||||
|
||||
# 演示仓储与不支持该接口的数据源都不应该报错。
|
||||
ChatNotificationCenter(DemoDoctorRepository(), host).poll()
|
||||
ChatNotificationCenter(object(), host).poll()
|
||||
host.deleteLater()
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
def __init__(self, payload: Any) -> None:
|
||||
self.payload = payload
|
||||
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||
self.get_calls.append((endpoint, dict(params or {})))
|
||||
return self.payload
|
||||
|
||||
|
||||
def test_remote_consumes_the_same_admin_endpoint() -> None:
|
||||
client = _RecordingClient([_row("a1")])
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
rows = repository.list_chat_notifications()
|
||||
|
||||
assert client.get_calls == [("chat/notifications", {})]
|
||||
assert [row["id"] for row in rows] == ["a1"]
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -669,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 == []
|
||||
|
||||
@@ -240,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]] = []
|
||||
@@ -346,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)
|
||||
@@ -375,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)
|
||||
@@ -383,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)
|
||||
@@ -408,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] = []
|
||||
@@ -500,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)
|
||||
@@ -513,6 +561,31 @@ def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
||||
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."""
|
||||
|
||||
|
||||
@@ -52,6 +52,19 @@ class WecomPromotionController extends BaseAdminController
|
||||
)));
|
||||
}
|
||||
|
||||
public function batchSetOperators()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
return $this->fail('权限不足');
|
||||
}
|
||||
|
||||
return $this->run(fn () => $this->success('方案操作人已批量更新', WecomPromotionLogic::batchSetOperators(
|
||||
$this->request->post(),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
)));
|
||||
}
|
||||
|
||||
public function deletePool()
|
||||
{
|
||||
if (!$this->hasPagePermission()) {
|
||||
|
||||
@@ -20,7 +20,12 @@ class WecomAcquisitionCustomerLogic
|
||||
->whereNull('l.delete_time')
|
||||
->where('l.remote_link_id', '<>', '')
|
||||
->where('l.remote_status', 1);
|
||||
self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo));
|
||||
self::applyScope(
|
||||
$query,
|
||||
'l',
|
||||
DataScopeService::getVisibleAdminIds($adminId, $adminInfo),
|
||||
self::operatorPoolIds($adminId)
|
||||
);
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('l.id', $localLinkId);
|
||||
}
|
||||
@@ -56,7 +61,8 @@ class WecomAcquisitionCustomerLogic
|
||||
$page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1));
|
||||
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20)));
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$base = self::customerQuery($params, $visibleIds);
|
||||
$operatorPoolIds = self::operatorPoolIds($adminId);
|
||||
$base = self::customerQuery($params, $visibleIds, $operatorPoolIds);
|
||||
$total = (int) (clone $base)->count();
|
||||
$rows = $base
|
||||
->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name')
|
||||
@@ -71,7 +77,7 @@ class WecomAcquisitionCustomerLogic
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$summaryQuery = self::customerQuery($params, $visibleIds);
|
||||
$summaryQuery = self::customerQuery($params, $visibleIds, $operatorPoolIds);
|
||||
$summaryRow = $summaryQuery->fieldRaw(
|
||||
'COUNT(*) AS customer_count, '
|
||||
. 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, '
|
||||
@@ -98,14 +104,14 @@ class WecomAcquisitionCustomerLogic
|
||||
];
|
||||
}
|
||||
|
||||
private static function customerQuery(array $params, ?array $visibleIds)
|
||||
private static function customerQuery(array $params, ?array $visibleIds, array $operatorPoolIds)
|
||||
{
|
||||
$query = Db::name('qywx_customer_acquisition_customer')->alias('c')
|
||||
->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL')
|
||||
->leftJoin('dept d', 'd.id = c.dept_id')
|
||||
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
|
||||
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
|
||||
self::applyCustomerScope($query, $visibleIds);
|
||||
self::applyCustomerScope($query, $visibleIds, $operatorPoolIds);
|
||||
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('c.promotion_link_id', $localLinkId);
|
||||
@@ -131,35 +137,69 @@ class WecomAcquisitionCustomerLogic
|
||||
* 企微 customer_list 的 state/customer_channel 允许为空,不能因此丢掉已返回的客户;
|
||||
* 同时也不能直接放开全部 owner_admin_id=0 的记录,否则会跨部门泄露未映射客户。
|
||||
*/
|
||||
private static function applyCustomerScope($query, ?array $visibleIds): void
|
||||
private static function applyCustomerScope($query, ?array $visibleIds, array $operatorPoolIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
if ($visibleIds === [] && $operatorPoolIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
|
||||
$query->where(function ($scope) use ($ids): void {
|
||||
$scope->whereIn('c.owner_admin_id', $ids)
|
||||
->whereOr(function ($unmapped) use ($ids): void {
|
||||
$unmapped->where('c.owner_admin_id', 0)
|
||||
->whereIn('l.owner_admin_id', $ids);
|
||||
});
|
||||
$query->where(function ($scope) use ($ids, $operatorPoolIds): void {
|
||||
if ($ids !== []) {
|
||||
$scope->whereIn('c.owner_admin_id', $ids)
|
||||
->whereOr(function ($unmapped) use ($ids): void {
|
||||
$unmapped->where('c.owner_admin_id', 0)
|
||||
->whereIn('l.owner_admin_id', $ids);
|
||||
});
|
||||
if ($operatorPoolIds !== []) {
|
||||
$scope->whereOr('p.id', 'in', $operatorPoolIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$scope->whereIn('p.id', $operatorPoolIds);
|
||||
});
|
||||
}
|
||||
|
||||
private static function applyScope($query, string $alias, ?array $visibleIds): void
|
||||
private static function applyScope($query, string $alias, ?array $visibleIds, array $operatorPoolIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
if ($visibleIds === [] && $operatorPoolIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds))));
|
||||
$ids = array_values(array_unique(array_map('intval', $visibleIds)));
|
||||
$query->where(function ($scope) use ($alias, $ids, $operatorPoolIds): void {
|
||||
if ($ids !== []) {
|
||||
$scope->whereIn($alias . '.owner_admin_id', $ids);
|
||||
if ($operatorPoolIds !== []) {
|
||||
$scope->whereOr($alias . '.pool_id', 'in', $operatorPoolIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$scope->whereIn($alias . '.pool_id', $operatorPoolIds);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function operatorPoolIds(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$ids = Db::name('qywx_promotion_pool_operator')
|
||||
->where('admin_id', $adminId)
|
||||
->whereNull('delete_time')
|
||||
->column('pool_id');
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn ($value): int => (int) $value,
|
||||
$ids
|
||||
), static fn (int $value): bool => $value > 0)));
|
||||
}
|
||||
|
||||
private static function maskIdentifier(string $value): string
|
||||
|
||||
@@ -21,12 +21,14 @@ class WecomPromotionLogic
|
||||
public static function overview(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
self::assertMemberDispatchSchema();
|
||||
self::assertPoolOperatorSchema();
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$operatorPoolIds = self::operatorPoolIds($adminId);
|
||||
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
|
||||
->leftJoin('admin u', 'u.id = p.owner_admin_id')
|
||||
->leftJoin('dept d', 'd.id = p.dept_id')
|
||||
->whereNull('p.delete_time');
|
||||
self::applyOwnerScope($poolsQuery, 'p', $visibleIds);
|
||||
self::applyPoolAccessScope($poolsQuery, 'p', $visibleIds, $operatorPoolIds);
|
||||
$pools = $poolsQuery
|
||||
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.widget_config_json,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
|
||||
->order('p.id', 'desc')
|
||||
@@ -75,7 +77,10 @@ class WecomPromotionLogic
|
||||
}
|
||||
unset($link);
|
||||
|
||||
$memberOptions = self::memberOptions($adminId, $adminInfo);
|
||||
$poolMemberAdminIds = self::poolMemberAdminIds($poolIds);
|
||||
$memberOptions = self::memberOptions($adminId, $adminInfo, $poolMemberAdminIds);
|
||||
$operatorOptions = self::operatorOptions($adminId, $adminInfo);
|
||||
$operatorsByPool = self::poolOperators($poolIds);
|
||||
$adminIdByUserId = [];
|
||||
$memberOptionByAdminId = [];
|
||||
foreach ($memberOptions as $member) {
|
||||
@@ -153,6 +158,15 @@ class WecomPromotionLogic
|
||||
$pool['legacy_link_count'] = $legacyCount;
|
||||
$pool['member_admin_ids'] = array_values(array_unique($memberAdminIds));
|
||||
$pool['member_rules'] = $memberRules;
|
||||
$pool['operators'] = $operatorsByPool[(int) $pool['id']] ?? [];
|
||||
$pool['operator_admin_ids'] = array_values(array_map(
|
||||
static fn (array $operator): int => (int) $operator['id'],
|
||||
$pool['operators']
|
||||
));
|
||||
$pool['is_shared_with_me'] = in_array($adminId, $pool['operator_admin_ids'], true);
|
||||
$pool['can_operate'] = true;
|
||||
$pool['can_manage_access'] = self::ownerInScope((int) ($pool['owner_admin_id'] ?? 0), $visibleIds);
|
||||
$pool['can_delete'] = $pool['can_manage_access'];
|
||||
$pool['dispatch_sync'] = $sync;
|
||||
$pool['skip_verify'] = (int) ($officialLink['skip_verify'] ?? 0);
|
||||
$pool['migration_state'] = count($officialLinks) > 1
|
||||
@@ -171,6 +185,7 @@ class WecomPromotionLogic
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'admin_id' => $adminId,
|
||||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
@@ -184,6 +199,7 @@ class WecomPromotionLogic
|
||||
'pools' => $pools,
|
||||
'links' => $links,
|
||||
'member_options' => $memberOptions,
|
||||
'operator_options' => $operatorOptions,
|
||||
'department_options' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
|
||||
];
|
||||
@@ -204,7 +220,7 @@ class WecomPromotionLogic
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
|
||||
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
|
||||
}
|
||||
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
|
||||
$members = self::resolveMembers((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo, $id);
|
||||
$userIds = array_values(array_column($members, 'userid'));
|
||||
$eligibleUserIds = self::eligibleSelectedUserIds($id, $members);
|
||||
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
|
||||
@@ -388,7 +404,7 @@ class WecomPromotionLogic
|
||||
?QywxCustomerAcquisitionApiService $api = null
|
||||
): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo, false);
|
||||
$links = Db::name('qywx_promotion_link')
|
||||
->where('pool_id', $id)
|
||||
->order('id', 'asc')
|
||||
@@ -479,6 +495,10 @@ class WecomPromotionLogic
|
||||
'delete_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::name('qywx_promotion_pool_operator')->where('pool_id', $id)->whereNull('delete_time')->update([
|
||||
'delete_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->update([
|
||||
'status' => 4,
|
||||
'next_retry' => 0,
|
||||
@@ -490,6 +510,139 @@ class WecomPromotionLogic
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加或移除分流方案共享操作人。
|
||||
* 共享操作人可访问并编辑方案;删除方案和继续授权仍受 owner 数据范围控制。
|
||||
*
|
||||
* @return array{action:string,pool_ids:list<int>,operator_admin_ids:list<int>,affected:int}
|
||||
*/
|
||||
public static function batchSetOperators(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
self::assertPoolOperatorSchema();
|
||||
$poolIds = self::normalizePositiveIds((array) ($params['pool_ids'] ?? []));
|
||||
$operatorAdminIds = self::normalizePositiveIds((array) (
|
||||
$params['operator_admin_ids'] ?? $params['admin_ids'] ?? []
|
||||
));
|
||||
$action = strtolower(trim((string) ($params['action'] ?? 'grant')));
|
||||
if (!in_array($action, ['grant', 'revoke'], true)) {
|
||||
throw new RuntimeException('批量设置方式仅支持添加或移除操作人');
|
||||
}
|
||||
if ($poolIds === []) {
|
||||
throw new RuntimeException('请至少选择一个分流方案');
|
||||
}
|
||||
if (count($poolIds) > 100) {
|
||||
throw new RuntimeException('单次最多设置 100 个分流方案');
|
||||
}
|
||||
if ($operatorAdminIds === []) {
|
||||
throw new RuntimeException('请至少选择一名操作人');
|
||||
}
|
||||
if (count($operatorAdminIds) > 100) {
|
||||
throw new RuntimeException('单次最多设置 100 名操作人');
|
||||
}
|
||||
if (in_array($adminId, $operatorAdminIds, true)) {
|
||||
throw new RuntimeException('不能将当前账号设置为自己的共享操作人');
|
||||
}
|
||||
|
||||
$pools = [];
|
||||
foreach ($poolIds as $poolId) {
|
||||
$pools[$poolId] = self::assertScopedRow(
|
||||
'qywx_promotion_pool',
|
||||
$poolId,
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds !== null) {
|
||||
foreach ($operatorAdminIds as $operatorAdminId) {
|
||||
if (!in_array($operatorAdminId, $visibleIds, true)) {
|
||||
throw new RuntimeException('选择的操作人超出当前角色或部门的数据范围');
|
||||
}
|
||||
}
|
||||
}
|
||||
$adminQuery = Db::name('admin')->whereIn('id', $operatorAdminIds)->whereNull('delete_time');
|
||||
if ($action === 'grant') {
|
||||
$adminQuery->where('disable', 0);
|
||||
}
|
||||
$operatorAdmins = $adminQuery->field('id,root')->select()->toArray();
|
||||
$existingAdminIds = self::normalizePositiveIds(array_column($operatorAdmins, 'id'));
|
||||
if (count($existingAdminIds) !== count($operatorAdminIds)) {
|
||||
throw new RuntimeException($action === 'grant'
|
||||
? '选择的操作人不存在或账号已被禁用'
|
||||
: '选择的操作人不存在');
|
||||
}
|
||||
if ($action === 'grant') {
|
||||
$pagePermissionAdminIds = self::promotionPagePermissionAdminIdSet($operatorAdminIds);
|
||||
foreach ($operatorAdmins as $operatorAdmin) {
|
||||
if ((int) ($operatorAdmin['root'] ?? 0) !== 1
|
||||
&& !isset($pagePermissionAdminIds[(int) ($operatorAdmin['id'] ?? 0)])) {
|
||||
throw new RuntimeException('选择的操作人尚未获得企业微信获客助手页面权限');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$affected = Db::transaction(function () use (
|
||||
$action,
|
||||
$poolIds,
|
||||
$pools,
|
||||
$operatorAdminIds,
|
||||
$adminId,
|
||||
$now
|
||||
): int {
|
||||
$changed = 0;
|
||||
foreach ($poolIds as $poolId) {
|
||||
$ownerAdminId = (int) ($pools[$poolId]['owner_admin_id'] ?? 0);
|
||||
foreach ($operatorAdminIds as $operatorAdminId) {
|
||||
// 方案归属人天然拥有权限,无需写入共享关系。
|
||||
if ($operatorAdminId === $ownerAdminId) {
|
||||
continue;
|
||||
}
|
||||
$query = Db::name('qywx_promotion_pool_operator')
|
||||
->where('pool_id', $poolId)
|
||||
->where('admin_id', $operatorAdminId);
|
||||
$existing = (clone $query)->lock(true)->find();
|
||||
if ($action === 'grant') {
|
||||
$data = [
|
||||
'granted_by_admin_id' => $adminId,
|
||||
'delete_time' => null,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($existing) {
|
||||
if ($existing['delete_time'] !== null) {
|
||||
$changed++;
|
||||
}
|
||||
$query->update($data);
|
||||
} else {
|
||||
Db::name('qywx_promotion_pool_operator')->insert($data + [
|
||||
'pool_id' => $poolId,
|
||||
'admin_id' => $operatorAdminId,
|
||||
'create_time' => $now,
|
||||
]);
|
||||
$changed++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($existing && $existing['delete_time'] === null) {
|
||||
$query->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
});
|
||||
|
||||
return [
|
||||
'action' => $action,
|
||||
'pool_ids' => $poolIds,
|
||||
'operator_admin_ids' => $operatorAdminIds,
|
||||
'affected' => $affected,
|
||||
];
|
||||
}
|
||||
|
||||
private static function isRemoteLinkAlreadyMissing(\Throwable $error): bool
|
||||
{
|
||||
$message = strtolower($error->getMessage());
|
||||
@@ -637,7 +790,11 @@ class WecomPromotionLogic
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$visibleUserIds = null;
|
||||
if ($visibleAdminIds !== null) {
|
||||
$visibleUserIds = array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
|
||||
$visibleUserIds = array_fill_keys(array_column(self::memberOptions(
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
self::poolMemberAdminIds([$poolId])
|
||||
), 'userid'), true);
|
||||
}
|
||||
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
@@ -704,7 +861,11 @@ class WecomPromotionLogic
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
$visibleUserIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo) === null
|
||||
? null
|
||||
: array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
|
||||
: array_fill_keys(array_column(self::memberOptions(
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
self::poolMemberAdminIds([(int) ($row['pool_id'] ?? 0)])
|
||||
), 'userid'), true);
|
||||
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
|
||||
throw new RuntimeException('该获客链接已不在当前角色或部门的数据范围内');
|
||||
}
|
||||
@@ -749,8 +910,8 @@ class WecomPromotionLogic
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{id:int,name:string,userid:string,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
private static function memberOptions(int $adminId, array $adminInfo): array
|
||||
/** @return list<array{id:int,name:string,disable:int,can_grant:bool,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
private static function operatorOptions(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === []) {
|
||||
@@ -758,29 +919,89 @@ class WecomPromotionLogic
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->whereNull('a.delete_time')
|
||||
->where('a.disable', 0)
|
||||
->where('a.work_wechat_userid', '<>', '');
|
||||
->where('a.id', '<>', $adminId);
|
||||
if ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
$admins = $query->field('a.id,a.name,a.root,a.disable')->order('a.disable', 'asc')->order('a.name', 'asc')->order('a.id', 'asc')->select()->toArray();
|
||||
if ($admins === []) {
|
||||
return [];
|
||||
}
|
||||
$adminIds = self::normalizePositiveIds(array_column($admins, 'id'));
|
||||
$departments = self::adminDepartmentMaps($adminIds);
|
||||
$pagePermissionAdminIds = self::promotionPagePermissionAdminIdSet($adminIds);
|
||||
$result = [];
|
||||
foreach ($admins as $admin) {
|
||||
$aid = (int) $admin['id'];
|
||||
$deptIds = array_values(array_unique(array_filter($departments[$aid]['ids'] ?? [])));
|
||||
$hasPagePermission = (int) ($admin['root'] ?? 0) === 1
|
||||
|| isset($pagePermissionAdminIds[$aid]);
|
||||
$result[] = [
|
||||
'id' => $aid,
|
||||
'name' => (string) ($admin['name'] ?? ('账号 ' . $aid)),
|
||||
'disable' => (int) ($admin['disable'] ?? 0),
|
||||
'can_grant' => (int) ($admin['disable'] ?? 0) === 0 && $hasPagePermission,
|
||||
'display_dept_id' => (int) ($deptIds[0] ?? 0),
|
||||
'dept_ids' => $deptIds,
|
||||
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param list<int> $poolIds @return array<int,list<array{id:int,name:string,disable:int,dept_names:list<string>}>> */
|
||||
private static function poolOperators(array $poolIds): array
|
||||
{
|
||||
if ($poolIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('qywx_promotion_pool_operator')->alias('po')
|
||||
->join('admin a', 'a.id = po.admin_id')
|
||||
->whereIn('po.pool_id', $poolIds)
|
||||
->whereNull('po.delete_time')
|
||||
->whereNull('a.delete_time')
|
||||
->field('po.pool_id,a.id,a.name,a.disable')
|
||||
->order('a.name', 'asc')
|
||||
->order('a.id', 'asc')
|
||||
->select()->toArray();
|
||||
$departments = self::adminDepartmentMaps(self::normalizePositiveIds(array_column($rows, 'id')));
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['id'] ?? 0);
|
||||
$result[(int) $row['pool_id']][] = [
|
||||
'id' => $aid,
|
||||
'name' => (string) ($row['name'] ?? ('账号 ' . $aid)),
|
||||
'disable' => (int) ($row['disable'] ?? 0),
|
||||
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return list<array{id:int,name:string,userid:string,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
private static function memberOptions(int $adminId, array $adminInfo, array $extraAdminIds = []): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$allowedIds = $visibleIds === null
|
||||
? null
|
||||
: self::normalizePositiveIds(array_merge($visibleIds, $extraAdminIds));
|
||||
if ($allowedIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->whereNull('a.delete_time')
|
||||
->where('a.disable', 0)
|
||||
->where('a.work_wechat_userid', '<>', '');
|
||||
if ($allowedIds !== null) {
|
||||
$query->whereIn('a.id', $allowedIds);
|
||||
}
|
||||
$admins = $query->field('a.id,a.name,a.work_wechat_userid')->order('a.id', 'asc')->select()->toArray();
|
||||
if ($admins === []) {
|
||||
return [];
|
||||
}
|
||||
$adminIds = array_map('intval', array_column($admins, 'id'));
|
||||
$deptRows = Db::name('admin_dept')->alias('ad')
|
||||
->leftJoin('dept d', 'd.id = ad.dept_id')
|
||||
->whereIn('ad.admin_id', $adminIds)
|
||||
->field('ad.admin_id,ad.dept_id,d.name as dept_name')
|
||||
->order('ad.dept_id', 'asc')->select()->toArray();
|
||||
$departments = [];
|
||||
foreach ($deptRows as $row) {
|
||||
$aid = (int) ($row['admin_id'] ?? 0);
|
||||
$departments[$aid]['ids'][] = (int) ($row['dept_id'] ?? 0);
|
||||
if (trim((string) ($row['dept_name'] ?? '')) !== '') {
|
||||
$departments[$aid]['names'][] = (string) $row['dept_name'];
|
||||
}
|
||||
}
|
||||
$departments = self::adminDepartmentMaps(self::normalizePositiveIds(array_column($admins, 'id')));
|
||||
|
||||
$result = [];
|
||||
$seenUserIds = [];
|
||||
@@ -808,14 +1029,15 @@ class WecomPromotionLogic
|
||||
}
|
||||
|
||||
/** @return list<array{id:int,name:string,userid:string,display_dept_id:int,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
private static function resolveMembers(array $adminIds, int $adminId, array $adminInfo): array
|
||||
private static function resolveMembers(array $adminIds, int $adminId, array $adminInfo, int $poolId = 0): array
|
||||
{
|
||||
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
|
||||
if ($requested === []) {
|
||||
throw new RuntimeException('请至少选择一名当前角色或部门范围内的获客成员');
|
||||
}
|
||||
$available = [];
|
||||
foreach (self::memberOptions($adminId, $adminInfo) as $member) {
|
||||
$existingMemberAdminIds = $poolId > 0 ? self::poolMemberAdminIds([$poolId]) : [];
|
||||
foreach (self::memberOptions($adminId, $adminInfo, $existingMemberAdminIds) as $member) {
|
||||
$available[(int) $member['id']] = $member;
|
||||
}
|
||||
$members = [];
|
||||
@@ -1033,9 +1255,8 @@ class WecomPromotionLogic
|
||||
$existing = Db::name('qywx_promotion_link')->where('remote_link_id', $remoteLinkId)->find();
|
||||
$remoteData = self::remoteColumns($remote, $now);
|
||||
if ($existing) {
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds !== null && !in_array((int) ($existing['owner_admin_id'] ?? 0), $visibleIds, true)) {
|
||||
throw new RuntimeException('该链接已归属其他数据范围');
|
||||
if ((int) ($existing['pool_id'] ?? 0) !== (int) ($pool['id'] ?? 0)) {
|
||||
throw new RuntimeException('该企业微信链接已归属其他分流方案');
|
||||
}
|
||||
$remoteData['delete_time'] = null;
|
||||
Db::name('qywx_promotion_link')->where('id', (int) $existing['id'])->update($remoteData);
|
||||
@@ -1146,37 +1367,183 @@ class WecomPromotionLogic
|
||||
return $encoded === false ? '[]' : $encoded;
|
||||
}
|
||||
|
||||
private static function assertScopedRow(string $table, int $id, int $adminId, array $adminInfo): array
|
||||
private static function assertScopedRow(
|
||||
string $table,
|
||||
int $id,
|
||||
int $adminId,
|
||||
array $adminInfo,
|
||||
bool $allowOperator = true
|
||||
): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('数据不存在');
|
||||
}
|
||||
$query = Db::name($table)->where('id', $id)->whereNull('delete_time');
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds !== null) {
|
||||
if ($visibleIds === []) {
|
||||
throw new RuntimeException('无权访问该数据');
|
||||
}
|
||||
$query->whereIn('owner_admin_id', $visibleIds);
|
||||
}
|
||||
$row = $query->find();
|
||||
$row = Db::name($table)->where('id', $id)->whereNull('delete_time')->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException('数据不存在或已删除');
|
||||
}
|
||||
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$pool = $table === 'qywx_promotion_pool'
|
||||
? $row
|
||||
: ($table === 'qywx_promotion_link'
|
||||
? Db::name('qywx_promotion_pool')
|
||||
->where('id', (int) ($row['pool_id'] ?? 0))
|
||||
->whereNull('delete_time')
|
||||
->find()
|
||||
: null);
|
||||
if ($pool !== null) {
|
||||
if (self::ownerInScope((int) ($pool['owner_admin_id'] ?? 0), $visibleIds)) {
|
||||
return $row;
|
||||
}
|
||||
if ($allowOperator && self::isPoolOperator((int) ($pool['id'] ?? 0), $adminId)) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
throw new RuntimeException($allowOperator
|
||||
? '无权访问或操作该分流方案'
|
||||
: '只有方案原有管理范围内的账号可以执行此操作');
|
||||
}
|
||||
if (!self::ownerInScope((int) ($row['owner_admin_id'] ?? 0), $visibleIds)) {
|
||||
throw new RuntimeException('数据不存在或超出当前权限范围');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
|
||||
/** @param list<int>|null $visibleIds */
|
||||
private static function ownerInScope(int $ownerAdminId, ?array $visibleIds): bool
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($ownerAdminId, $visibleIds, true);
|
||||
}
|
||||
|
||||
/** @param list<int>|null $visibleIds @param list<int> $operatorPoolIds */
|
||||
private static function applyPoolAccessScope(
|
||||
$query,
|
||||
string $alias,
|
||||
?array $visibleIds,
|
||||
array $operatorPoolIds
|
||||
): void {
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
if ($visibleIds === [] && $operatorPoolIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
|
||||
$query->where(function ($scope) use ($alias, $visibleIds, $operatorPoolIds): void {
|
||||
if ($visibleIds !== []) {
|
||||
$scope->whereIn($alias . '.owner_admin_id', $visibleIds);
|
||||
if ($operatorPoolIds !== []) {
|
||||
$scope->whereOr($alias . '.id', 'in', $operatorPoolIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$scope->whereIn($alias . '.id', $operatorPoolIds);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function operatorPoolIds(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizePositiveIds(Db::name('qywx_promotion_pool_operator')
|
||||
->where('admin_id', $adminId)
|
||||
->whereNull('delete_time')
|
||||
->column('pool_id'));
|
||||
}
|
||||
|
||||
private static function isPoolOperator(int $poolId, int $adminId): bool
|
||||
{
|
||||
if ($poolId <= 0 || $adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Db::name('qywx_promotion_pool_operator')
|
||||
->where('pool_id', $poolId)
|
||||
->where('admin_id', $adminId)
|
||||
->whereNull('delete_time')
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
/** @param list<int> $poolIds @return list<int> */
|
||||
private static function poolMemberAdminIds(array $poolIds): array
|
||||
{
|
||||
$poolIds = self::normalizePositiveIds($poolIds);
|
||||
if ($poolIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizePositiveIds(Db::name('qywx_promotion_pool_member')
|
||||
->whereIn('pool_id', $poolIds)
|
||||
->whereNull('delete_time')
|
||||
->column('admin_id'));
|
||||
}
|
||||
|
||||
/** @param list<int> $adminIds @return array<int,array{ids:list<int>,names:list<string>}> */
|
||||
private static function adminDepartmentMaps(array $adminIds): array
|
||||
{
|
||||
if ($adminIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('admin_dept')->alias('ad')
|
||||
->leftJoin('dept d', 'd.id = ad.dept_id')
|
||||
->whereIn('ad.admin_id', $adminIds)
|
||||
->field('ad.admin_id,ad.dept_id,d.name as dept_name')
|
||||
->order('ad.dept_id', 'asc')->select()->toArray();
|
||||
$departments = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['admin_id'] ?? 0);
|
||||
$departments[$aid]['ids'][] = (int) ($row['dept_id'] ?? 0);
|
||||
if (trim((string) ($row['dept_name'] ?? '')) !== '') {
|
||||
$departments[$aid]['names'][] = (string) $row['dept_name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $departments;
|
||||
}
|
||||
|
||||
/** @param list<int> $adminIds @return array<int,true> */
|
||||
private static function promotionPagePermissionAdminIdSet(array $adminIds): array
|
||||
{
|
||||
if ($adminIds === []) {
|
||||
return [];
|
||||
}
|
||||
$menuIds = self::normalizePositiveIds(Db::name('system_menu')
|
||||
->where('perms', 'firstvisit.wecomPromotion/overview')
|
||||
->where('is_disable', 0)
|
||||
->column('id'));
|
||||
if ($menuIds === []) {
|
||||
return [];
|
||||
}
|
||||
$roleIds = self::normalizePositiveIds(Db::name('system_role_menu')
|
||||
->whereIn('menu_id', $menuIds)
|
||||
->column('role_id'));
|
||||
if ($roleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$permittedAdminIds = self::normalizePositiveIds(Db::name('admin_role')
|
||||
->whereIn('role_id', $roleIds)
|
||||
->whereIn('admin_id', $adminIds)
|
||||
->column('admin_id'));
|
||||
|
||||
return array_fill_keys($permittedAdminIds, true);
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function normalizePositiveIds(array $ids): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn ($value): int => (int) $value,
|
||||
$ids
|
||||
), static fn (int $value): bool => $value > 0)));
|
||||
}
|
||||
|
||||
private static function primaryDeptId(int $adminId): int
|
||||
@@ -1210,6 +1577,17 @@ class WecomPromotionLogic
|
||||
}
|
||||
}
|
||||
|
||||
private static function assertPoolOperatorSchema(): void
|
||||
{
|
||||
try {
|
||||
Db::name('qywx_promotion_pool_operator')->limit(1)->find();
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException(
|
||||
'分流方案共享操作人数据表尚未安装,请先执行 server/sql/1.9.20260828/add_wecom_promotion_pool_operators.sql'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function mask(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import t from"./error-BRBhqisw.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-Dwh6tNxD.js";import"./index-CLwe6ftX.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
import t from"./error-D-uPyFBJ.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-Dwh6tNxD.js";import"./index-a_ZxLOOo.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import e from"./error-BRBhqisw.js";import{o,q as r,r as t,v as s}from"./.pnpm-Dwh6tNxD.js";import"./index-CLwe6ftX.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
import e from"./error-D-uPyFBJ.js";import{o,q as r,r as t,v as s}from"./.pnpm-Dwh6tNxD.js";import"./index-a_ZxLOOo.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-Dwh6tNxD.js";import{a as V}from"./doctor-CD6XYjKU.js";import{m as A,_ as M}from"./index-CLwe6ftX.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-Dwh6tNxD.js";import{a as V}from"./doctor-DQJF3mgr.js";import{m as A,_ as M}from"./index-a_ZxLOOo.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-Dwh6tNxD.js";import{ae as V}from"./tcm-BzzBMfBw.js";import{_ as q}from"./index-CLwe6ftX.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-Dwh6tNxD.js";import{ae as V}from"./tcm--WHZCSMq.js";import{_ as q}from"./index-a_ZxLOOo.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-Dwh6tNxD.js";import{af as Y}from"./tcm-BzzBMfBw.js";import{_ as q}from"./index-CLwe6ftX.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-Dwh6tNxD.js";import{af as Y}from"./tcm--WHZCSMq.js";import{_ as q}from"./index-a_ZxLOOo.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as j,T as u,s as y,bi as z,M as v}from"./.pnpm-Dwh6tNxD.js";import M from"./RecordingPlaybackBlock-CMHUdU8f.js";import{U as k}from"./index-BWeecgSM.js";import{i as c,_ as q}from"./index-CLwe6ftX.js";import{aj as K,ak as x,al as A}from"./tcm-BzzBMfBw.js";import"./RecordingVideoPlayer-CDqcv1v9.js";import"./file-CAEOOa9_.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=j,I=L,B=z;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(M,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as j,T as u,s as y,bi as z,M as v}from"./.pnpm-Dwh6tNxD.js";import M from"./RecordingPlaybackBlock-B2R-2s7-.js";import{U as k}from"./index-B8LZ-HCp.js";import{i as c,_ as q}from"./index-a_ZxLOOo.js";import{aj as K,ak as x,al as A}from"./tcm--WHZCSMq.js";import"./RecordingVideoPlayer-Xhx3gz5-.js";import"./file-DWoFbTjb.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=j,I=L,B=z;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(M,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-Dwh6tNxD.js";import{am as q}from"./tcm-BzzBMfBw.js";import{_ as H}from"./index-CLwe6ftX.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-Dwh6tNxD.js";import{am as q}from"./tcm--WHZCSMq.js";import{_ as H}from"./index-a_ZxLOOo.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-DH8EYYzR.js";import"./.pnpm-Dwh6tNxD.js";import"./tcm--WHZCSMq.js";import"./index-a_ZxLOOo.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-hLNJ9AIP.js";import"./.pnpm-Dwh6tNxD.js";import"./tcm-BzzBMfBw.js";import"./index-CLwe6ftX.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-Dwh6tNxD.js";import{p as j}from"./tcm-BzzBMfBw.js";import{i as C}from"./index-CLwe6ftX.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-Dwh6tNxD.js";import{p as j}from"./tcm--WHZCSMq.js";import{i as C}from"./index-a_ZxLOOo.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-Dwh6tNxD.js";import{d as te}from"./dayjs-Cbxn44tS.js";import{ar as ne,as as oe}from"./tcm-BzzBMfBw.js";import{p as re}from"./im-business-message-parse-CVnz1EnV.js";import{_ as le}from"./index-CLwe6ftX.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=se,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:X(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(V,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(Y,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-Dwh6tNxD.js";import{d as te}from"./dayjs-Cbxn44tS.js";import{ar as ne,as as oe}from"./tcm--WHZCSMq.js";import{p as re}from"./im-business-message-parse-CVnz1EnV.js";import{_ as le}from"./index-a_ZxLOOo.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=se,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:X(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(V,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(Y,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-Dwh6tNxD.js";import{t as j,_ as J}from"./index-CLwe6ftX.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-Dwh6tNxD.js";import{t as j,_ as J}from"./index-a_ZxLOOo.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-Dwh6tNxD.js";import{_ as fe}from"./picker-Dr1-lH_q.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-CLwe6ftX.js";import{a as T,d as he}from"./patient-4QJ3YgMJ.js";import{h as ke}from"./perm-CHalL7pT.js";import"./index-De13kk0O.js";import"./index-BhX1mXHA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-DUJcOhOZ.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-Dwh6tNxD.js";import{_ as fe}from"./picker-tazliPuC.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-a_ZxLOOo.js";import{a as T,d as he}from"./patient-DiY8uj2P.js";import{h as ke}from"./perm-tSma3sWL.js";import"./index-CJn0zdCA.js";import"./index-DFvgqKH8.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-BSwVDYZd.js";import"./index-B8LZ-HCp.js";import"./file-DWoFbTjb.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-Dwh6tNxD.js";import{_ as V}from"./index-CLwe6ftX.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-Dwh6tNxD.js";import{_ as V}from"./index-a_ZxLOOo.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-Dwh6tNxD.js";import H from"./RecordingVideoPlayer-CDqcv1v9.js";import{e as I,_ as P}from"./index-CLwe6ftX.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-Dwh6tNxD.js";import H from"./RecordingVideoPlayer-Xhx3gz5-.js";import{e as I,_ as P}from"./index-a_ZxLOOo.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-Dwh6tNxD.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-Dwh6tNxD.js";import{e as ae,_ as ne}from"./index-CLwe6ftX.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-Dwh6tNxD.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-Dwh6tNxD.js";import{e as ae,_ as ne}from"./index-a_ZxLOOo.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-Dwh6tNxD.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-Dwh6tNxD.js";import{a4 as L}from"./tcm-BzzBMfBw.js";import{i as M,_ as S}from"./index-CLwe6ftX.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-Dwh6tNxD.js";import{a4 as L}from"./tcm--WHZCSMq.js";import{i as M,_ as S}from"./index-a_ZxLOOo.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-D8V2sQ39.js";import"./.pnpm-Dwh6tNxD.js";import"./index-De13kk0O.js";import"./index-CLwe6ftX.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-Bqj5kT9K.js";import"./.pnpm-Dwh6tNxD.js";import"./index-CJn0zdCA.js";import"./index-a_ZxLOOo.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-Dwh6tNxD.js";import{_ as L}from"./index-De13kk0O.js";import{i as V}from"./index-CLwe6ftX.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-Dwh6tNxD.js";import{_ as L}from"./index-CJn0zdCA.js";import{i as V}from"./index-a_ZxLOOo.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-Cd2ygMrI.js";import"./.pnpm-Dwh6tNxD.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./add-nav.vue_vue_type_script_setup_true_lang-BHQKdn5u.js";import"./.pnpm-Dwh6tNxD.js";import"./index-BSwVDYZd.js";import"./index-a_ZxLOOo.js";import"./picker-RZLRR_G5.js";import"./index-CJn0zdCA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Dsf3Q7Pd.js";import"./usePaging-DOuAwzL9.js";import"./picker-tazliPuC.js";import"./index-DFvgqKH8.js";import"./index-B8LZ-HCp.js";import"./file-DWoFbTjb.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-Dwh6tNxD.js";import{_ as q}from"./index-DUJcOhOZ.js";import{_ as F}from"./picker-DYZuEtRo.js";import{_ as K}from"./picker-Dr1-lH_q.js";import{c as O,i as r}from"./index-CLwe6ftX.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}个`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}个`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
|
||||
import{o as E,q as p,r as C,s as l,v as a,u as c,bQ as B,C as N,D as d,O as $,b7 as z,b6 as D,I,K as A,L,p as R}from"./.pnpm-Dwh6tNxD.js";import{_ as q}from"./index-BSwVDYZd.js";import{_ as F}from"./picker-RZLRR_G5.js";import{_ as K}from"./picker-tazliPuC.js";import{c as O,i as r}from"./index-a_ZxLOOo.js";const P={class:"bg-fill-light flex items-center w-full p-4 mb-4"},Q={class:"upload-btn w-[60px] h-[60px]"},S={class:"ml-3 flex-1"},T={class:"flex items-center"},j={class:"flex items-center mt-[18px]"},G={class:"flex-1 flex items-center"},H={class:"drag-move cursor-move ml-auto"},Z=E({__name:"add-nav",props:{modelValue:{type:Array,default:()=>[]},max:{type:Number,default:100},min:{type:Number,default:1}},emits:["update:modelValue"],setup(_,{emit:f}){const t=_,V=f,m=R({get(){return t.modelValue},set(s){V("update:modelValue",s)}}),x=()=>{var s;((s=t.modelValue)==null?void 0:s.length)<t.max?m.value.push({image:"",name:"导航名称",link:{},is_show:"1"}):r.msgError(`最多添加${t.max}个`)},v=s=>{var e;if(((e=t.modelValue)==null?void 0:e.length)<=t.min)return r.msgError(`最少保留${t.min}个`);m.value.splice(s,1)};return(s,e)=>{const u=O,g=K,b=z,h=F,k=I,w=D,y=q,U=A;return p(),C("div",null,[l("div",null,[a(c(B),{class:"draggable",modelValue:c(m),"onUpdate:modelValue":e[0]||(e[0]=o=>N(m)?m.value=o:null),animation:"300",handle:".drag-move","item-key":"index"},{item:d(({element:o,index:i})=>[(p(),$(y,{class:"w-[467px]",key:i,onClose:n=>v(i)},{default:d(()=>[l("div",P,[a(g,{modelValue:o.image,"onUpdate:modelValue":n=>o.image=n,"upload-class":"bg-body",size:"60px","exclude-domain":""},{upload:d(()=>[l("div",Q,[a(u,{name:"el-icon-Plus",size:20})])]),_:1},8,["modelValue","onUpdate:modelValue"]),l("div",S,[l("div",T,[e[1]||(e[1]=l("span",{class:"text-tx-regular flex-none mr-3"},"名称",-1)),a(b,{modelValue:o.name,"onUpdate:modelValue":n=>o.name=n,placeholder:"请输入名称"},null,8,["modelValue","onUpdate:modelValue"])]),l("div",j,[e[2]||(e[2]=l("span",{class:"text-tx-regular flex-none mr-3"},"链接",-1)),a(h,{modelValue:o.link,"onUpdate:modelValue":n=>o.link=n},null,8,["modelValue","onUpdate:modelValue"])]),a(w,{label:"是否显示",class:"mt-[18px]"},{default:d(()=>[l("div",G,[a(k,{modelValue:o.is_show,"onUpdate:modelValue":n=>o.is_show=n,"active-value":"1","inactive-value":"0"},null,8,["modelValue","onUpdate:modelValue"]),l("div",H,[a(u,{name:"el-icon-Rank",size:"18"})])])]),_:2},1024)])])]),_:2},1032,["onClose"]))]),_:1},8,["modelValue"])]),l("div",null,[a(U,{type:"primary",onClick:x},{default:d(()=>[...e[3]||(e[3]=[L("添加",-1)])]),_:1})])])}}});export{Z as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as n}from"./index-CLwe6ftX.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
|
||||
import{r as n}from"./index-a_ZxLOOo.js";function e(t){return n.get({url:"/auth.admin/lists",params:t},{ignoreCancelToken:!0})}function i(t){return n.post({url:"/auth.admin/add",params:t})}function r(t){return n.post({url:"/auth.admin/edit",params:t})}function u(t){return n.post({url:"/auth.admin/delete",params:t})}function d(t){return n.get({url:"/auth.admin/detail",params:t})}export{e as a,r as b,u as c,i as d,d as e};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./index-CLwe6ftX.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
|
||||
import{r as e}from"./index-a_ZxLOOo.js";function a(t){return e.get({url:"/article.articleCate/lists",params:t})}function l(t){return e.get({url:"/article.articleCate/all",params:t})}function i(t){return e.post({url:"/article.articleCate/add",params:t})}function c(t){return e.post({url:"/article.articleCate/edit",params:t})}function u(t){return e.post({url:"/article.articleCate/delete",params:t})}function n(t){return e.get({url:"/article.articleCate/detail",params:t})}function s(t){return e.post({url:"/article.articleCate/updateStatus",params:t})}function o(t){return e.get({url:"/article.article/lists",params:t})}function d(t){return e.post({url:"/article.article/add",params:t})}function f(t){return e.post({url:"/article.article/edit",params:t})}function C(t){return e.post({url:"/article.article/delete",params:t})}function p(t){return e.get({url:"/article.article/detail",params:t})}function g(t){return e.post({url:"/article.article/updateStatus",params:t})}export{a,u as b,s as c,c as d,i as e,n as f,p as g,f as h,d as i,l as j,o as k,g as l,C as m};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./index-CLwe6ftX.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
|
||||
import{r as e}from"./index-a_ZxLOOo.js";function r(s){return e.get({url:"/asset.AssetUser/lists",params:s})}function u(s){return e.post({url:"/asset.AssetUser/add",params:s})}function a(s){return e.post({url:"/asset.AssetUser/edit",params:s})}function i(s){return e.post({url:"/asset.AssetUser/delete",params:s})}function o(s){return e.get({url:"/asset.AssetResource/lists",params:s})}function n(s){return e.post({url:"/asset.AssetResource/add",params:s})}function A(s){return e.post({url:"/asset.AssetResource/edit",params:s})}function c(s){return e.post({url:"/asset.AssetResource/delete",params:s})}export{o as a,c as b,A as c,n as d,r as e,a as f,i as g,u as h};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-CVCkEkKY.js";import"./.pnpm-Dwh6tNxD.js";import"./index.vue_vue_type_script_setup_true_lang-Cq5qrGbU.js";import"./picker-Dr1-lH_q.js";import"./index-De13kk0O.js";import"./index-CLwe6ftX.js";import"./index-BhX1mXHA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-DUJcOhOZ.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-Nqd2pOnE.js";import"./.pnpm-Dwh6tNxD.js";import"./picker-tazliPuC.js";import"./index-CJn0zdCA.js";import"./index-a_ZxLOOo.js";import"./index-DFvgqKH8.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-BSwVDYZd.js";import"./index-B8LZ-HCp.js";import"./file-DWoFbTjb.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-DiYI-N-j.js";import"./.pnpm-Dwh6tNxD.js";import"./picker-Dr1-lH_q.js";import"./index-De13kk0O.js";import"./index-CLwe6ftX.js";import"./index-BhX1mXHA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./index-DUJcOhOZ.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./usePaging-DOuAwzL9.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-D3NJwrTZ.js";import"./.pnpm-Dwh6tNxD.js";import"./index-BSwVDYZd.js";import"./index-a_ZxLOOo.js";import"./picker-RZLRR_G5.js";import"./index-CJn0zdCA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Dsf3Q7Pd.js";import"./usePaging-DOuAwzL9.js";import"./picker-tazliPuC.js";import"./index-DFvgqKH8.js";import"./index-B8LZ-HCp.js";import"./file-DWoFbTjb.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-SwwwDk4U.js";import"./.pnpm-Dwh6tNxD.js";import"./add-nav.vue_vue_type_script_setup_true_lang-Cd2ygMrI.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-BIW0PBf5.js";import"./.pnpm-Dwh6tNxD.js";import"./add-nav.vue_vue_type_script_setup_true_lang-BHQKdn5u.js";import"./index-BSwVDYZd.js";import"./index-a_ZxLOOo.js";import"./picker-RZLRR_G5.js";import"./index-CJn0zdCA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Dsf3Q7Pd.js";import"./usePaging-DOuAwzL9.js";import"./picker-tazliPuC.js";import"./index-DFvgqKH8.js";import"./index-B8LZ-HCp.js";import"./file-DWoFbTjb.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";export{o as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./attr.vue_vue_type_script_setup_true_lang-C2wYb6Py.js";import"./.pnpm-Dwh6tNxD.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";import"./index.vue_vue_type_script_setup_true_lang-Cq5qrGbU.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as b,q as c,r as V,s as v,v as t,D as a,Y as x,X as E,u as l,b9 as g,F as k,p as w}from"./.pnpm-Dwh6tNxD.js";import{_ as p}from"./menu-set.vue_vue_type_script_setup_true_lang-CbLdZhC4.js";import"./index-DUJcOhOZ.js";import"./index-CLwe6ftX.js";import"./picker-DYZuEtRo.js";import"./index-De13kk0O.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Cg76y5pa.js";import"./usePaging-DOuAwzL9.js";import"./picker-Dr1-lH_q.js";import"./index-BhX1mXHA.js";import"./index-BWeecgSM.js";import"./file-CAEOOa9_.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";const $=b({__name:"attr",props:{modelValue:{type:Object,default:()=>({nav:[],menu:{}})}},emits:["update:modelValue"],setup(s,{emit:u}){const d=s,i=u,o=w({get(){return d.modelValue},set(n){i("update:modelValue",n)}});return(n,e)=>{const r=E,f=x,_=g;return c(),V(k,null,[e[2]||(e[2]=v("div",{class:"title flex items-center before:w-[3px] before:h-[14px] before:block before:bg-primary before:mr-2"}," pc导航设置 ",-1)),t(_,{class:"mt-4","label-width":"70px"},{default:a(()=>[t(f,{"model-value":"nav"},{default:a(()=>[t(r,{label:"主导航设置",name:"nav"},{default:a(()=>[t(p,{modelValue:l(o).nav,"onUpdate:modelValue":e[0]||(e[0]=m=>l(o).nav=m)},null,8,["modelValue"])]),_:1}),t(r,{label:"菜单设置",name:"menu"},{default:a(()=>[t(p,{modelValue:l(o).menu,"onUpdate:modelValue":e[1]||(e[1]=m=>l(o).menu=m)},null,8,["modelValue"])]),_:1})]),_:1})]),_:1})],64)}}});export{$ as default};
|
||||
import{o as b,q as c,r as V,s as v,v as t,D as a,Y as x,X as E,u as l,b9 as g,F as k,p as w}from"./.pnpm-Dwh6tNxD.js";import{_ as p}from"./menu-set.vue_vue_type_script_setup_true_lang-Go2YUSJ3.js";import"./index-BSwVDYZd.js";import"./index-a_ZxLOOo.js";import"./picker-RZLRR_G5.js";import"./index-CJn0zdCA.js";import"./index.vue_vue_type_script_setup_true_lang-D0KAQf1t.js";import"./article-Dsf3Q7Pd.js";import"./usePaging-DOuAwzL9.js";import"./picker-tazliPuC.js";import"./index-DFvgqKH8.js";import"./index-B8LZ-HCp.js";import"./file-DWoFbTjb.js";import"./index.vue_vue_type_script_setup_true_lang-Cxx3Fcm2.js";const $=b({__name:"attr",props:{modelValue:{type:Object,default:()=>({nav:[],menu:{}})}},emits:["update:modelValue"],setup(s,{emit:u}){const d=s,i=u,o=w({get(){return d.modelValue},set(n){i("update:modelValue",n)}});return(n,e)=>{const r=E,f=x,_=g;return c(),V(k,null,[e[2]||(e[2]=v("div",{class:"title flex items-center before:w-[3px] before:h-[14px] before:block before:bg-primary before:mr-2"}," pc导航设置 ",-1)),t(_,{class:"mt-4","label-width":"70px"},{default:a(()=>[t(f,{"model-value":"nav"},{default:a(()=>[t(r,{label:"主导航设置",name:"nav"},{default:a(()=>[t(p,{modelValue:l(o).nav,"onUpdate:modelValue":e[0]||(e[0]=m=>l(o).nav=m)},null,8,["modelValue"])]),_:1}),t(r,{label:"菜单设置",name:"menu"},{default:a(()=>[t(p,{modelValue:l(o).menu,"onUpdate:modelValue":e[1]||(e[1]=m=>l(o).menu=m)},null,8,["modelValue"])]),_:1})]),_:1})]),_:1})],64)}}});export{$ as default};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user