This commit is contained in:
Your Name
2026-08-28 18:24:37 +08:00
parent 43ad07208f
commit ed48f8be31
383 changed files with 8673 additions and 2222 deletions
+24 -4
View File
@@ -238,10 +238,30 @@ export function wecomPromotionSavePool(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params, timeout: 120000 })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
}
export function wecomPromotionSaveWidget(params: Record<string, unknown>) {
return request.post({ url: '/firstvisit.wecomPromotion/saveWidget', params })
}
export interface WecomPromotionBatchSetOperatorsParams {
pool_ids: number[]
operator_admin_ids: number[]
action: 'grant' | 'revoke'
}
export interface WecomPromotionBatchSetOperatorsResult {
action: 'grant' | 'revoke'
pool_ids: number[]
operator_admin_ids: number[]
affected: number
}
export function wecomPromotionBatchSetOperators(params: WecomPromotionBatchSetOperatorsParams) {
return request.post<WecomPromotionBatchSetOperatorsResult>({
url: '/firstvisit.wecomPromotion/batchSetOperators',
params
})
}
export function wecomPromotionDeletePool(params: { id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
}
@@ -51,28 +51,51 @@
</nav>
<div v-if="activeTab === 'links'" class="tab-content links-tab">
<div class="section-heading">
<div class="section-heading">
<div>
<h2>获客助手分流方案</h2>
<p>选择几名医助就生成几条成员规则官方链接只在方案顶部复制成员行不重复展示链接</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button>
</div>
<div v-if="overview.pools.length" class="pool-layout">
<aside class="pool-sidebar">
<button
v-for="pool in overview.pools"
:key="pool.id"
class="pool-item"
: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>
</aside>
</div>
<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">
<div
v-for="pool in overview.pools"
:key="pool.id"
class="pool-select-row"
:class="{ active: Number(pool.id) === selectedPoolId }"
>
<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">
<div v-if="selectedPool" class="pool-toolbar">
@@ -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,17 +632,29 @@ 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(','), () => {
const ids = overview.pools.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]
}, { immediate: true })
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(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() {
async function loadOverview() {
loading.value = true
try {
const result: any = await wecomPromotionOverview()
@@ -546,9 +663,79 @@ async function loadOverview() {
ElMessage.error(error?.message || '企业微信推广配置加载失败')
} finally {
loading.value = false
}
}
}
}
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 ? {
@@ -1074,12 +1261,17 @@ h1, h2, h3, p { margin: 0; }
.tab-nav button { display: flex; align-items: center; gap: 7px; min-width: 132px; height: 54px; justify-content: center; border: 0; border-bottom: 3px solid transparent; color: #5e6d80; background: transparent; cursor: pointer; font-size: 14px; }
.tab-nav button.active { border-bottom-color: var(--teal); color: #0d8277; font-weight: 700; }
.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; }
.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); }
.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-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%; }
@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; } }
.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; }.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

+158
View File
@@ -0,0 +1,158 @@
# Windows x64 客户端更新 `can_install=False` 诊断
## 结论
“已识别最新版本,但按钮显示「暂不可安装」并提示后台尚未配置安装包”并不等价于只有一种后台配置错误。当前链路把多种拒绝原因压缩为同一个 `UpdateOffer.can_install=False`,而对话框的兜底文案统一归因为“后台未配置”。
对标准 Windows x64 客户端,最值得按以下顺序检查:
1. **服务端没有在 `packages.windows_x64` 取到同时非空的 `url` 和 `sha256`。** 最新版本是全局字段,安装包是按平台另行选择,因此完全可能 `has_update=True``can_install=False`
2. **Windows Inno Setup 包使用了 HTTP(非 localhost)地址,或本机关闭了 HTTPS 证书校验。** 前者会被解析器拒绝;后者会在 UI session 中把一个原本可安装的 offer 二次降级为不可安装。
3. **服务端返回了非 64 位十六进制 SHA-256。** 当前 PHP evaluate 只检查 SHA 是否非空,Python 客户端则做严格格式校验,两端判定可能不一致。
4. **`package` 结构或 `package.type` 不符合客户端契约。** 客户端只接受对象形式的 `package`,类型只接受 `archive` / `inno_setup`;不过当前第一方 PHP 后端会把未知类型归一为 `archive`,当前管理端也只提供这两个选项,所以这通常只发生在旧服务、手工响应或绕过当前保存链路的配置中。
如果更新对话框确实已经出现,则单纯的版本、`enabled`、响应平台/架构不匹配通常可以排除:`AppUpdateSession._on_offer()``offer.has_update=False` 时直接返回,不会展示更新对话框(`app/src/doctor_workstation/ui/dialogs/app_update.py:360-368`)。
## 端到端链路与证据
### 1. 客户端发送的身份
- Windows 被映射为 `windows``app/src/doctor_workstation/services/app_update.py:78-83`)。
- `AMD64``x86_64``x64` 都被映射为 `x64``app/src/doctor_workstation/services/app_update.py:86-92`)。
- 检查请求固定发往 `setting.desktop_workstation/check`,携带 `current_version``platform``arch``app/src/doctor_workstation/services/app_update.py:218-243`)。
- `ApiClient` 会解开 `{code, data}` 信封,`code == 1` 时把 `data` 直接交给更新解析器(`app/src/doctor_workstation/services/api_client.py:515-542`)。
因此标准 64 位 Windows 的请求应为:
```text
GET /adminapi/setting.desktop_workstation/check
?current_version=<当前版本>&platform=windows&arch=x64
```
### 2. 服务端先决定是否有对应平台安装包
- 服务端只声明三个包槽位:`windows_x64``macos_arm64``macos_x64``server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:28-32`)。
- `windows`/`win32`/`win64` 会归一为 `windows``x64`/`amd64`/`x86_64` 会归一为 `x64`,然后拼成 `windows_x64`(同文件 `:125-153`)。
- evaluate 从 `config.packages[windows_x64]` 取包;服务端 `canInstall` 只要求 `url !== '' && sha256 !== ''`(同文件 `:75-86`)。
- `hasUpdate` 独立由启用状态和版本比较决定(`:86-88`),响应中只有 `canInstall` 为真才返回 `package`,最终 `can_install = hasUpdate && canInstall``:90-103`)。
这直接解释了核心现象:`latest_version` 配置正确会让客户端看到新版本,但 `packages.windows_x64.url``packages.windows_x64.sha256` 任一为空,响应仍会是 `has_update: true``package: null``can_install: false`
管理端保存的真实字段是嵌套结构 `packages.windows_x64.{url,sha256,size,filename,type}``admin/src/api/setting/desktop_workstation.ts:5-24``admin/src/views/setting/desktop_workstation/index.vue:330-344`),而不是把 Windows 包放在 macOS 槽位或任意自定义键下。管理页默认 Windows 类型为 `inno_setup`Vue 文件 `:198-218`),上传 `.exe` 也会设置为 `inno_setup` 并在浏览器计算 SHA-256`:292-315`)。
当前服务端校验允许整行安装包为空:空值/空行会继续通过(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:83-110`),所以“自动检测已启用、最新版本有效、Windows 包未完整配置”是被允许保存的状态。外部 URL 缺 SHA 会被拒绝,但站内相对 URL 对应文件不存在且 SHA 为空的情形仍可能保存;服务端只会在本地文件确实存在时自动补 SHA、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
### 3. Python 客户端会再做一轮更严格的判定
`parse_update_offer()` 的规则位于 `app/src/doctor_workstation/services/app_update.py:140-215`
- `package` 必须是字典;`url` 必须非空(`:152-167`)。
- `type` 缺省为 `archive`,只接受 `archive` / `inno_setup``inno_setup` 只允许 Windows`:158-167`)。
- 响应平台、架构必须与请求时的期望值完全一致;同时必须满足服务端 `has_update``enabled`、合法且更高的版本(`:175-184`)。
- SHA-256 必须恰好 64 个十六进制字符(`:185-189`)。
- `inno_setup` URL 必须是 HTTPS,唯一例外是 HTTP localhost/loopback`:190-194`,具体 URL 规则在 `:371-376`)。
- 最终 `can_install` 是服务端 `can_install`、有效 package、有效 SHA、安全安装器传输、`has_update` 五者的合取(`:195-201`)。判失败后返回对象会清除 `package`,并把 `force` 一并降为 false`:202-215`)。
因此若原始 API 返回 `can_install: true`,客户端仍可能因以下字段得到 false:
| 字段/状态 | 拒绝条件 | Windows x64 症状是否吻合 |
|---|---|---|
| `package` | `null`、数组、字符串等非对象 | 是 |
| `package.url` | 空字符串 | 是 |
| `package.sha256` | 空、长度不是 64、包含非十六进制字符 | 是 |
| `package.type` | 非 `archive` / `inno_setup` | 是,但当前第一方后端通常会归一为 `archive` |
| `package.type=inno_setup` + URL | 非 localhost 的 `http://` 或相对 URL | 是 |
| `package.filename` | 空或扩展名不匹配 | **不会在 offer 阶段令 `can_install=False`**;可能在下载/应用阶段失败 |
| `package.size` | 空、0、不可转整数 | **不会在 offer 阶段令 `can_install=False`**;解析为 0 |
| 缺少 `package.type` | 默认 `archive` | **不会单独导致 false**EXE 被误当 archive 会在稍后解压失败 |
一个重要的不一致是:PHP evaluate 目前只检查 SHA 非空(`DesktopWorkstationLogic.php:85`),Python 检查完整格式(`app_update.py:185-189`)。管理端正常保存会校验 64 位十六进制(`DesktopWorkstationValidate.php:147-151`),但旧数据、直接写配置或绕过校验的导入仍可能造成“后端说可安装、客户端说不可安装”。
### 4. UI session 还会因本机 TLS 设置二次降级
即使 `fetch_update_offer()` 返回的 Inno Setup offer 已经 `can_install=True``AppUpdateSession._on_offer()` 仍会以本机 `config.verify_ssl` 调用安装器下载策略;失败时把 `force=False``package=None``can_install=False``app/src/doctor_workstation/ui/dialogs/app_update.py:371-392`)。
本机配置默认 `verify_ssl=True``app/src/doctor_workstation/config.py:88-97``:124-130`),但登录页勾选“信任自签名证书(仅内网调试)”会把它反转为 false(`app/src/doctor_workstation/ui/login.py:831-844``:937-944``:1041-1053`)。`validate_installer_download_policy()` 明确拒绝 `verify_ssl=False`,也拒绝非安全的 Inno Setup URL`app/src/doctor_workstation/services/app_update.py:379-385`)。
这是最容易被误判为“后台没包”的非后台原因。诊断时应比较两个时点:
1. `fetch_update_offer()` 刚返回时是否 `can_install=True`
2. `_on_offer()` 传给 `_present()` 时是否已经变成 false。
若只有第 2 个时点为 false,按当前代码唯一的正常降级入口就是 Inno Setup 下载策略,优先检查 `verify_ssl`
审阅时工作树中已存在一项并非本文创建的未提交改善:`UpdateOffer` 增加 `install_unavailable_reason`,TLS 策略降级时生成具体原因,对话框优先展示该原因(`app_update.py` service `:53-67`UI `:200-206``:379-391`)。兜底文案仍用于服务端/解析阶段没有原因的 `can_install=False`,所以根因判别和补测仍有必要。
### 5. 为什么平台或版本通常不是这个弹窗的根因
- 客户端要求响应 `platform`/`arch` 与请求期望值精确相等,错配会让 `has_update=False``app_update.py:175-184`)。
- session 对 `has_update=False` 直接显示“当前已是最新版本”或静默返回,不创建更新对话框(UI `:360-368`)。
- 当前第一方后端会把 Windows/x64 常见别名归一为响应中的 `windows`/`x64``DesktopWorkstationLogic.php:125-153`)。
所以对于已经出现该对话框的标准 Windows x64 客户端,优先查 `packages.windows_x64`,而不是先怀疑 `AMD64``x64` 名称差异。例外是非标准/旧后端没有按当前契约归一,或实际机器是 Windows ARM64;服务端没有 `windows_arm64` 包槽位,后者会天然没有对应包。
同理,`enabled=false`、最新版本无效、当前版本不低于最新版本都会使 `has_update=False`,与“更新弹窗出现但不可安装”不吻合。源码运行也不是该兜底文案的成因:源码模式只会取消强制属性,点击安装后才显示“当前为源码运行”(UI `:393-394``:401-410`)。
## 最短现场排查路径
1. 用发生问题的当前版本请求实际 API,并保留解包后的 `data`
```text
/adminapi/setting.desktop_workstation/check?current_version=<version>&platform=windows&arch=x64
```
2. 若响应已经是 `can_install:false` 且 `package:null`,读取管理端配置并核对 `packages.windows_x64.url` 与 `.sha256` 是否同时非空;确认包没有误填到 `macos_x64`,也没有只保存最新版本而未保存包。
3. 若响应是 `can_install:true`,核对 `package` 是否为对象、SHA 是否 64 位十六进制、`type` 是否精确为 `archive` 或 `inno_setup`。若为 Inno SetupURL 应为 HTTPS。
4. 若解析后 offer 为 true、弹窗前变成 false,检查客户端 `preferences.json` 中的 `verify_ssl`,以及登录页“信任自签名证书”是否被勾选。
5. 对 Windows 安装程序,期望响应至少应类似:
```json
{
"has_update": true,
"enabled": true,
"platform": "windows",
"arch": "x64",
"can_install": true,
"package": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
"sha256": "<64 lowercase hex chars>",
"size": 123456789,
"filename": "DoctorWorkstation-Setup-Windows-x64-0.2.0.exe",
"type": "inno_setup"
}
}
```
## 现有测试覆盖与缺口
已有客户端测试覆盖:
- 缺 SHA 会拒绝安装(`app/tests/test_app_update.py:43-63`)。
- 合法 HTTPS Inno Setup 会接受(`:66-89`)。
- HTTP Inno Setup 会拒绝(`:92-115`)。
- 未知类型会拒绝(`:118-139`)。
- 旧版本/错误平台响应不会成为 update(`:142-163`)。
- 检查接口会发送 `platform=windows`、`arch=x64``:166-208`)。
- UI 的可选/强制升级基本行为,以及“给定 policy reason 时展示该 reason”(`app/tests/test_app_update_ui.py:49-96`)。
已有 PHP 契约测试覆盖 `win32 + amd64 -> windows_x64`,以及完整 Windows Inno 包可安装(`server/tests/DesktopWorkstationUpdateContractTest.php:17-62`);缺包测试只覆盖 macOS 槽位(`:74-77`)。
建议新增以下测试:
1. **Windows x64 服务端缺字段矩阵(最高优先级)**:分别让 `packages.windows_x64.url` 为空、`sha256` 为空、整个键缺失;断言 `has_update=true`、`package=null`、`can_install=false`。这会直接固化本次症状。
2. **服务端/客户端 SHA 契约一致性**:给 evaluate 一个“非空但不是 64 位十六进制”的 SHA。期望服务端也返回不可安装,或至少用共享 fixture 明确当前由客户端拒绝;避免两端一个 true、一个 false。
3. **`AppUpdateSession` TLS 二次降级**:构造合法 HTTPS `inno_setup` offer,分别设置 `verify_ssl=True/False`,截获 `_present()`true 时保持可安装,false 时断言 `can_install=False` 且原因明确指向证书策略而非后台缺包。
4. **UI 兜底分支**:构造 `can_install=False` 且无 reason 的 offer,断言按钮禁用并展示后台/平台包缺失文案;与已有“注入 policy reason”的测试形成两条独立路径。
5. **解析字段矩阵**:补充 `package=null`、非对象、空 URL、63 位 SHA、含非 hex SHA、缺少 type 默认 archive、Windows archive 使用 HTTP 仍可解析等边界测试。现有测试覆盖了部分,但没有把每个判定条件与原因一一锁定。
6. **跨层契约 fixture**:把 PHP `check` 的 Windows x64 JSON 响应作为 Python `parse_update_offer()` 输入,验证 canonical `windows/x64`、包类型、SHA 和 `can_install` 不发生语义漂移。
7. **管理端 payload 测试**:确认保存时始终发送 `packages.windows_x64` 嵌套对象,上传 `.exe` 后 `type=inno_setup` 且 URL、SHA、文件名、大小均落在同一槽位。
长期看,最稳妥的可观测性是让 `can_install=False` 同时带结构化原因(例如 `missing_package`、`invalid_digest`、`unsupported_type`、`insecure_installer_url`、`tls_verification_disabled`),并在客户端保留原因而不是立即清除所有包信息。这样 UI 不必用一个“后台未配置”文案覆盖所有安全门禁。
## 验证记录
- 根目录 `.trellis/` 不存在;本次按根 `AGENTS.md` 执行,只读检查生产代码,仅新增本文档。
- `app/.venv/Scripts/python.exe -m pytest tests/test_app_update.py tests/test_app_update_ui.py -q`:通过。
- 收集结果:`test_app_update.py` 21 项、`test_app_update_ui.py` 3 项,共 24 项。
- `php tests/DesktopWorkstationUpdateContractTest.php`(工作目录 `server/`):`Desktop workstation update contract: OK`。
+171
View File
@@ -0,0 +1,171 @@
# DEBUG_MODE 与线上 API 固定策略分析
## 结论
当前启动配置不是单一的“环境变量 -> `AppConfig`”链路,而是四层覆盖:
1. `python-dotenv` 先加载 `.env`,且 `override=False`,所以进程环境变量优先于 `.env``app/src/doctor_workstation/config.py:113-118`)。
2. `DOCTOR_API_BASE_URL` 被规范化并传入 `AppConfig``config.py:118-132`)。
3. 用户目录中的 `preferences.json` 再覆盖环境配置,因此当前实际上是 `进程环境/.env < preferences.json``config.py:133-153`)。
4. 登录页另有一套 Qt `QSettings``server/base_url` 会覆盖已经合并好的 `config.api_base_url`,保存或正式登录时再通过 `config_changed` 写回 `AppConfig`,控制器随后保存 `preferences.json` 并重建远端仓库(`app/src/doctor_workstation/ui/login.py:917-927, 1026-1054, 1067-1077, 1079-1103``app/src/doctor_workstation/app.py:438-504`)。
所以,只在 `AppConfig.load()` 里把环境变量替换成线上域名是不完整的。`DEBUG_MODE=False` 时必须同时封住:
- 环境变量 / `.env`
- `preferences.json`
- Qt `QSettings``server/base_url`
- 登录页运行期 `with_updates(api_base_url=...)`
建议目标契约为:
| 模式 | 最终 `AppConfig.api_base_url` | 本地地址设置 |
| --- | --- | --- |
| `DEBUG_MODE=False` | 始终为 `https://admin.zhenyangtang.com.cn/adminapi` | 环境、JSON preference、Qt `QSettings`、登录页编辑均不得改变 |
| `DEBUG_MODE=True` | 保留当前规则:环境/.env 初始化,`preferences.json` 覆盖,登录页可再次编辑 | 完全保留现有可配置行为 |
仓库内线上地址最直接的证据是 `admin/.env.production:1-3`,生产管理端使用 `https://admin.zhenyangtang.com.cn/``admin/vite.config.ts:54-64` 的开发代理也指向同一主机。桌面端的 `normalize_api_base_url()` 会追加 `/adminapi``config.py:67-85`),因此建议常量保存主机根地址,最终有效地址由同一个规范化函数产生。`TongjiUniApp/main.js:3` 当前使用的是 `https://xt.zhenyangtang.com.cn/`,它属于另一客户端,不能替代管理 API 地址。
## 精确修改建议
### 1. 包级发布策略常量
`app/src/doctor_workstation/__init__.py:1-6` 添加两个普通源码常量,并更新 `__all__`。当前该文件已有未提交的版本升级 `1.1.0 -> 1.2.0`,实现时必须保留它,只做增量编辑。
建议名称和取值:
```python
DEBUG_MODE = False
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
__all__ = ["__version__", "DEBUG_MODE", "ONLINE_API_BASE_URL"]
```
`DEBUG_MODE` 不应来自 `DOCTOR_DEBUG_MODE` 或其他环境变量,否则已安装程序仍可被本地环境切回调试地址,直接违反需求。它应是发布代码/构建产物内的策略开关。线上常量不要带查询参数、凭据或 fragment;是否在常量里带 `/adminapi` 均可,但建议只放域名根地址,让 `normalize_api_base_url()` 保持路径的唯一规范化入口。
`config.py` 从包根导入这两个常量不会形成循环:包 `__init__.py` 不导入 `config.py`;现有 `services/app_update.py:21` 也已经用相同方式从包根导入 `__version__`
### 2. 在 AppConfig 的两个入口执行同一发布策略
涉及 `app/src/doctor_workstation/config.py:17-30, 67-85, 113-153, 167-176`
建议在 `normalize_api_base_url()` 之后增加一个小型策略函数(名称可调整):
```python
def effective_api_base_url(candidate: str) -> str:
if not DEBUG_MODE:
# 源码常量无效属于发布错误,应显式失败,不要静默退回空地址。
return normalize_api_base_url(ONLINE_API_BASE_URL)
try:
return normalize_api_base_url(candidate)
except ValueError:
return ""
```
然后在两个入口复用:
1. `AppConfig.load()``api_url` 必须由该函数生成。这样进程环境和 `.env` 在 release 模式下即使含 `http://127.0.0.1` 也只会被读取而不会成为有效 API 地址。
2. `_merge_preferences()``DEBUG_MODE=False` 时必须忽略 JSON 中的 `api_base_url`;也可以允许读取后在 `replace()` 前强制写回 `effective_api_base_url(...)`。关键是**发布策略必须在 preference 合并之后生效**。
3. `with_updates()``DEBUG_MODE=False` 时必须把任何传入的 `api_base_url` 强制改为线上值,而不是仅做 URL 规范化。登录页运行期正是通过此入口更新配置。
实现上可选择“每个入口调用 `effective_api_base_url()`”,也可选择一个 `_apply_runtime_policy()``_merge_preferences()``with_updates()``replace()` 之后统一执行。后者更不容易遗漏,但需要保证两条返回路径都调用它。
不建议用 `AppConfig.__post_init__()` 强制改写所有直接构造的实例。仓库中大量单元/UI 测试直接构造带 `.test` 域名的 `AppConfig`(例如 `app/tests/test_ui_contract.py:408-412`);发布要求针对真实运行配置入口,没必要破坏依赖注入式测试。若希望更强的防御,可提供显式 `apply_runtime_policy()`,由 `load()``with_updates()` 和控制器接收外部 `AppConfig` 时调用。
### 3. 封住 Qt QSettings 的第二套本地 preference
涉及 `app/src/doctor_workstation/ui/login.py:451-468, 788-851, 917-950, 956-969, 1026-1054`
这是满足“本地 preference 不应把它改回调试地址”的必需修改,不是纯 UI 优化:
- `_restore_settings()``DEBUG_MODE=False` 时,`server_url_edit` 只能显示 `config.api_base_url`,不得读取 `self.settings.value("server/base_url", ...)``DEBUG_MODE=True` 时保持现有读取逻辑。
- `_apply_server_settings()``DEBUG_MODE=False` 时使用 `config.api_base_url` 作为 `base_url`,不得信任编辑框或旧 QSettings;也不要把旧调试地址重新写入 `server/base_url``DEBUG_MODE=True` 时保持现状。
- 发布模式下至少将 `server_url_edit` 设为只读。也可隐藏地址编辑入口,但不要无意中一起删除超时设置;是否同时禁止“信任自签名证书”属于另一项发布安全策略。
- `_credential_scope()` 当前优先使用地址编辑框(`login.py:956-969`)。因此必须先确保发布模式下编辑框显示线上地址,否则实际请求虽已被 `with_updates()` 锁到线上,密码却可能错误地按旧调试地址做凭据 scope,造成跨环境凭据恢复混乱。
不建议启动时删除旧的 `server/base_url` QSettings。发布模式忽略它即可,这样将来显式切到 `DEBUG_MODE=True` 时仍能保留既有调试配置,也避免无必要的数据清理。
### 4. bootstrap / repository 侧无需另建域名来源
实际启动入口是 `app/src/doctor_workstation/__main__.py:5-19 -> app.py:1166-1182``main()` 只调用一次 `AppConfig.load()`,随后 `ApplicationController.__init__()` 立即执行 `_rebuild_remote_repository()``app.py:402-424`);后者把 `self.config.api_base_url` 原样传给 `build_repository()``app.py:513-525`),再由 `ApiClient` 规范化为带结尾斜杠的 `/adminapi/` 地址(`services/factory.py:13-45``services/api_client.py:133-148`)。
因此 `app.py``factory.py``api_client.py` 不应复制线上域名常量。只要 `AppConfig` 在完成所有合并后保持不变量,这一段无需修改。
可选的纵深防御:`ApplicationController._on_config_changed()` 在收到一个完整 `AppConfig` payload 时目前直接接受(`app.py:460-489`),只有 dict payload 才经过 `self.config.with_updates()`。若未来可能有第二个发信者,建议让完整 `AppConfig` 同样经过显式 runtime policy;当前唯一连接来自 `LoginWindow``app.py:447`),且其正常路径会先调用 `with_updates()`,所以这不是本次最小改动的阻塞项。
## 当前覆盖顺序与修改后顺序
当前:
```text
.env --(override=False)--> os.environ
|
v
AppConfig(env)
|
v
preferences.json 覆盖 env
|
v
LoginWindow 的 QSettings/server/base_url 覆盖 config
|
v
with_updates -> save_preferences -> rebuild repository
```
建议修改后:
```text
DEBUG_MODE=True : 保持上面的完整可配置链路
DEBUG_MODE=False:
env/.env ----------- ignored for api_base_url ---+
preferences.json --- ignored for api_base_url ---+--> ONLINE_API_BASE_URL
QSettings ---------- ignored for api_base_url ---+ |
runtime update ------ clamped for api_base_url ---+ v
build_repository
```
## 兼容风险与边界
1. **Demo 模式仍可覆盖“是否使用远端仓库”。** 当前 `demo_mode` 默认 `True`,且仍可被环境和 `preferences.json` 覆盖(`config.py:93, 126, 140-153`);登录页也会保存它(`login.py:1002-1006`)。本需求只要求固定 API 域名,所以不应顺手强制 `demo_mode=False`。如果产品语义其实是“发布版必须始终连接线上、不能进入 Demo”,需要单独明确并给 `demo_mode` 增加相同发布策略。
2. **TLS 校验仍可被本地 preference 关闭。** `verify_ssl` 当前可由环境、JSON preference 和 QSettings 改为 `False``config.py:129, 151-152, 174-175``login.py:937-944, 1038-1048`)。固定线上域名但允许关闭证书校验仍有中间人风险。建议产品确认是否在 `DEBUG_MODE=False` 时也强制 `verify_ssl=True`,但它超出“域名不可改”的最小范围。
3. **调试启动脚本不会自动打开源码 DEBUG_MODE。** `app/Debug_DoctorWorkstation.bat:16-23` 只设置独立配置目录、Demo 和日志级别,没有能力改变源码布尔常量。若常量提交为 `False`,脚本仍能跑 Demo,但不能用本地 URL。不要为方便而从环境读取 `DEBUG_MODE`;更安全的方案是开发者本地改为 `True`(不提交),或由明确区分的 debug 构建生成非发布模块。
4. **已有 preference 不需要迁移或删除。** 发布模式会忽略旧调试 URL;切回 debug 后仍按现有优先级恢复。`save_preferences()` 当前把完整 dataclass 写入 JSON`config.py:155-165`),发布运行后可能把线上 URL写回文件,这是可接受的,但测试应覆盖“旧文件存在时首次启动仍直接得到线上 URL”。
5. **凭据按 URL scope 隔离。** `LoginWindow._credential_scope()``TokenStore` 使用 API scope。切到线上后旧调试 token/password 不应被用于线上,这是正确行为;但若只锁 `AppConfig` 而不锁登录页 QSettings,可能出现“请求发往线上、密码却按调试 URL scope 保存/恢复”的错配,因此第 3 节不能省略。
6. **构建 smoke 环境目前注入 loopback API。** Windows/macOS 构建与安装 smoke 分别在 `app/scripts/build_windows.ps1:28-47``build_macos.sh:105-123``smoke_windows_installer.ps1:101-109` 注入 `https://127.0.0.1:9`。发布策略生效后该变量会被忽略。正常 smoke 不应访问线上:更新检查被 `DOCTOR_SMOKE_TEST`/`--smoke-test` 阻断(`ui/dialogs/app_update.py:332-340`),且这些脚本设置 `DOCTOR_DEMO_MODE=true`,会阻断 session restore`app.py:530-540`)。仍建议增加“smoke 期间没有发起线上请求”的断言,避免未来启动流程变化造成生产流量。
7. **不要把线上常量的错误静默转为空地址。** 调试环境输入无效时保持当前的空地址降级合理;源码内线上常量无效则应让测试/构建立即失败,否则发布包只会落入 `_UnconfiguredRepository``app.py:435-436, 513-525`),错误会拖到登录时才暴露。
8. **版本读取兼容。** PyInstaller spec 用正则只读取 `__version__` 行(`app/packaging/doctor_workstation.spec:27-37`)。只要保留当前独立的 `__version__ = "1.2.0"` 赋值,新增常量与 `__all__` 不影响版本生成。
## 建议测试
优先在 `app/tests/test_config.py` 增加以下矩阵:
1. `DEBUG_MODE=False`,环境 `DOCTOR_API_BASE_URL=http://127.0.0.1:8000`,无 preference`AppConfig.load().api_base_url == "https://admin.zhenyangtang.com.cn/adminapi"`
2. `DEBUG_MODE=False`,环境为线上、`preferences.json` 保存调试 URL:最终仍为线上。
3. `DEBUG_MODE=False`,先 `AppConfig.load()`,再 `with_updates(api_base_url="http://localhost:8000")`:最终仍为线上。
4. `DEBUG_MODE=True`,环境提供 A、preference 提供 B:最终仍为 B,证明现有“preference 覆盖 env”行为未回归。
5. `DEBUG_MODE=True`,无 preference,仅环境提供 URL:继续规范化并自动追加 `/adminapi`
6.`ONLINE_API_BASE_URL` 临时 monkeypatch 为非法值且 `DEBUG_MODE=False`:应显式抛错,避免发布误配置静默降级。
`app/tests/test_ui_contract.py` 增加:
1. 发布模式的 QSettings 预置 `server/base_url=http://127.0.0.1:8000`,创建 `LoginWindow` 后地址框显示线上 URL且不可编辑。
2. 发布模式调用 `_save_server_settings()` / 非 Demo `submit()``config_changed` payload 的 `api_base_url` 仍为线上,远端仓库不会以 QSettings 地址重建。
3. 上述场景下 `_credential_scope()` 返回线上 scope,防止凭据落在旧调试 scope。
4. `DEBUG_MODE=True` 重跑同类场景,确认地址框仍从 QSettings 恢复、保存后仍能切换服务器。
在 bootstrap/构建层增加或保留以下回归:
1. `ApplicationController``AppConfig.load()` 启动时,传给 `build_repository()` 的 release base URL 精确为 `https://admin.zhenyangtang.com.cn/adminapi`
2. `--smoke-test``DOCTOR_SMOKE_TEST=1` 下,无论 release URL 是否存在,都不执行更新请求或 token restore 网络调用。
3. 冻结包 smoke 继续通过;原 smoke 脚本中的 loopback `DOCTOR_API_BASE_URL` 被忽略是预期行为,不应把断言写成“最终 URL 等于 127.0.0.1”。
建议验证命令:
```powershell
Set-Location D:\web\zyt\app
uv run pytest tests/test_config.py tests/test_ui_contract.py -q
uv run ruff check src/doctor_workstation/__init__.py src/doctor_workstation/config.py src/doctor_workstation/ui/login.py tests/test_config.py tests/test_ui_contract.py
```
若还修改了 bootstrap 防御或 smoke 契约,再运行相关完整测试和冻结构建门禁;仅本分析任务未修改生产代码、也未执行会连接线上环境的测试。
+143
View File
@@ -0,0 +1,143 @@
# 登录页 DEBUG_MODE 门禁分析
## 结论
当前实现不存在 `DEBUG_MODE`(项目内唯一含 `debug_mode` 的命中只是一个测试函数名)。登录页始终创建并展示“演示模式”和“服务器设置”入口;`AppConfig.demo_mode` 默认又是 `True`,且 `preferences.json` 会覆盖环境配置。因此,仅对两个控件调用 `hide()` 不能满足目标:隐藏的 checkbox 仍可能保持 checked,普通登录仍会自动读取/写回残留 `QSettings`,控制器也会接受伪造或残留的 demo 状态。
建议把 `DEBUG_MODE` 设计成**非用户偏好、不可由 `QSettings``preferences.json` 覆盖的单一运行时门禁**,并在配置加载、LoginWindow 行为和 ApplicationController 三层同时收口:
- `DEBUG_MODE=True`:显示且允许演示仓库切换和登录页服务器设置,保留现有调试行为。
- `DEBUG_MODE=False`:隐藏完整 UI 区块,强制 effective demo 为 `False`,忽略残留服务器 QSettings,登录只能使用 composition root 提供的远程仓库;直接调用槽函数、设置隐藏 checkbox、发信号或构造 demo payload 也不能绕过。
`demo_mode` 只能表示 DEBUG 模式下的默认选择/当前选择,不能再充当“是否有权使用 demo”的授权位。
## 当前实现与风险点
### 1. 配置与持久化
| 位置 | 当前行为 | DEBUG_MODE=False 的风险 |
| --- | --- | --- |
| `src/doctor_workstation/config.py:88-99` | `AppConfig.demo_mode` 默认 `True`,没有 debug gate | 直接构造 `AppConfig()` 就默认允许 demo |
| `config.py:113-133` | `DOCTOR_DEMO_MODE` 未设置时也按 `True` 加载,然后调用 `_merge_preferences()` | 生产未显式注入环境变量时默认 demo;即使环境设为 false,后续偏好仍可覆盖 |
| `config.py:135-153` | `preferences.json` 中所有 dataclass 字段均会合并,包括 `demo_mode``api_base_url``request_timeout``verify_ssl` | 旧 debug profile 的 demo/server 值可覆盖本次受控配置 |
| `config.py:155-165` | `save_preferences()``asdict(self)` 保存完整配置 | demo 切换和服务器设置会持续残留在 JSON 中 |
| `config.py:167-176` | `with_updates()` 可随时把 demo/服务器字段改回调试值 | UI 隐藏后仍可从信号/直接调用修改 |
需要特别区分两套持久化:demo 当前**不写 QSettings**,它通过 `config_changed -> ApplicationController._on_config_changed -> save_preferences()` 写入 `preferences.json`;服务器地址、超时和证书校验先写 `QSettings`,随后同一信号链又写入 `preferences.json`。相关位置是 `ui/login.py:1038-1054``app.py:460-491`
当前 `Debug_DoctorWorkstation.bat:16-24` 只是设置 `DOCTOR_DEMO_MODE=true``DOCTOR_LOG_LEVEL=DEBUG`,没有提供独立 debug capability。若新门禁来自环境,调试启动器应显式设置专用值(例如 `DOCTOR_DEBUG_MODE=true`);普通/冻结启动不得设置。若门禁是构建期常量,则无需让用户偏好或 `.env` 参与。无论采用哪种来源,都不要把它作为普通 `AppConfig` dataclass 字段写入 `preferences.json`
### 2. LoginWindow 组件和信号
| 位置 | 组件/信号链 | 当前行为与缺口 |
| --- | --- | --- |
| `ui/login.py:444-449` | `server_settings_changed(dict)``config_changed(object)``demo_mode_changed(bool)` | `server_settings_changed` 目前仅测试监听;另外两个信号由 controller 监听。所有发射点都无 debug gate |
| `login.py:451-468` | 构造参数、`demo_repository``active_repository` | 只要传入 demo repository 就保留可切换能力;controller 当前总会传入 |
| `login.py:739-755` | “记住密码”行和 `demo_check` | checkbox 始终加入布局;只有 repository 为空时 disabled,不会隐藏 |
| `login.py:770-855` | “或”分隔线、`server_toggle``server_panel` 及 URL/timeout/self-signed/save 子控件 | toggle 始终显示,panel 只是在初始时折叠。若只隐藏 toggle,“或”分隔线和固定 spacing 仍会残留 |
| `login.py:917-948` | `_restore_settings()` | 始终从 QSettings 恢复三项 server 值;只看 `config.demo_mode` 就勾选 demo。`setChecked(True)` 会触发已连接的 `_on_demo_toggled()` |
| `login.py:956-969` | `_credential_scope()` | 优先读取 `server_url_edit`;即便控件隐藏,残留 QSettings URL 仍可改变凭据读取/保存 scope |
| `login.py:1002-1013` | `demo_check.toggled -> _on_demo_toggled()` | 切换 `active_repository`,发射 `demo_mode_changed`,再经 `_emit_config_update` 发射 `config_changed`;没有权限判断 |
| `login.py:1015-1027` | `server_toggle.clicked`、save button | 方法可被直接调用,隐藏控件并不能阻止 panel 展开或保存 |
| `login.py:1029-1054` | `_apply_server_settings()` | 会持久化 QSettings、发射两个配置相关信号;没有权限判断 |
| `login.py:1079-1126` | `submit()` | 直接以隐藏 checkbox 的 checked 状态决定 demo;非 demo 登录会**无条件自动应用服务器控件当前值**,所以旧 QSettings 即便不展开 panel 也会生效 |
| `login.py:1140-1159` | `_set_loading()` | loading 结束会按 `demo_repository is not None` 重新 enable demo,并重新 enable server 子控件;需把 debug gate 合入 enable 条件 |
| `login.py:1177-1216` | 登录成功与凭据保存 | `payload["demo_mode"]` 决定是否保存密码;凭据 scope 又可能来自隐藏的 server edit |
| `login.py:1224-1234` | 证书错误 | 会直接勾选 toggle 并展开 panel;生产隐藏后仍可被错误路径重新显示 |
证书错误文案还在 `ui/widgets.py:376-383` 明确引导用户展开服务器设置、关闭证书校验。非 debug 模式必须改为不引用隐藏入口的运维提示,否则 UI 和文案契约矛盾。
### 3. ApplicationController 与登录可信边界
| 位置 | 当前行为 | 需要的防线 |
| --- | --- | --- |
| `app.py:402-423` | 总是实例化 `DemoDoctorRepository()``current_demo_mode=config.demo_mode` | 非 debug 不创建/不暴露 demo repository,并强制 current demo false |
| `app.py:438-455` | 总把 demo repository 传给 LoginWindow;复用窗口时信任 `demo_check` | 传递显式 gate;非 debug 复用时重置 checkbox/active repository |
| `app.py:460-506` | 接受 `demo_mode` 及全部 server 字段,保存 preferences 并重建 repository | 非 debug 拒绝 debug-only changes,避免伪造 `config_changed` 绕过 UI |
| `app.py:508-511` | 任意 `demo_mode_changed(True)` 都会设置 current demo 并取消 session restore | 非 debug 忽略/纠正 true |
| `app.py:530-540` | `config.demo_mode=True` 会跳过生产 token restore | 必须基于经过门禁归一化的 effective demo;残留 preference 不能阻止 restore |
| `app.py:653-681` | 信任成功 payload 中的 `demo_mode` 与 repository | 非 debug 必须拒绝 demo payload/repository,或无条件把 effective demo 归零;这是 UI 之外的最后可信边界 |
| `app.py:782-792` | `current_demo_mode` 决定是否打开离线 demo 视频窗 | 前述边界不收口时,伪造状态还会扩散到登录后的功能 |
## 精确修改建议
### A. 建立单一、不可持久化的 capability
`src/doctor_workstation/config.py` 定义唯一 `DEBUG_MODE`(或等价只读函数),由受控构建/专用调试启动器决定。不要从 `QSettings` 读取,不要让 `preferences.json` 覆盖,也不要随 `asdict(AppConfig)` 保存。
配置加载完成后必须做一次最终归一化:`effective_demo_mode = DEBUG_MODE and requested_demo_mode`。在 `DEBUG_MODE=False` 时,`_merge_preferences()` 至少忽略 `demo_mode`;若“服务器设置不可用”意味着生产连接完全由受控环境提供,还应同时忽略偏好中的 `api_base_url``request_timeout``verify_ssl`,否则旧登录页设置虽然 UI 不可见,仍会从 JSON 生效。`with_updates()` 也应拒绝或丢弃非 debug 下对这些 debug-only 字段的修改。
推荐把 debug capability 显式传给 `ApplicationController`/`LoginWindow` 或保存为只读实例属性,便于测试 True/False 两条路径。不要在多个模块各自复制一个可 monkeypatch 的常量,否则测试或运行时可能出现 config 判 false、UI 判 true 的分裂状态。
### B. LoginWindow:可见性和行为同时门禁
`ui/login.py:451-468` 记录 `self.debug_mode`,并把 `self.demo_repository` 设为 `demo_repository if debug_mode else None`。建议仍构造具名控件以保持测试和代码引用稳定,但所有状态转换都使用 `self.debug_mode` 判断。
UI 结构建议:
1. `demo_check` 仅在 debug 时 visible,并且 enabled 条件为 `debug_mode and demo_repository is not None and not loading`
2.`login.py:770-855` 的“或”分隔线、server toggle、panel 和上下 spacing 包进一个 `self.debug_server_section` QWidget;整个 section 仅在 debug 时 visible。单独隐藏 `server_toggle` 会留下“或”和空白。
3. panel 初始仍折叠;debug true 时保持现有 toggle 行为。
行为防线建议:
1. `_restore_settings()`:非 debug 不读取 `server/*` QSettings,不恢复 demo,明确令 demo unchecked、active repository 为 production repository;服务器控件若仍构造,只从受控 `config` 填充。是否删除旧键是迁移策略,**忽略它们才是安全要求**。
2. `_credential_scope()`:非 debug 始终从受控 config URL 取 scope,不读取隐藏的 `server_url_edit`
3. `_on_demo_toggled(True)`:非 debug 立即用 signal blocker 恢复 unchecked/production repository,然后 return;不得发 `demo_mode_changed``config_changed`
4. `_toggle_server_panel()``_save_server_settings()``_apply_server_settings()`:非 debug 强制 panel 关闭且不写 QSettings、不发 `server_settings_changed/config_changed`。直接调用也必须无效。
5. `submit()`:用 `demo_mode = self.debug_mode and self.demo_check.isChecked()`,并从这个 effective 值选择 repository。非 debug 跳过 `_apply_server_settings()`,只使用 composition root 已构造的 remote repository;否则会再次应用隐藏控件中的旧值。
6. `_set_loading()`:所有 demo/server enabled 状态与 `self.debug_mode` 做 AND,防止 loading 完成后重新激活。
7. `_on_login_error()`:仅 debug 时自动展开 certificate panel;非 debug 保持 section 隐藏,并显示“请联系管理员检查受控服务器/证书配置”之类不提供绕过证书校验的文案。
### C. Controller:不要信任 UI 状态或 payload
`app.py:402-423` 以同一 capability 计算 effective state;非 debug 最好根本不实例化 `DemoDoctorRepository``_show_login()` 显式传 gate,窗口复用时不要读取隐藏 checkbox 决定 repository。
`_on_config_changed()` 必须再次过滤 demo/server debug-only 字段;`_on_demo_mode_changed()` 非 debug 不接受 true`_begin_session_restore()` 不得因未经门禁的旧 `config.demo_mode` 跳过;`_on_login_succeeded()` 应把 demo capability 作为可信边界,非 debug 收到 `demo_mode=True` 或 demo repository 时拒绝进入 shell并清理 session,而不是静默接受 payload。这样即使未来有其他代码直接调用槽函数,也不能重新开启演示路径。
## `tests/test_ui_contract.py` 现状与调整
实际文件是 `app/tests/test_ui_contract.py``app/tests` 下没有 `conftest.py`;这里使用的 `tmp_path`/`monkeypatch` 是 pytest 内置 fixture,相关 helper 都定义在测试函数内。
现有相关契约:
- `test_ui_contract.py:230-273`:真实 demo 登录,证明 `config.demo_mode=True` 会勾选 checkbox、使用空账号密码登录 demo,并发出 demo payload;未覆盖 debug capability。
- `test_ui_contract.py:276-337`:同一 QSettings 跨窗口恢复账号/密码;不涉及 demo。
- `test_ui_contract.py:340-375`:服务器 panel 在最小窗口的布局。
- `test_ui_contract.py:378-399`:函数名虽含 `debug_mode`,实际仅验证 self-signed 值写入 QSettings,没有任何 `DEBUG_MODE` 判断。
- `test_ui_contract.py:402-455`:普通登录前自动应用 server 值、经 `config_changed` 换成新 repository。
- `test_ui_contract.py:458-463`:证书错误文案指向服务器设置。
- `test_ui_contract.py:495-513`:证书错误会自动展开服务器 panel;这个契约只应在 debug true 成立。
引入 gate 后,`230``340``378``402``495` 这几组依赖 demo/server 的测试都应显式运行在 `DEBUG_MODE=True`,避免它们因测试默认值偶然通过。不要新增“把 demo 写入 QSettings”的契约;当前 demo 的持久化源是 AppConfig/preferences,目标反而要求非 debug 忽略该残留值。
## 建议回归测试矩阵
### `tests/test_ui_contract.py`
1. **debug true 可见且可用**show 窗口后断言 demo checkbox、完整 server section/toggle 可见;原 demo 登录、panel 几何、自签名保存、登录前应用服务器设置均继续通过。
2. **debug false 无视觉残件**:断言 demo checkbox、`debug_server_section`(包括“或”分隔线)、toggle、panel 都不可见;demo unchecked`active_repository is remote_repository`
3. **残留 QSettings 不生效**:预写 `server/base_url=旧地址``server/read_timeout``server/verify_ssl=false`,用 debug false 构造窗口;断言 credential scope/实际登录 repository 使用受控 configQSettings 值未被 `_apply_server_settings()` 写回或发射成配置更新。
4. **直接调用不能绕过**debug false 下程序化 `demo_check.setChecked(True)``_on_demo_toggled(True)``_toggle_server_panel(True)``_save_server_settings()`;断言仍 unchecked、production repository、panel hidden`demo_mode_changed``server_settings_changed``config_changed` 均无 debug 更新。
5. **提交强制 production**:给 debug false 窗口同时传 remote 和 demo repository,并让 stale config 的 `demo_mode=True`;输入账号密码后立即执行 worker,断言只有 remote `login()` 被调用,payload `demo_mode=False`
6. **证书错误分模式**:debug true 仍自动展开并给出 self-signed 指引;debug false 不展开/不显示 section,错误文案不再提隐藏的“服务器设置”或关闭证书校验。
7. **loading 不重启入口**debug false 执行 `_set_loading(True)``_set_loading(False)`,断言 demo/server 控件持续 hidden + disabled。
### `tests/test_config.py`
1. 在隔离 `DOCTOR_CONFIG_DIR` 写入旧 `preferences.json`(至少 `demo_mode:true`);DEBUG false 加载后必须 `demo_mode is False`,即使 `DOCTOR_DEMO_MODE=true` 也不能越权。
2. DEBUG true 时确认 `DOCTOR_DEMO_MODE`/允许的 demo preference 仍能选择默认 demo 状态。
3. 若生产服务器配置要求环境权威,再写入旧 JSON server 字段,断言 DEBUG false 仍采用环境的 URL/timeout/verify_ssl。
4. `with_updates(demo_mode=True)` 在 DEBUG false 下不能产生 effective demo true;同理覆盖 server debug-only 更新的策略。
### `tests/test_ui_contract.py` 中的 controller 边界(或拆到 controller 专属测试)
1. DEBUG false 时 `_begin_session_restore()` 不因 stale `config.demo_mode=True` 而跳过远程恢复。
2. DEBUG false 时直接调用 `_on_demo_mode_changed(True)` 不改变 `current_demo_mode`
3. DEBUG false 时把 `demo_mode=True`/demo repository 的伪造 payload 传给 `_on_login_succeeded()`,断言不能创建 ShellWindow。
若采用专用 `DOCTOR_DEBUG_MODE` 环境变量,还应在 `tests/test_one_click_entrypoints.py:102-108` 增加调试启动器显式开启、普通启动器/打包入口不开启的静态契约,并同步 `.env.example:8-17``README.md:56-72`,避免继续把 `DOCTOR_DEMO_MODE=true` 描述成足以启用演示能力。
## 最小验收标准
非 debug 模式应同时满足以下可观察结果:登录页看不到 demo、服务器入口、“或”分隔线或相关空白;旧 demo preference 不能阻止远程 token restore;旧 server QSettings 不能改变 URL、timeout、TLS 校验或凭据 scope;程序化调用隐藏控件/槽函数/信号也不能切换仓库或进入 demo shell。只有这四层都成立,才不是单纯的视觉隐藏。
+79
View File
@@ -0,0 +1,79 @@
# Windows 1.2.0 正式包重建结果(DEBUG_MODE=False
- 执行日期:2026-08-28Asia/Shanghai
- 工作目录:`D:\web\zyt\app`
- 总体结果:成功
- 生产源码修改:无(本次仅重建产物并新增本记录)
## 发布配置核对
打包前解析 `src/doctor_workstation/__init__.py`,确认:
- `__version__ = "1.2.0"`
- `DEBUG_MODE = False`
- `ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"`
打包完成后,使用 PyInstaller 的归档读取器打开
`dist/DoctorWorkstation/DoctorWorkstation.exe` 内嵌的 `PYZ.pyz`,提取
`doctor_workstation` 模块并检查其顶层字节码常量,得到:
- `STORE_NAME __version__` 前的常量为 `"1.2.0"`
- `STORE_NAME DEBUG_MODE` 前的常量为 `False`
- `STORE_NAME ONLINE_API_BASE_URL` 前的常量为
`"https://admin.zhenyangtang.com.cn"`
因此本次重建的 EXE 已包含正式模式和线上 API 域名配置。
Windows 版本资源也核对为:应用 EXE 的 `FileVersion` / `ProductVersion`
均为 `1.2.0`,安装器的 `ProductVersion``1.2.0`
## 打包
执行命令:
```powershell
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1
```
- 退出码:`0`
- Vue/Vite 前端构建:通过(43 个模块)
- PyInstaller 6.22.0 / Python 3.12.12:通过
- Frozen Qt multimedia file gate:通过
- Frozen Qt multimedia smoke gate:通过
- Frozen application entry smoke gate:通过
- 7-Zip ZIP 创建:通过(`Everything is Ok`
- Inno Setup 6.7.3:通过(`Successful compile (175.078 sec)`
构建过程出现 Vite 大 chunk 提示、一个可选 Qt QML 插件缺失提示以及
Windows 系统 DLL 解析警告;它们均未阻断构建,且上述冻结产物门禁全部通过。
## 安装器烟测
执行命令:
```powershell
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
```
- 退出码:`0`
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
- 隔离测试目录:
`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-4988cc3cc71346e49925445b9ae57fb9`
- 覆盖项:安装器图标、静默安装、已安装 EXE 启动、静默卸载及卸载残留检查
## 产物与校验
| 文件 | 大小(字节) | 大小(MiB | SHA-256 |
| --- | ---: | ---: | --- |
| `dist/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,912,808 | 155.366 | `D0A9EED88F42F7FBBF31920D7B1ED82BD241481E98964F42313DF386CB746C0A` |
| `dist/DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,810,092 | 220.118 | `9FAA8596AE0D233626B3D44676C51E6DB0BA3496B12D2EE6C984ABC0E51EB01B` |
| `dist/SHA256SUMS.txt` | 220 | 0.000 | `2DA56F76458ACE040597916C68B265C737D270B335EA73F21C4A248A2FA41B78` |
`dist/SHA256SUMS.txt` 内容:
```text
D0A9EED88F42F7FBBF31920D7B1ED82BD241481E98964F42313DF386CB746C0A DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
9FAA8596AE0D233626B3D44676C51E6DB0BA3496B12D2EE6C984ABC0E51EB01B DoctorWorkstation-Windows-x64-1.2.0.zip
```
独立使用 `Get-FileHash -Algorithm SHA256` 重算 EXE 和 ZIP 后,两项均与
`SHA256SUMS.txt` 逐字符匹配;清单恰好包含两条记录。
+57
View File
@@ -0,0 +1,57 @@
# Debug mode full-suite verification
Verification date: 2026-08-28 (Asia/Shanghai)
Scope: read-only verification of the current shared worktree under `D:\web\zyt\app`. No production source was modified.
## Pytest
Command:
```powershell
.venv\Scripts\python.exe -m pytest
```
- Exit code: `1`
- Result: `2 failed, 684 passed`
- Total collected/executed: `686`
- Duration: `3195.85s` (`0:53:15`)
Failures:
1. `tests/test_diagnosis_order_video_visual.py::test_video_table_embeds_player_and_preserves_row_bound_upload`
- Assertion location: `tests/test_diagnosis_order_video_visual.py:429`
- Assertion: `table.rowHeight(0) >= playback.required_table_row_height()`
- Actual: row height `246`; required row height `250`.
2. `tests/test_reception_parity_ui.py::test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geometry`
- Assertion location: `tests/test_reception_parity_ui.py:1575`
- Assertion: `expand_button.size().width() == expand_button.size().height() == 28`
- Actual: `QSize(28, 34)`; expected `QSize(28, 28)`.
## Ruff
Command:
```powershell
.venv\Scripts\python.exe -m ruff check src tests
```
- Exit code: `1`
- Result: `9` errors, all `F401` unused imports and all reported as fixable with `--fix`.
Findings:
1. `src/doctor_workstation/ui/diagnosis_index_widgets.py:37:5` - unused `PySide6.QtGui.QPixmap`.
2. `src/doctor_workstation/ui/dialogs/ai_consult.py:18:5` - unused `PySide6.QtGui.QFont`.
3. `src/doctor_workstation/ui/dialogs/ai_consult.py:24:5` - unused `PySide6.QtGui.QPixmap`.
4. `src/doctor_workstation/ui/dialogs/ai_consult.py:26:5` - unused `PySide6.QtGui.QTextBlockFormat`.
5. `src/doctor_workstation/ui/dialogs/ai_consult.py:27:5` - unused `PySide6.QtGui.QTextCharFormat`.
6. `src/doctor_workstation/ui/dialogs/ai_consult.py:28:5` - unused `PySide6.QtGui.QTextCursor`.
7. `src/doctor_workstation/ui/dialogs/prescription.py:19:28` - unused `datetime.datetime`.
8. `src/doctor_workstation/ui/pages/patients.py:11:75` - unused `PySide6.QtGui.QPixmap`.
9. `src/doctor_workstation/ui/pages/prescriptions.py:10:65` - unused `PySide6.QtGui.QPixmap`.
## Overall result
The full verification gate is failing: both pytest and ruff returned exit code `1`.
+166
View File
@@ -0,0 +1,166 @@
# 强制更新对话框“退出软件”安全实现分析
## 结论
强制更新对话框可以提供“退出软件”,但不能把按钮直接连接到 `dialog.close()``reject()``QApplication.quit()`。当前更新下载由全局 `QThreadPool` 中的 `QRunnable` 执行,退出应用不会自动取消或等待该任务;安全的最小方案应是两阶段退出:
1. GUI 线程记录“退出已请求”,禁止再提交安装,并用线程安全的取消事件通知下载任务;
2. 更新任务通过既有 `finished` 信号确认已结束后,再由 `ApplicationController` 调用 `application.quit()`
3. `aboutToQuit` 中的 `ApplicationController.shutdown()` 只做最终、幂等的资源清理,不能承担异步等待任务结束的职责。
这条顺序保证:用户选择“退出软件”后不会又启动更新助手;`.part` 文件能走现有异常清理;Qt 事件循环在工作线程仍可能发信号时不会提前消失。
## 当前实现与证据
### 1. 强制对话框目前没有退出路径
- `AppUpdateDialog` 对强制更新移除关闭按钮并设为应用级模态(`app/src/doctor_workstation/ui/dialogs/app_update.py:112-121`)。
- 按钮区只有“稍后提醒”“取消下载”“立即更新”;“稍后提醒”在强制更新时隐藏(`:179-197`)。
- `set_busy()` 只在“忙且非强制”时显示取消下载,所以强制更新下载过程中没有任何停止入口(`:208-213`)。
- 强制更新或任意下载忙状态都会忽略窗口关闭事件,强制更新还会忽略 Escape(`:265-275`)。
因此新增能力应是独立的 `exit_requested` 语义,而不是复用 `download_cancelled`。后者当前在 session 中明确拒绝强制更新(`:411-415`),且它的既有语义只是“取消后留在应用内”。
### 2. session 没有“请求取消 -> 已经停稳”的闭环
- `_TaskSignals` 已声明 `finished``_Task.run()` 也一定会在 `finally` 发出它(`app_update.py:67-90`),但 `AppUpdateSession` 没有连接该信号。
- session 只保留一个跨线程共享的 `_cancel: bool` 和最近一次 `_signals`,没有活动 worker/token、退出状态或完成回调(`:285-292`)。
- 检查任务和安装准备任务均直接提交到 `QThreadPool.globalInstance()``:316-330``:467-472`);局部 `worker` 没被 session 用来跟踪生命周期。
- 安装准备任务直接把进度/状态连到 dialog,把结果连到 `_finish_install()``:467-471`)。关闭事件循环前没有撤销或门控这些回调。
- 新 offer 到达时,session 会对旧 dialog 调用 `close()` 后立刻 `deleteLater()``:397-405`)。如果旧 dialog 正在强制更新/下载,它的 `closeEvent()` 会拒绝关闭,但 `deleteLater()` 仍会排队;与此同时旧 worker 仍持有连接和捕获该 dialog 的 lambda。这也是需要用“活动操作 token”阻止重入/替换的理由。
### 3. 当前取消只能在收到下载分块以后生效
- `download_package()` 把 HTTP read timeout 设为 `None``app/src/doctor_workstation/services/app_update.py:311`)。服务器建立连接后若不再发送数据,worker 可以无限阻塞在读取中,GUI 写入 `_cancel=True` 也不能唤醒 socket。
- 取消回调只在 `iter_bytes()` 产出一个 chunk 后检查(`:334-337`)。取消被观察到时会抛出 `AppUpdateError`,现有异常分支会删除 `.part` 文件(`:349-351`),这一清理机制可以继续复用。
- 下载返回以后没有再次检查取消状态;job 会继续校验安装器,或调用不可取消的 `safe_extract_zip()`UI `app_update.py:439-465`service `app_update.py:247-260`)。
- 下载全部完成后,文件在 `os.replace()` 前也没有最后一次取消检查(service `app_update.py:358-367`)。即使退出请求恰好到达末尾,job 仍可能返回 `_PreparedUpdate`
- 工作目录在下一次同版本尝试开始时会整体删除重建(service `app_update.py:722-727`),所以取消发生在下载完成或解压阶段时,保留完整 zip/部分解压目录不会污染下一次尝试;关键仍是不能继续提交安装。
### 4. 直接 `quit()` 存在安装竞态
当前 `_finish_install()` 收到任何合法 `_PreparedUpdate` 就先启动外部更新助手,再用 300 ms 定时器调用 `application.quit()`UI `app_update.py:483-503`)。更新助手按设计等待当前 PID 消失后才覆盖/安装并重启:archive 路径见 service `app_update.py:517-525`Inno Setup 路径见 `:594-625`
若下载中“退出软件”直接调用 `quit()`,存在以下时序:
1. worker 已完成最后一个 chunk,并已把 `result` 排进 GUI 事件队列;
2. 用户的退出点击与该 queued result 先后到达 GUI 线程;
3. 若 result 仍被处理,当前 `_finish_install()` 没有“退出已请求”门禁,会启动更新助手;
4. 应用随后退出,于是用户选择的“只退出”实际变成“退出并安装”。
反向时序也不安全:如果 `quit()` 先结束事件循环,worker 仍可能在独立 `httpx.Client` 中写 `.part`、解压或发射 Qt 信号。`QApplication.quit()` 是退出事件循环的请求,不是 `QRunnable` 的 cancel/join。进程最终可能等待 Qt 线程池析构、遗留中间文件,或丢弃已经排队的结果;不能把这些析构时机当成生命周期协议。
### 5. `ApplicationController.shutdown()` 目前不管理 updater
- `aboutToQuit` 在控制器构造时连接到 `shutdown()``app/src/doctor_workstation/app.py:402-427`)。
- `shutdown()` 只置 `_shutting_down`、失效 session restore、关闭视频和远端 API client;没有调用 `self.app_updater.shutdown()``:1081-1097`)。
- Qt 配置了 `setQuitOnLastWindowClosed(True)``:1147-1162`),因此单纯关闭/拒绝 dialog 也不是统一的退出协议:父 login/shell 仍存在时未必退出,最后窗口意外关闭时又会绕过 updater 的准备阶段。
- `ApiClient.close()` 会无超时地等待所有活跃短请求归还连接(`app/src/doctor_workstation/services/api_client.py:414-450`,尤其 `:427-432`)。更新检查使用的正是共享 remote clientUI `app_update.py:310-330`),所以若退出恰逢检查请求,`aboutToQuit -> shutdown -> client.close()` 可能在 GUI 线程等待请求超时/重试结束。强制对话框的原始检查通常已经返回,但 session 仍应在最终 shutdown 时先递增 generation,使迟到的检查结果绝不能再创建窗口。
`aboutToQuit` 已经处于事件循环退出阶段,不适合再启动“取消后等 finished signal”的异步流程;finished queued signal 可能已没有下一轮事件可处理。因此必须在点击“退出软件”时先完成 quiesce,再真正调用 `quit()`
## 建议的最小实现
### A. 对话框只发意图,不自行退出
`AppUpdateDialog` 增加独立信号 `exit_requested = Signal()` 和按钮:
- 文案为“退出软件”,仅 `offer.force` 时显示;非强制更新继续使用“稍后提醒/取消下载”。
- 强制更新即使 `_busy=True` 也保持该按钮可用,因为这正是下载中唯一的离开路径。
- 点击后只 emit;session 接管状态转换。对话框增加 `set_exiting()`,禁用所有按钮、显示“正在停止更新并退出…”,防止双击。
- `closeEvent()` 和 Escape 的现有强制拦截继续保留。不要让窗口标题栏关闭绕开协调器。
- 一旦外部安装助手已经成功启动,进入不可逆的 `APPLY_COMMITTED` 状态,禁用“退出软件”;此后退出必然表示“退出并安装”。
### B. 用 `threading.Event` 和活动操作身份建立闭环
`AppUpdateSession` 最少需要以下 GUI 线程状态:
```python
self._cancel_event = Event()
self._active_install_signals: _TaskSignals | None = None
self._exit_requested = False
self._quit_when_idle: Callable[[], None] | None = None
self._apply_committed = False
```
开始安装准备时 `clear()` event,保存本次 `signals`,并把 `signals.finished` 连到带 `signals` 身份参数的 `_on_install_finished()`。进度、状态、result、error 也不要再直接连接 dialog 方法;统一经过 session handler,并同时验证:
- `signals is self._active_install_signals`
- dialog 仍是 `self.dialog`
- 未处于 `_exit_requested`finished handler 除外)。
这会同时解决迟到回调、旧 dialog 被替换、以及上一次任务影响下一次 `_cancel` 状态的问题。活动安装存在时,`check()`/`_present()` 应拒绝再替换 dialog,避免两个 job 同时删除和使用同一版本 workspace。
退出请求的最小状态机是:
```text
IDLE/PREPARING --点击退出--> EXIT_PENDING
EXIT_PENDING --cancel_event.set()--> 等待当前 install signals.finished
无活动任务或 finished 到达 --> ApplicationController.request_quit()
aboutToQuit --> ApplicationController.shutdown() 最终幂等清理
```
`_finish_install()` 的第一条业务门禁必须是“如果退出已请求、event 已 set、或 signals 已不是当前操作,则直接返回,不调用 `apply_downloaded_update()`”。这是防止“退出反而安装”的关键断言。
### C. 让 job 在阶段边界观察取消,并给网络读取有限上界
现有 `download_package(cancelled=...)` 接口无需改变,改传 `self._cancel_event.is_set`。job 至少在以下边界调用统一的 `_raise_if_cancelled()`
1. 创建 workspace 前;
2. `download_package()` 返回后;
3. 安装器校验/zip 解压前;
4. 校验/解压后、构造 `_PreparedUpdate` 前。
同时把 `download_package()``read=None` 改成有限的“单次读空闲超时”,建议沿用配置的 `request_timeout` 或默认 30 秒。这个 timeout 不是总下载时长:只要持续收到 chunk,大文件仍可继续;服务器停止发数据后,退出等待则有确定上界。
如果希望解压中点击退出也能很快响应,可把 `safe_extract_zip()` 从一次性 `extractall()` 改为逐 member 提取并在每个 member 前检查同一个 cancel callback。若坚持最小改动,也可以让退出等待当前 `extractall()` 完成,但必须保持 event loop 和 dialog 存活,并在解压后门禁掉安装,不能先 `quit()`
### D. 由控制器统一发起真正退出
`ApplicationController` 增加与 `_shutting_down` 分离的 `_quit_requested`,以及幂等 `request_quit()`
1. 首次调用时设置 `_quit_requested`
2. 调用 `app_updater.prepare_to_quit(self.application.quit)`
3. updater 无活动安装时立即以 `QTimer.singleShot(0, callback)` 完成;有任务时保存 callback,待该任务 `finished` 后完成;
4. 重复调用不做任何事。
不要提前设置 `_shutting_down`,否则真正触发 `aboutToQuit` 时现有 `shutdown()` 会在 `:1084-1086` 直接返回,跳过资源释放。
`ApplicationController.shutdown()` 中应在 `_cancel_session_restore()` 之后、关闭视频和 remote client 之前调用幂等的 `self.app_updater.shutdown()`。该方法应:递增 `_generation`、设置 cancel event、清除退出 callback、使所有迟到 callback 失效;它是兜底,不再等待 worker。正常的强制对话框退出路径到这里时,安装准备 worker 已经 finished。
成功更新也应复用同一完成门:`_finish_install()` 成功启动 helper 后只记录 `_apply_committed=True` 和“任务结束后退出”;由本次 `signals.finished` 再调用控制器的 `request_quit()`。这样可以删除当前依赖经验值的 300 ms 定时退出(UI `app_update.py:501-503`),并明确保证 worker 已离开 `run()`
### E. 不建议的实现
- 不要在退出按钮中调用 `os._exit()``terminate()` 或强杀线程;这会绕过 controller 的视频/API 清理,并可能截断 `.part`/日志写入。
- 不要在 `aboutToQuit` 中调用 `QThreadPool.globalInstance().waitForDone()`;它会等待整个应用的全局线程池,而不只是更新任务,当前无限 read timeout 还可能让 GUI 永久卡住。
- 不要用循环 `processEvents()` 等待 worker;这会允许更新按钮、窗口关闭和 queued result 重入。
- 不要只设置现有 `_cancel=True` 后立即 `quit()`;设置取消只是请求,`finished` 才是可退出的确认。
## 建议补测
`app/tests/test_app_update_ui.py` 现有强制对话框测试(`:68-83`)基础上补:
1. 强制更新显示“退出软件”,不显示“稍后提醒”,关闭按钮/Escape 仍不能绕过;非强制更新不显示该退出按钮。
2. 空闲时点击退出只调用一次 controller `request_quit()`
3. 下载中点击退出会 set event、保持应用运行且不立即调用 `application.quit()`;手工 emit 当前 signals 的 `finished` 后才调用一次。
4. 退出请求后再投递 `progress/status/result/error` 均不更新旧 dialog;特别断言 `_finish_install()` 不调用 `apply_downloaded_update()`
5. 模拟“result 已排队但退出点击先处理”的边界,断言不会启动 helper;模拟 result 已先完成 helper 提交,则退出按钮已禁用且最终走“安装后退出”。
6. 新 offer 在活动安装期间不会 `deleteLater()` 当前 dialog,也不会启动第二个 workspace job。
7. `ApplicationController.shutdown()` 调用 updater shutdown 早于 `remote_repository.client.close()`,并保持二次调用幂等。
`app/tests/test_app_update.py` 的下载测试(现有 `:235-293`)基础上补:
8. 流式响应在若干 chunk 后设置 `Event`,断言抛取消错误、目标文件和 `.part` 都不存在。
9. 下载最后一个 chunk 后、`os.replace()`/job 返回前取消,断言 session 的阶段门禁不会产出可安装结果。
10. 读空闲超时为有限值,并被转换为 `AppUpdateError`;避免退出永久等待。
## 实施顺序
最小、低风险的提交顺序是:先加入 session 的 operation token、`Event`、finished 门和 controller `request_quit()`;再加对话框按钮;最后把 read timeout 改为有限值并补阶段取消检查。只有当“退出后 result 绝不会进入 `apply_downloaded_update()`”和“quit 只发生在 finished 以后”两条测试通过,才应开放强制更新下载中的退出按钮。
## 审阅说明
- 本次依据工作树当前内容只读分析;生产代码与现有测试均未修改。
- 根目录 `AGENTS.md` 已读取;仓库当前不存在其中提到的 `.trellis/` 目录,因此没有额外的 workflow/spec 文件可读。
- 工作树原本已有多项未提交修改;本次只新增本研究文档,没有覆盖或回退任何现有改动。
@@ -0,0 +1,87 @@
# 强制更新“退出软件”Windows 正式包重建与验证结果
- 执行日期:2026-08-28Asia/Shanghai
- 工作目录:`D:\web\zyt\app`
- 总体结果:成功
- 生产源码/测试修改:无(本次仅重建发布产物并新增本报告)
- 工作树说明:执行前 `src/doctor_workstation/__init__.py` 及其他生产/测试文件已有用户修改;本次全部保留,未覆盖或回退
- Trellis:仓库根目录不存在 `.trellis/`,因此无 Trellis 工作流文件可继续读取
## 发布配置核对
重建前后均读取 `src/doctor_workstation/__init__.py`,确认:
```powershell
rg -n "(__version__|DEBUG_MODE)" src/doctor_workstation/__init__.py
```
- 退出码:`0`
- 输出:`6:__version__ = "1.2.0"``10:DEBUG_MODE = False`
- `__version__ = "1.2.0"`
- `DEBUG_MODE = False`
最终 frozen 主程序的 Windows 版本资源也复核为:
- `FileVersion = 1.2.0`
- `ProductVersion = 1.2.0`
最终安装器的 `ProductVersion = 1.2.0`
## 正式包重建
执行命令:
```powershell
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1
```
- 退出码:`0`
- 结果:`Windows package complete.`
- Python 构建环境:PyInstaller 6.22.0 / Python 3.12.12
- 视频伴侣构建:通过,Vite 转换 43 个模块
- frozen Qt 多媒体文件门禁:通过
- frozen Qt 多媒体 smoke`--media-smoke-test`,隔离 offscreen 环境):通过
- frozen 应用入口 smoke`--smoke-test`,隔离 offscreen 环境):通过
- ZIP7-Zip 报告 `Everything is Ok`
- 安装器:Inno Setup 6.7.3 编译成功,`Successful compile (140.609 sec)`
构建期间有非阻断警告:Vite 报告单个压缩后 chunk 超过 500 kBPyInstaller 报告一个 Qt QML 插件二进制缺失及若干 Windows 系统 DLL 解析警告。脚本内置的 frozen 文件门禁与两个 smoke gate 均通过,构建最终退出码为 `0`
## 最终安装器冒烟验证
执行命令:
```powershell
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer 'D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe'
```
- 退出码:`0`
- 结果:`Installer icon/install/start/uninstall smoke test passed.`
- 隔离验证目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-55b6feebaa134d21ba0637549d55355b`
- 覆盖范围:安装器品牌图标、静默安装、已安装 EXE 存在性与启动 smoke、静默卸载、卸载后主程序残留检查
## 产物与独立校验
使用以下命令模式逐项独立复核,退出码为 `0`
```powershell
Get-Item -LiteralPath <产物绝对路径>
Get-FileHash -LiteralPath <产物绝对路径> -Algorithm SHA256
```
| 产物 | 绝对路径 | 字节数 | SHA-256 |
| --- | --- | ---: | --- |
| 正式安装器 EXE | `D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,907,874 | `71B4ADF7B431A3BBC53818AA2D68089977D777D18F0969C930A0A6B1777F2BA9` |
| 发布 ZIP | `D:\web\zyt\app\dist\DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,817,779 | `D41C0794568A59FB57A083425750F4A2CDBED9962B13D9EBBD42FC2EEF374604` |
| frozen 主程序 EXE | `D:\web\zyt\app\dist\DoctorWorkstation\DoctorWorkstation.exe` | 6,300,842 | `550BF4434C6B2BEEB9DDA5A78107FB392C4FF442A6D62D01803A51197E59A893` |
| 校验清单 | `D:\web\zyt\app\dist\SHA256SUMS.txt` | 220 | `9BD4CB7CD0A1FA7FBBED0E6D38BE2A4F98898224A6B97E0E38F35701CB8B8A20` |
`dist\SHA256SUMS.txt` 内容:
```text
71B4ADF7B431A3BBC53818AA2D68089977D777D18F0969C930A0A6B1777F2BA9 DoctorWorkstation-Setup-Windows-x64-1.2.0.exe
D41C0794568A59FB57A083425750F4A2CDBED9962B13D9EBBD42FC2EEF374604 DoctorWorkstation-Windows-x64-1.2.0.zip
```
独立复算的安装器与 ZIP 哈希均与脚本末尾输出及 `SHA256SUMS.txt` 逐字符一致。
+182
View File
@@ -0,0 +1,182 @@
# 强制更新“退出软件”回归测试设计
## 结论
建议在 `app/tests/test_app_update_ui.py` 把“退出软件”作为强制更新对话框的独立显式动作测试,不把它等同于关闭窗口或 `reject()`
- 强制更新在初始状态和下载中状态都显示且启用“退出软件”。
- 点击只发出一次专用信号(下文假定为 `exit_requested`);对话框自身不静默 `reject()`
- 普通更新仍显示“稍后提醒”,不显示“退出软件”,原有 `update_deferred` + `reject()` 行为不变。
- 强制更新无论初始还是下载中,标题栏关闭和 Escape 都继续被拦截;用户只能通过明确的“退出软件”动作退出。
生产实现若采用独立控件,建议公开 `exit_button`;这比把强制退出语义塞进现有 `later_button` 更容易测试,也避免 `_defer()` 同时承担“稍后”和“退出”两种相反行为。若实现选择复用 `later_button`,下述断言可把 `exit_button` 替换为该控件,但至少应保留独立的 `exit_requested` 信号。
## 当前覆盖缺口
当前 `test_app_update_ui.py` 有以下相关覆盖:
- `test_optional_update_dialog_allows_later` 只断言普通更新的稍后按钮可见和更新文案,未点击按钮,也未验证 `update_deferred`
- `test_forced_update_dialog_hides_defer_and_blocks_escape` 断言稍后按钮隐藏,并用 `dialog.close()` 验证强更无法关闭;尽管测试名写有 `blocks_escape`,测试体没有发送 Escape。
- 没有覆盖 `set_busy(True)`。当前 `set_busy()` 会禁用 `later_button`,因此若复用该按钮显示“退出软件”,下载中会直接回归为不可退出。
- 没有覆盖退出信号的次数,也没有证明显式退出动作不会被当成普通 `reject()`
现有 service 测试 `app/tests/test_app_update.py` 主要覆盖 offer 解析、下载、校验与更新应用,不适合承载 Qt 按钮和键盘行为;这些回归应继续留在 `test_app_update_ui.py`
## 建议测试矩阵
| offer | 对话框状态 | 稍后按钮 | 退出按钮 | 更新按钮 | 取消下载 | 关闭 / Escape |
|---|---|---|---|---|---|---|
| 强制 | 初始 | 隐藏 | 显示、启用 | 启用 | 隐藏 | 均拦截 |
| 强制 | 下载中 | 隐藏 | 显示、启用 | 禁用 | 隐藏 | 均拦截 |
| 普通 | 初始 | 显示、启用,文案“稍后提醒” | 隐藏 | 启用 | 隐藏 | 允许 |
| 普通 | 下载中 | 保持现有禁用语义 | 隐藏 | 禁用 | 显示 | `closeEvent` 目前拦截;本次不要顺带定义 Escape 新语义 |
最后一格存在现有 Qt 行为不对称:普通更新下载中时 `closeEvent()` 会拦截标题栏关闭,但 `keyPressEvent()` 仅专门拦截强制更新的 Escape。除非产品需求明确要求调整普通更新下载中的 Escape,否则本次回归不要无意固化或改变该行为。
## 推荐测试拆分
测试文件增加:
```python
import pytest
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
```
### 1. 强制更新在初始和下载中均可显式退出
用参数化覆盖两个状态,避免只测初始渲染:
```python
@pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"])
def test_forced_update_exit_action_stays_available(
busy: bool,
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=True))
dialog.show()
if busy:
dialog.set_busy(True)
dialog.show_download_progress(256, 1024)
app.processEvents()
assert not dialog.later_button.isVisible()
assert dialog.exit_button.isVisible()
assert dialog.exit_button.isEnabled()
assert dialog.exit_button.text() == "退出软件"
assert dialog.update_button.isEnabled() is (not busy)
assert not dialog.cancel_button.isVisible()
dialog.hide()
dialog.deleteLater()
app.processEvents()
```
这里必须在 `set_busy(True)` 后断言,才能捕获“统一禁用底部按钮”导致强制更新无法退出的回归。调用 `show_download_progress()` 同时让测试更贴近真实 `_start_install()` 顺序:先 `set_busy(True)`,再进入下载进度态。
### 2. 点击退出按钮只发一次专用信号
```python
def test_forced_update_exit_button_emits_request(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
dialog = AppUpdateDialog(_offer(force=True))
requested: list[bool] = []
dialog.exit_requested.connect(lambda: requested.append(True))
dialog.show()
app.processEvents()
dialog.exit_button.click()
assert requested == [True]
assert dialog.isVisible()
dialog.hide()
dialog.deleteLater()
app.processEvents()
```
`assert dialog.isVisible()` 有意证明按钮在 dialog 层只表达“请求退出软件”,而不是绕开应用级清理流程直接 `reject()`。真正退出应由 `AppUpdateSession`/应用层的 slot 完成。若最终设计明确由 dialog 自身关闭,则删除这一条,但仍要保留信号次数断言。
还可把该测试参数化为初始/下载中并在两种状态点击;若测试数量需要控制,则第一个参数化测试负责可用性,第二个测试负责一次信号已足够定位大部分回归。
### 3. 普通更新仍是“稍后提醒”
建议增强现有 optional 测试,而不是只检查可见性:
```python
def test_optional_update_dialog_keeps_defer_action(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
dialog = AppUpdateDialog(_offer(force=False))
deferred: list[bool] = []
dialog.update_deferred.connect(lambda: deferred.append(True))
dialog.show()
app.processEvents()
assert dialog.later_button.isVisible()
assert dialog.later_button.isEnabled()
assert dialog.later_button.text() == "稍后提醒"
assert not dialog.exit_button.isVisible()
dialog.later_button.click()
assert deferred == [True]
assert not dialog.isVisible()
dialog.deleteLater()
app.processEvents()
```
这条会防止实现“退出软件”时误把普通更新的次按钮文案、信号或关闭行为一起改掉。
### 4. 强制更新明确拦截关闭与 Escape
把当前名不副实的测试改成真实事件测试,并参数化初始/下载中:
```python
@pytest.mark.parametrize("busy", [False, True], ids=["initial", "downloading"])
def test_forced_update_only_allows_explicit_exit(
busy: bool,
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
dialog = AppUpdateDialog(_offer(force=True))
dialog.show()
if busy:
dialog.set_busy(True)
dialog.show_download_progress(256, 1024)
app.processEvents()
dialog.close()
app.processEvents()
assert dialog.isVisible()
QTest.keyClick(dialog, Qt.Key.Key_Escape)
app.processEvents()
assert dialog.isVisible()
dialog.hide()
dialog.deleteLater()
app.processEvents()
```
行为断言比检查 window flags 更稳定:不同平台可能规范化窗口标志,但 `closeEvent()`/`keyPressEvent()` 是否真正保留对话框才是用户可观察契约。
## 会话层边界
dialog 信号测试只能证明点击请求已发出,不能证明应用最终退出。生产接线还应满足:
- `AppUpdateSession._present()` 连接 `exit_requested` 到一个应用级退出入口。
- 下载中退出时先设置取消标志,使 `download_package(..., cancelled=...)` 尽快结束并清理 `.part` 文件,再请求 `QApplication.quit()`;否则全局线程池任务可能拖延进程退出。
- 应用级退出必须走既有 `QApplication.aboutToQuit -> ApplicationController.shutdown`,不要从 dialog 直接调用 `sys.exit()` 或跳过资源清理。
若实现为可替换的 session 方法(例如 `_request_exit()`),可另补一个 session 单测,mock/monkeypatch 该方法后验证 dialog 信号接线;不要在 pytest 共享的真实 `QApplication` 上直接调用 `quit()`,以免污染同进程后续 UI 测试。本次题目明确要求的四项回归,以上 dialog 测试已经可以独立、稳定覆盖。
## 验证记录
- 已读取根 `AGENTS.md`;工作树中不存在 `.trellis/workflow.md``.trellis/spec/`,因此无法应用额外 Trellis 分层规范。
- 只读运行现有基线:`app/.venv/Scripts/python.exe -m pytest tests/test_app_update_ui.py -q`,结果 `4 passed`
- 本文之外未修改生产代码或测试代码;工作树中原有的 `app_update.py``test_app_update_ui.py` 未提交改动均已保留。
+52
View File
@@ -0,0 +1,52 @@
# Windows 1.2.0 正式打包结果
- 打包日期:2026-08-28Asia/Shanghai
- 工作目录:`D:\web\zyt\app`
- 版本源:`src/doctor_workstation/__init__.py`
- 确认版本:`1.2.0`
- 执行命令:`powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\package_windows.ps1`
- 脚本退出码:`0`
- 总体结果:成功
## 发布产物核对
| 文件 | 大小(字节) | 大小(MiB | SHA-256 |
| --- | ---: | ---: | --- |
| `dist/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe` | 162,914,042 | 155.367 | `1D74966B73005B30ECB4EB7E2101BECB57BB8C91D0981392ACD00F6800D37E7D` |
| `dist/DoctorWorkstation-Windows-x64-1.2.0.zip` | 230,810,707 | 220.118 | `A56057D95EFCE70E58FA54264CAC17A16E2F5BDB842FE515A5A153B073A4AFE9` |
| `dist/SHA256SUMS.txt` | 220 | 0.000 | `08BD69A21BACEC86B8269ED665C50CDAF4D3ED6D252CEC4DD7BEE30E73573175` |
以上三个文件均存在。独立使用 `Get-FileHash -Algorithm SHA256` 重新计算 EXE 和 ZIP 哈希,结果与打包脚本末尾输出及 `SHA256SUMS.txt` 中的两条记录逐项一致。
## 脚本验证结果
- 锁定的 Python 构建依赖检查通过。
- 视频伴侣依赖安装成功,`vue-tsc --noEmit && vite build` 成功;Vite 共转换 43 个模块。
- PyInstaller 6.22.0 / Python 3.12.12 构建成功,输出 `dist/DoctorWorkstation`
- `Frozen Qt multimedia file gate passed.`
- `Frozen Qt multimedia smoke gate passed (--media-smoke-test, isolated offscreen mode).`
- `Frozen application entry smoke gate passed (--smoke-test, isolated offscreen mode).`
- 7-Zip 创建 ZIP 成功,输出 `Everything is Ok`;归档包含 180 个目录、3,011 个文件。
- Inno Setup 6.7.3 编译成功,输出 `Successful compile (165.062 sec)`
- 正式安装包、ZIP 和校验清单均生成,脚本最终退出码为 0。
## 非阻断警告
- Vite 报告单个压缩后 chunk 超过 500 kB,仅为体积优化提示。
- PyInstaller 报告一个 Qt QML 插件二进制缺失,以及若干 Windows 系统 DLL 解析警告;这些警告未阻断构建,且脚本内置的多媒体文件门禁、多媒体离屏 smoke test 和应用入口 smoke test 全部通过。
## 真实安装器冒烟测试
- 执行命令:`powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File scripts\smoke_windows_installer.ps1 -Installer 'D:\web\zyt\app\dist\DoctorWorkstation-Setup-Windows-x64-1.2.0.exe'`
- 脚本退出码:`0`
- 脚本最终结果:`Installer icon/install/start/uninstall smoke test passed.`
- 隔离测试目录:`C:\Users\pc\AppData\Local\Temp\doctor-workstation-installer-smoke-ac84581babe94e28a0d0ffab63dd0b5f`
### 分阶段核对
- 静默安装:通过。安装器以 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER` 运行到隔离目录;`setup.log` 记录 `Installation process succeeded.``Need to restart Windows? No`
- 安装内容:通过。脚本在启动前确认 `DoctorWorkstation.exe``unins000.exe` 均存在。
- 图标:通过。脚本分别提取安装器、已安装主程序及卸载器的 32×32 关联图标并计算 SHA-256;主程序与卸载器图标均和安装器品牌图标一致,否则脚本会失败。
- 启动 smoke:通过。已安装主程序在隔离配置、日志目录与 `QT_QPA_PLATFORM=offscreen` 环境下执行 `--smoke-test`,退出码为 0;应用日志记录 `doctor workstation starting`,未记录异常堆栈。
- 静默卸载:通过。卸载器以 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART` 运行,退出码为 0`uninstall.log` 记录 `Uninstallation process succeeded.``Removed all? Yes``Need to restart Windows? No`
- 残留检查:通过。卸载完成后独立确认隔离安装目录、`DoctorWorkstation.exe``unins000.exe`、当前用户开始菜单快捷方式及本次当前用户卸载注册表键均不存在。隔离测试根目录按设计保留,仅包含安装/卸载日志和隔离应用日志,便于审计。
+233
View File
@@ -0,0 +1,233 @@
# 医生工作站版本发布配置与更新 API 诊断
## 结论
当前仓库中,admin、server、app 三端的**现行契约是一致的**,但字段名不是扁平的
`package_type` / `download_url`。正式 wire contract 是:
```json
{
"code": 1,
"data": {
"has_update": true,
"force": false,
"enabled": true,
"current_version": "1.1.0",
"latest_version": "1.2.0",
"min_version": "",
"title": "...",
"notes": "...",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://.../DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
"sha256": "64 位十六进制值",
"size": 123,
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
"type": "inno_setup"
},
"can_install": true
}
}
```
因此:
- `latest_version``data` 下的顶层字段。
- `platform``arch` 是检测请求参数,同时在 `data` 下回显规范化后的值;它们不保存在发布配置中。
- 安装包对象叫 `package`
- 安装包类型叫 `package.type`,不是顶层或同级的 `package_type`
- 下载地址叫 `package.url`,不是 `download_url`
- 哈希叫 `package.sha256`
- 如果截图或线上响应实际出现的是扁平 `package_type``download_url`,当前 app 不会读取这些别名。这不是当前仓库 server 的输出,优先怀疑线上后端/代理为另一版本或只部署了部分提交。
当前工作区还存在一个已确认的发布物版本风险:运行时版本源已经是 `1.2.0``app/src/doctor_workstation/__init__.py:5-6`),但 `app/dist/SHA256SUMS.txt:1-2` 只登记了 `1.1.0` 的 EXE/ZIP,且本地 `DoctorWorkstation.exe` 文件版本也是 `1.1.0`。如果管理端把 `latest_version` 设为 `1.2.0`,却填入当前 `1.1.0` 安装包,客户端安装后仍会报告 `1.1.0`,下次启动会再次发现 `1.2.0`,形成重复升级提示。打包脚本明确从同一个 `__version__` 读取版本(`app/scripts/package_windows.ps1:68-76`),并用它生成 EXE/ZIP 名称(`:169-174`)及两者哈希(`:234-244`);发布前必须重新生成 `1.2.0` 产物。
> 本轮只读诊断没有修改生产代码。根目录 `AGENTS.md` 已读取;仓库中没有 `.trellis/` 目录。
## 1. Windows 64 位安装包字段如何进入保存请求
管理端类型定义把 Windows 包放在 `packages.windows_x64`,每个包固定包含
`url``sha256``size``filename``type`;其中 `type` 只允许
`archive | inno_setup``admin/src/api/setting/desktop_workstation.ts:3-24`)。保存接口是
`POST /setting.desktop_workstation/setConfig`(同文件 `:27-34`)。
页面的 Windows 区块来自平台键 `windows_x64``admin/src/views/setting/desktop_workstation/index.vue:196-224`),默认类型是 `inno_setup``:198-218`)。UI 字段与请求体的对应关系如下:
| 截图/UI 字段 | 保存请求字段 | 证据 |
|---|---|---|
| Windows 64 位安装包 | `packages.windows_x64` | `index.vue:221-224, 339-343` |
| 安装包类型 | `packages.windows_x64.type`EXE 为 `inno_setup` | `index.vue:96-104, 305-309` |
| 安装包地址 | `packages.windows_x64.url` | `index.vue:113-123, 318-324` |
| SHA-256 | `packages.windows_x64.sha256` | `index.vue:144-150, 292-315` |
| 文件名 | `packages.windows_x64.filename` | `index.vue:152-162, 300-304` |
| 文件大小(字节) | `packages.windows_x64.size` | `index.vue:164-174, 300-304` |
| 最新版本号 | 顶层 `latest_version` | `index.vue:34-42, 330-344` |
选择文件后,页面在浏览器本地读取原始文件名和字节数,`.exe` 自动切换为
`inno_setup`,并用 Web Crypto 计算 SHA-256`index.vue:292-315`)。上传成功后只把上传接口返回的 `data.uri`(次选 `data.url`)写入包的 `url``:318-328`)。最终点击保存时,页面显式组装三个平台的完整 `packages` 对象,而不是上传后自动发布(`:330-345`)。
虽然 API 封装调用写成 `request.post({ params })`,拦截器会在 POST 且没有 `data` 时把
`params` 移入 JSON body`admin/src/utils/request/index.ts:20-38`),所以 PHP 收到的是上述嵌套 JSON,而不是查询字符串。
## 2. 后端如何校验和持久化
控制器用 POST 校验器接收请求,再交给逻辑层保存(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:38-46`)。
正常管理端保存时的关键约束:
- 版本号需是纯数字分段格式(`server/app/adminapi/validate/setting/DesktopWorkstationValidate.php:48-57`)。
- 包类型只允许 `archive` / `inno_setup`,且 `inno_setup` 只允许 Windows x64`:118-137`)。
- Inno Setup 若填写文件名,必须以 `.exe` 结尾(`:138-140`)。
- 显式 `http://` 的 Inno Setup 地址会被拒绝(`:141-143`)。
- 外部 http(s) 地址必须带 SHA-256SHA-256 若非空必须是 64 位十六进制(`:144-152`)。
- 文件名最长 180 字节,size 必须是非负数(`:153-158`)。
逻辑层将标量分别保存为配置项,把所有平台包作为一个 `packages` 配置项保存:
- `enabled`
- `latest_version`
- `min_version`
- `force_update`
- `title`
- `notes`
- `packages`
证据为 `server/app/adminapi/logic/setting/DesktopWorkstationLogic.php:45-55`。其中
`latest_version` / `min_version` 会被正规化为三段版本,包则逐平台正规化
`url/sha256/size/filename/type``:180-205, 226-254`)。
`ConfigService::set()` 对数组执行 `json_encode(..., JSON_UNESCAPED_UNICODE)` 后写入 Config 模型的 `value` 字段;标量直接写入(`server/app/common/service/ConfigService.php:32-50`)。因此数据库中的逻辑形态是:
```text
type = desktop_workstation, name = latest_version, value = "1.2.0"
type = desktop_workstation, name = packages, value =
{"windows_x64":{"url":"...","sha256":"...","size":...,"filename":"...","type":"inno_setup"},...}
```
读取时,`ConfigService::get()` 会对合法 JSON 自动 `json_decode(..., true)`(同文件
`:65-85`),所以 `packages` 回到 PHP 数组。保存 URL 时会去掉当前站点/当前存储域名,读取给 API 时再补回绝对域名(`DesktopWorkstationLogic.php:231-252, 261-280``server/app/common/service/FileService.php:42-59, 69-78`)。本地 `uploads/...` 文件还会在缺失/无效时由 server 计算哈希、大小和文件名(`DesktopWorkstationLogic.php:309-331`)。
## 3. 检测 API 如何选择包和序列化响应
app 请求的端点是 `setting.desktop_workstation/check``app/src/doctor_workstation/services/app_update.py:29-35`)。控制器把 `check` 放进免登录列表(`server/app/adminapi/controller/setting/DesktopWorkstationController.php:26-29`),并把 GET 参数直接交给逻辑层(`:48-55`)。
客户端发送:
```text
current_version=<当前运行时版本>&platform=windows&arch=x64
```
证据为 `app_update.py:217-243`。server 将 `windows/win/win32/win64` 统一成
`windows`,把 `amd64/x86_64/x64` 统一成 `x64`,拼成配置键
`windows_x64``DesktopWorkstationLogic.php:125-153`)。也就是说,`platform` / `arch`
不是管理端发布字段,而是由客户端运行环境发给检测接口、用于选择
`packages.windows_x64` 的请求维度。
server 的检测响应由 `evaluate()` 直接组成(`DesktopWorkstationLogic.php:75-103`):
- `latest_version` 来自已保存的配置并正规化。
- `platform``arch` 是请求值正规化后的回显。
- `package` 是匹配平台的单个包,只有 `url``sha256` 都非空才返回,否则为 `null`
- 包对象的键为 `url/sha256/size/filename/type``:272-280`)。
- `can_install = has_update && url 非空 && sha256 非空`
- `force` 只有存在更新、命中强制策略并且有可安装包时才为 true。
控制器的 `data()` 最终封装为 `{code, show, msg, data}``server/app/common/controller/BaseLikeAdminController.php:50-60``server/app/common/service/JsonService.php:71-91`)。app 的 `ApiClient``code == 1` 返回 envelope 中的 `data``app/src/doctor_workstation/services/api_client.py:504-542`),因此 `parse_update_offer()` 收到的就是上面列出的 `data` 对象,而不是整个 envelope。
## 4. 与 app 客户端契约逐字段对照
| 语义 | server 实际输出 | app 实际读取 | 是否一致 |
|---|---|---|---|
| 最新版本 | `latest_version` | `data.get("latest_version")` | 一致(`DesktopWorkstationLogic.php:95`; `app_update.py:174-181` |
| 平台 | `platform` | `data.get("platform")` | 一致(server `:99`; app `:147-150, 176-183` |
| 架构 | `arch` | `data.get("arch")` | 一致(server `:100`; app `:147-150, 176-183` |
| 安装包 | `package` object/null | `data.get("package")` | 一致(server `:101`; app `:151-153` |
| 下载地址 | `package.url` | `package_payload.get("url")` | 一致(server `:275`; app `:154, 166-173` |
| SHA-256 | `package.sha256` | `package_payload.get("sha256")` | 一致(server `:276`; app `:155, 184-188` |
| 包类型 | `package.type` | `package_payload.get("type")` | 一致(server `:279`; app `:157-173` |
| 文件大小 | `package.size` | `package_payload.get("size")` | 一致(server `:277`; app `:158-173` |
| 文件名 | `package.filename` | `package_payload.get("filename")` | 一致(server `:278`; app `:156-173` |
| 可安装 | `can_install` | `data.get("can_install")` + 客户端二次校验 | 一致但客户端更严格(server `:85-102`; app `:184-200` |
客户端只认可 `archive` / `inno_setup`,且 Inno 只允许 Windows;它还要求 SHA-256
严格为 64 位小写十六进制、响应平台/架构必须与请求一致(`app_update.py:162-200`)。对于
`inno_setup`,下载 URL 还必须是 HTTPS(localhost 调试例外),随后下载内容要通过 SHA-256、size、`.exe` 后缀和 PE `MZ` 头校验(`:287-300, 360-397`)。UI 根据 `package.type` 分流:`inno_setup` 直接走 Windows 安装器,`archive` 则按 ZIP 解压(`app/src/doctor_workstation/ui/dialogs/app_update.py:423-449`)。
现有自动化也明确锁定了这个嵌套契约:server contract test 要求 Windows 包返回
`package.type == inno_setup``server/tests/DesktopWorkstationUpdateContractTest.php:34-62`);app test 用
`package.{url,sha256,size,filename,type}` 构造响应并验证接收(`app/tests/test_app_update.py:66-90`)。本轮实跑:
```text
php server/tests/DesktopWorkstationUpdateContractTest.php PASS
uv run pytest app/tests/test_app_update.py -q PASS (21 tests)
```
## 5. 根因候选(按优先级)
### A. `latest_version` 与实际安装包版本不一致(当前工作区已有直接证据)
当前版本源是 `1.2.0`,但现有 EXE/ZIP、SHA256SUMS 和冻结 exe 都是 `1.1.0`。如果截图中的管理端配置已经把最新版本发布为 `1.2.0`,当前 `1.1.0` 包不能作为它的安装包。表现为下载、安装可能成功,但应用重启后仍是旧版本并再次提示更新。
### B. 线上响应使用 `package_type` / `download_url` 扁平字段
当前 app 没有这两个 wire key 的兼容读取,仓库内也没有生成它们的 server 代码。如果截图中的实际网络响应是例如:
```json
{"latest_version":"1.2.0","package_type":"inno_setup","download_url":"...","sha256":"..."}
```
app 会因为没有 `package.url` 而得到 `package=None`,最终 `can_install=false`;即使把包放在
`package` 中但只给 `package_type`,客户端也会默认当成 `archive`,对 EXE 执行 ZIP 解压并失败。该情形应视为明确的协议不一致。
### C. admin / server / app 部署版本分叉,或 PHP OPcache 未刷新
Git 历史显示提交 `43e5411b6a8d2e625140c5dca8ddeb8492ba7daa` 才同步把
`type=inno_setup` 加入 admin、server 和 app。它之前的 server 会在保存/读取包时丢掉 `type`
因此“管理页面已有 Inno Setup 下拉框,但 check 响应没有 `package.type`”最符合部分部署或旧 PHP 代码仍在运行,而不是当前源码的逻辑错误。
### D. SHA-256 非空但无效,server 与 app 的可安装判定强度不同
`evaluate()` 只检查 URL/哈希非空;app 要求恰好 64 位十六进制。通过正常管理端保存不会发生,因为 validator 会拦截;但旧数据、手工改库、另一服务写入配置时,可能出现 server 返回 `can_install=true`、app 最终降级为不可安装。
### E. 相对上传路径在 server 输出时被扩成 HTTP
validator 只对输入字符串显式以 `http://` 开头的 Inno URL 拒绝;`uploads/...` 相对路径可通过。响应时 `FileService::getFileUrl()``request()->domain()` 补域名。如果生产位于 HTTPS 反向代理后但 PHP 未正确识别代理协议,响应可能变成 `http://...`。app 会安全地拒绝自动执行这个 EXE。若截图中的 `package.url` 为 HTTP,应核对反向代理的 forwarded proto / trusted proxy 配置,而不是放宽客户端安全校验。
## 6. 建议修复与验证顺序
1. **先重新打 1.2.0 正式包再发布。** 保持 `app/src/doctor_workstation/__init__.py`、EXE 的 FileVersion/ProductVersion、安装包文件名、管理端 `latest_version` 四者全部为 `1.2.0`;从新生成的 `SHA256SUMS.txt` 复制 EXE 对应哈希,不要复用 1.1.0 的值。
2. **直接抓线上 check 响应。** 用与 app 一样的参数请求:
`GET /adminapi/setting.desktop_workstation/check?current_version=1.1.0&platform=windows&arch=x64`。确认有效数据位于 `data`,并且字段精确为 `data.package.url/type/sha256`
3. **如果看到 `download_url/package_type`,统一契约。** 首选修 server 采用当前仓库的嵌套结构并整体部署;若必须兼容历史服务,可在 app 解析层短期接受别名,但 canonical 输出仍应只有 `package.{url,type,...}`,并补契约测试。
4. **如果 `package.type` 缺失,做完整部署并清 OPcache。** 同时部署 admin 静态资源、PHP controller/logic/validator 和新 app;不要只替换管理页面。
5. **核验 URL 与哈希。** `package.url` 必须是客户端可达的 HTTPS 绝对地址;下载文件 SHA-256 必须与 `package.sha256` 完全一致,size 若填写也必须一致。
6. **补一条跨端端到端 fixture。** 固化一个 Windows `inno_setup` 响应,既让 PHP `evaluate()` 产出 JSON,也让 Python `parse_update_offer()` 消费同一 fixture;另外增加扁平别名必须被拒绝(或在决定兼容后明确接受)的测试,避免字段名再次漂移。
## 最小正确发布样例
管理端保存体中的 Windows 部分:
```json
{
"enabled": 1,
"latest_version": "1.2.0",
"min_version": "",
"force_update": 0,
"title": "医生工作站 1.2.0",
"notes": "...",
"packages": {
"windows_x64": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
"sha256": "<新 1.2.0 EXE 的 64 位 SHA-256>",
"size": 0,
"filename": "DoctorWorkstation-Setup-Windows-x64-1.2.0.exe",
"type": "inno_setup"
},
"macos_arm64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"},
"macos_x64": {"url":"","sha256":"","size":0,"filename":"","type":"archive"}
}
}
```
不要把同一内容改名为顶层 `download_url` / `package_type`;当前 app 不消费该形态。
+447
View File
@@ -0,0 +1,447 @@
"""Read-only-ish timing probe for the desktop update commit sequence.
The probe imports production code and replaces only its external download/apply
edges in memory. It does not modify production sources or existing tests.
"""
from __future__ import annotations
import gc
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
import weakref
from pathlib import Path
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QCoreApplication, QEvent, QObject, QThreadPool, QTimer
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import app_update as update_service
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_INNO_SETUP,
UpdateOffer,
UpdatePackage,
)
from doctor_workstation.ui.dialogs import app_update as update_ui
def _record(events: list[dict[str, Any]], name: str, started: float) -> None:
events.append(
{
"event": name,
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
"thread_id": threading.get_ident(),
}
)
def exercise_session(iterations: int = 25) -> dict[str, Any]:
app = QApplication.instance() or QApplication([])
main_thread = threading.get_ident()
original_edges = {
"is_frozen_install": update_ui.is_frozen_install,
"frozen_install_root": update_ui.frozen_install_root,
"download_package": update_ui.download_package,
"apply_downloaded_update": update_ui.apply_downloaded_update,
}
failures: list[dict[str, Any]] = []
samples: list[list[dict[str, Any]]] = []
collected_signals = 0
try:
with tempfile.TemporaryDirectory(prefix="zyt-update-commit-") as raw_tmp:
temp_root = Path(raw_tmp)
install_root = temp_root / "installed"
install_root.mkdir()
(install_root / "DoctorWorkstation.exe").write_bytes(b"MZ")
update_ui.is_frozen_install = lambda: True
update_ui.frozen_install_root = lambda: install_root
for index in range(iterations):
run_root = temp_root / f"run-{index}"
run_root.mkdir()
events: list[dict[str, Any]] = []
started = time.perf_counter()
def fake_download(
_url: str,
destination: Path,
*,
_events: list[dict[str, Any]] = events,
_started: float = started,
**kwargs: Any,
) -> Path:
_record(_events, "download_enter", _started)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(b"MZ" + b"probe")
progress = kwargs.get("progress")
if callable(progress):
progress(7, 7)
_record(_events, "download_return", _started)
return destination
def fake_apply(
_payload: Path,
*,
package_type: str,
_events: list[dict[str, Any]] = events,
_started: float = started,
) -> None:
assert package_type == PACKAGE_TYPE_INNO_SETUP
_record(_events, "apply_enter", _started)
_record(_events, "apply_return", _started)
update_ui.download_package = fake_download
update_ui.apply_downloaded_update = fake_apply
host = QObject()
host.config = SimpleNamespace( # type: ignore[attr-defined]
config_dir=run_root,
verify_ssl=True,
)
def request_quit(
_events: list[dict[str, Any]] = events,
_started: float = started,
) -> None:
_record(_events, "request_quit", _started)
host.request_quit = request_quit # type: ignore[attr-defined]
session = update_ui.AppUpdateSession(host)
offer = UpdateOffer(
has_update=True,
force=True,
enabled=True,
current_version="1.0.0",
latest_version=f"1.0.{index + 1}",
min_version="",
title="probe",
notes="probe",
platform="windows",
arch="x64",
package=UpdatePackage(
url="https://example.invalid/DoctorWorkstation-Setup.exe",
sha256="a" * 64,
size=7,
filename="DoctorWorkstation-Setup.exe",
type=PACKAGE_TYPE_INNO_SETUP,
),
can_install=True,
)
dialog = update_ui.AppUpdateDialog(offer)
session.dialog = dialog
original_finish = session._finish_install
original_finished = session._on_install_finished
def finish_probe(
*args: Any,
_events: list[dict[str, Any]] = events,
_started: float = started,
_original: Any = original_finish,
**kwargs: Any,
) -> None:
_record(_events, "result_slot_enter", _started)
_original(*args, **kwargs)
_record(_events, "result_slot_return", _started)
def finished_probe(
*args: Any,
_events: list[dict[str, Any]] = events,
_started: float = started,
_original: Any = original_finished,
**kwargs: Any,
) -> None:
_record(_events, "finished_slot_enter", _started)
_original(*args, **kwargs)
_record(_events, "finished_slot_return", _started)
session._finish_install = finish_probe # type: ignore[method-assign]
session._on_install_finished = finished_probe # type: ignore[method-assign]
session._start_install(dialog, offer)
signal_ref = weakref.ref(session._active_install_signals)
deadline = time.perf_counter() + 3.0
while time.perf_counter() < deadline:
app.processEvents()
if any(item["event"] == "request_quit" for item in events):
break
time.sleep(0.001)
QThreadPool.globalInstance().waitForDone(3000)
app.processEvents()
names = [item["event"] for item in events]
expected = [
"download_enter",
"download_return",
"result_slot_enter",
"apply_enter",
"apply_return",
"result_slot_return",
"finished_slot_enter",
"request_quit",
"finished_slot_return",
]
slot_threads = {
item["thread_id"]
for item in events
if item["event"] in {"result_slot_enter", "finished_slot_enter", "request_quit"}
}
if names != expected or slot_threads != {main_thread}:
failures.append(
{
"iteration": index,
"events": events,
"main_thread_id": main_thread,
}
)
if index < 3:
samples.append(events)
session.deleteLater()
dialog.deleteLater()
del session, dialog, host
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
app.processEvents()
gc.collect()
app.processEvents()
if signal_ref() is None:
collected_signals += 1
finally:
update_ui.is_frozen_install = original_edges["is_frozen_install"]
update_ui.frozen_install_root = original_edges["frozen_install_root"]
update_ui.download_package = original_edges["download_package"]
update_ui.apply_downloaded_update = original_edges["apply_downloaded_update"]
return {
"iterations": iterations,
"failures": failures,
"signals_collected_after_iteration": collected_signals,
"main_thread_id": main_thread,
"samples": samples,
}
def exercise_real_popen() -> dict[str, Any]:
if sys.platform != "win32":
return {"skipped": f"requires win32, got {sys.platform}"}
with tempfile.TemporaryDirectory(prefix="zyt-update-helper-") as raw_tmp:
temp_root = Path(raw_tmp)
script = temp_root / "probe_helper.ps1"
script.write_text(
"\n".join(
[
"param(",
" [int]$TargetPid, [string]$Installer, [string]$RestartExe,",
" [string]$HelperLogFile, [string]$InstallerLogFile",
")",
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
"Start-Sleep -Milliseconds 1200",
"Set-Content -LiteralPath $HelperLogFile -Value 'child-complete'",
]
),
encoding="utf-8-sig",
)
installer = temp_root / "Setup.exe"
installer.write_bytes(b"MZ")
restart = temp_root / "DoctorWorkstation.exe"
restart.write_bytes(b"MZ")
helper_log = temp_root / "helper.log"
fixed_log = temp_root / "fixed.log"
installer_log = temp_root / "installer.log"
original_script = script.read_text(encoding="utf-8-sig")
fixed_log_literal = str(fixed_log).replace("'", "''")
script.write_text(
original_script.replace(
"Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
"Set-Content -LiteralPath '"
+ fixed_log_literal
+ "' -Value (\"helper=<{0}> args=<{1}>\" -f $HelperLogFile, ($args -join '|'))\n"
+ "Set-Content -LiteralPath $HelperLogFile -Value 'child-started'",
),
encoding="utf-8-sig",
)
captured: list[subprocess.Popen[Any]] = []
captured_calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
real_popen = update_service.subprocess.Popen
def capture_popen(*args: Any, **kwargs: Any) -> subprocess.Popen[Any]:
captured_calls.append((args, kwargs.copy()))
process = real_popen(*args, **kwargs)
captured.append(process)
return process
update_service.subprocess.Popen = capture_popen
try:
started = time.perf_counter()
update_service._spawn_inno_setup_applier(
script,
installer=installer,
restart_exe=restart,
helper_log_file=helper_log,
installer_log_file=installer_log,
)
returned_ms = round((time.perf_counter() - started) * 1000, 3)
finally:
update_service.subprocess.Popen = real_popen
deadline = time.perf_counter() + 2.5
child_log = ""
while time.perf_counter() < deadline:
if helper_log.exists():
child_log = helper_log.read_text(encoding="utf-8").strip()
if child_log == "child-complete":
break
time.sleep(0.05)
return_code = captured[0].poll() if captured else None
if captured and return_code is None:
return_code = captured[0].wait(timeout=2.0)
matrix: dict[str, Any] = {}
if captured_calls:
command = list(captured_calls[0][0][0])
flag_cases = {
"zero": 0,
"detached": getattr(subprocess, "DETACHED_PROCESS", 0),
"new_process_group": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
"no_window": getattr(subprocess, "CREATE_NO_WINDOW", 0),
"detached_new_group": getattr(subprocess, "DETACHED_PROCESS", 0)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
"detached_no_window": getattr(subprocess, "DETACHED_PROCESS", 0)
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
"new_group_no_window": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
"production_all": getattr(subprocess, "DETACHED_PROCESS", 0)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
| getattr(subprocess, "CREATE_NO_WINDOW", 0),
}
running: dict[str, tuple[subprocess.Popen[Any], Path]] = {}
helper_parameter = command.index("-HelperLogFile") + 1
for name, flags in flag_cases.items():
case_command = command.copy()
case_log = temp_root / f"matrix-{name}.log"
case_command[helper_parameter] = str(case_log)
process = real_popen(
case_command,
close_fds=True,
creationflags=flags,
cwd=str(temp_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
running[name] = (process, case_log)
production_flags = flag_cases["production_all"]
detached_flags = flag_cases["detached"]
extra_cases = {
"production_close_false": (production_flags, False, False),
"production_all_devnull": (production_flags, True, True),
"detached_close_false": (detached_flags, False, False),
"detached_all_devnull": (detached_flags, True, True),
}
for name, (flags, close_fds, all_devnull) in extra_cases.items():
case_command = command.copy()
case_log = temp_root / f"matrix-{name}.log"
case_command[helper_parameter] = str(case_log)
stream_kwargs = (
{
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
if all_devnull
else {}
)
process = real_popen(
case_command,
close_fds=close_fds,
creationflags=flags,
cwd=str(temp_root),
**stream_kwargs,
)
flag_cases[name] = flags
running[name] = (process, case_log)
matrix_deadline = time.perf_counter() + 3.0
while time.perf_counter() < matrix_deadline:
if all(case_log.exists() for _, case_log in running.values()):
break
time.sleep(0.05)
for name, (process, case_log) in running.items():
matrix[name] = {
"flags": flag_cases[name],
"log_created": case_log.exists(),
"return_code": process.poll(),
}
control_return_code = None
control_stdout = ""
control_stderr = ""
if not fixed_log.exists() and captured_calls:
call_args, call_kwargs = captured_calls[0]
call_kwargs.update(
creationflags=0,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
control = real_popen(*call_args, **call_kwargs)
control_stdout, control_stderr = control.communicate(timeout=5.0)
control_return_code = control.returncode
return {
"command": captured_calls[0][0][0] if captured_calls else [],
"spawn_returned_ms": returned_ms,
"child_completed": child_log == "child-complete",
"child_log": child_log,
"fixed_log": fixed_log.read_text(encoding="utf-8").strip()
if fixed_log.exists()
else "",
"child_return_code": return_code,
"flag_matrix": matrix,
"control_return_code": control_return_code,
"control_stdout": control_stdout,
"control_stderr": control_stderr,
}
def exercise_controller_quit() -> dict[str, Any]:
"""Run the production request_quit method against a real Qt event loop."""
from doctor_workstation.app import ApplicationController
app = QApplication.instance() or QApplication([])
events: list[str] = []
holder = SimpleNamespace(application=app, _shutting_down=False)
app.aboutToQuit.connect(lambda: events.append("aboutToQuit"))
QTimer.singleShot(
0,
lambda: (
events.append("request_quit_enter"),
ApplicationController.request_quit(holder),
events.append("request_quit_return"),
),
)
watchdog = QTimer()
watchdog.setSingleShot(True)
watchdog.timeout.connect(lambda: (events.append("watchdog"), app.quit()))
watchdog.start(1000)
started = time.perf_counter()
return_code = app.exec()
return {
"events": events,
"return_code": return_code,
"returned_ms": round((time.perf_counter() - started) * 1000, 3),
}
if __name__ == "__main__":
iteration_count = int(sys.argv[1]) if len(sys.argv) > 1 else 25
output = {
"session": exercise_session(iteration_count),
"real_popen": exercise_real_popen(),
"controller_quit": exercise_controller_quit(),
}
print(json.dumps(output, ensure_ascii=False, indent=2))
+200
View File
@@ -0,0 +1,200 @@
# AppUpdateSession 更新提交时序复现报告
日期:2026-08-28
环境:Windows、Python 3.12.13、PySide6 6.11.1、uv 0.11.8
范围:只读检查生产源码和现有测试;新增的唯一测试资产是
`app/research/update_commit_repro.py`,未修改生产源码和既有测试。
## 结论
1. **当前工作树中的 `result -> finished -> request_quit` 时序可以稳定复现为正确。**
100 次真实 `QThreadPool` 跨线程循环没有一次乱序或丢失:下载/prepare 在 worker
线程,`_finish_install()``_on_install_finished()``request_quit()` 都在 GUI 主线程。
2. **`_TaskSignals` 不会因局部变量释放而提前消失。** 安装期间它同时被 `_Task`
`AppUpdateSession._signals``_active_install_signals` 强引用;`finished` 到达后才清空
session 引用。探针在主动 `gc.collect()` 后仍观察到 wrapper 存活,风险方向是残留/泄漏,
不是过早 GC 导致信号丢失。
3. **`ApplicationController.request_quit()` 本身有效。** 在真实 Qt 事件循环里,调用顺序是
`request_quit_enter -> request_quit_return -> aboutToQuit`,没有触发 1 秒 watchdog;本次
测量从进入事件循环到退出约 0.1 ms。
4. **`apply_downloaded_update()``Popen` 调用不阻塞 GUI。** 生产参数下
`_spawn_inno_setup_applier()` 约 2.7--4.7 ms 返回。
5. **真正可复现的安装失败位于 Windows helper 启动。** 当前代码组合
`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` 启动 Windows
PowerShell。调用会快速返回,PowerShell 进程退出码甚至是 0,但脚本没有执行第一条写日志
命令。标志矩阵表明:本机上任何包含 `DETACHED_PROCESS` 的组合都失败;去掉它后,
`CREATE_NEW_PROCESS_GROUP``CREATE_NO_WINDOW` 以及两者组合均能执行脚本。
6. **本机真实 18:08 更新尝试与复现完全一致。** 安装包和 `install_update.ps1` 都在
18:08:37 生成,证明 Qt `result` 已投递且 `_finish_install()` 已进入
`apply_downloaded_update()`;但同目录没有 `update_helper.log``inno_setup.log`,说明
helper 没有运行到脚本第 20 行的第一条 `Write-Log`
因此,“下载完成后没有进入安装”的首要根因不是 `finished` 信号丢失,也不是
`request_quit()` 失效,而是 **`DETACHED_PROCESS` 令 PowerShell helper 静默不执行**。
当前源码仍会在 `finished` 后请求退出,所以如果现场描述为“窗口也一直不关闭”,这部分在当前
工作树中未能复现;现有日志更符合“应用已走到提交/退出路径,但安装器从未启动,因此没有安装
和重启”的用户观感。成功路径没有阶段日志,无法仅凭旧日志证明窗口具体关闭时刻。
## 源码时序
相关位置:
- `app/src/doctor_workstation/ui/dialogs/app_update.py:68-91``_Task.run()` 在同一个
`try/else/finally` 中先 `result.emit(result)`,再 `finished.emit()`
- `app/src/doctor_workstation/ui/dialogs/app_update.py:503-555`:安装任务创建
`_TaskSignals`,保存到 `_signals``_active_install_signals`,然后连接
`progress/status/result/error/finished`
- `app/src/doctor_workstation/ui/dialogs/app_update.py:601-641``result` 槽先设置
`_apply_committed=True` 并同步调用 `apply_downloaded_update()``finished` 槽随后清理活动
signals,并在 `_apply_committed``_exit_requested` 时调用 `_complete_quit()`
- `app/src/doctor_workstation/app.py:1081-1086`controller 通过
`QTimer.singleShot(0, application.quit)` 请求正常退出。
- `app/src/doctor_workstation/app.py:427,1088-1105``aboutToQuit` 同步进入幂等
`shutdown()`,更新 session 先被 invalidated,再清理视频和 remote client。
- `app/src/doctor_workstation/services/app_update.py:637-672`Inno helper 的 Windows
`Popen` 和三个 creation flags。
必须注意一个 Qt 细节:worker 发出 `result` 后不会等待 GUI 槽执行,紧接着就发出
`finished`;两者作为同一 sender 的跨线程事件按连接顺序排入 GUI 队列。本次 100 次实测均为:
```text
worker: download/prepare return
-> GUI: result slot enter
-> GUI: apply_downloaded_update enter/return
-> GUI: result slot return
-> GUI: finished slot enter
-> GUI: request_quit
```
这也意味着:如果 `apply_downloaded_update()` 真正阻塞,排在它后面的 `finished` 和 quit 会一起
延迟。但本机真实 `Popen` 返回只需数毫秒,未观察到阻塞。
## 复现结果
### 1. 现有测试
```powershell
$env:QT_QPA_PLATFORM='offscreen'
uv run --project app pytest app/tests/test_app_update_ui.py app/tests/test_app_update.py -q
```
结果:`28 passed`
现有 `test_session_waits_for_update_worker_before_quitting` 是直接调用私有槽的同步单元测试,能够
检查状态门禁,但没有经过 `QThreadPool`/Qt queued delivery;这正是独立探针需要补足的部分。
### 2. 独立跨线程与 helper 探针
```powershell
$env:QT_QPA_PLATFORM='offscreen'
uv run --project app python app/research/update_commit_repro.py 100
```
关键结果:
```text
session.iterations = 100
session.failures = []
worker thread != GUI thread
result_slot_enter.thread_id == finished_slot_enter.thread_id
request_quit.thread_id == GUI main thread
_spawn_inno_setup_applier return = 约 3 ms
production flags script log created = false
production PowerShell return code = 0
controller quit events =
request_quit_enter, request_quit_return, aboutToQuit
```
Windows 创建标志矩阵:
| creation flags | 脚本是否执行 |
|---|---:|
| `0` | 是 |
| `CREATE_NEW_PROCESS_GROUP` | 是 |
| `CREATE_NO_WINDOW` | 是 |
| `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` | 是 |
| `DETACHED_PROCESS` | 否 |
| `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` | 否 |
| `DETACHED_PROCESS | CREATE_NO_WINDOW` | 否 |
| 当前生产三标志组合 | 否 |
`close_fds` 改为 false,或给 stdin/stdout/stderr 全部接 `DEVNULL`,均不能挽救包含
`DETACHED_PROCESS` 的组合。
### 3. 真实运行残留
本机目录:
```text
C:\Users\pc\AppData\Local\ZhenYangTang\ZhenyangDoctor\updates\1_3_0\
```
18:08:37 已有:
- `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe`
- `install_update.ps1`
不存在:
- `update_helper.log`
- `inno_setup.log`
`install_update.ps1` 只会在 `_finish_install() -> apply_downloaded_update() ->
apply_inno_setup_update()` 中生成,所以这组残留直接排除了“result 未投递”和
`_TaskSignals` 被提前回收”。脚本第一项业务动作就是写 `waiting for pid ...`;没有 helper log
则失败发生在脚本业务逻辑之前。
另有一个独立的发布数据风险:workspace 名为 `1_3_0`,下载文件名却是 `1.1.0`,而请求中的
当前版本是 `1.2.0`。即使 helper 启动成功,也可能尝试降级安装。服务端 offer 的
`latest_version`、package filename、安装器 FileVersion/产品版本需要在发布端和客户端都做一致性
校验。这不是本次 helper 不启动的直接原因,但上线前必须处理。
## 建议修复方向
1. Windows helper 不使用 `DETACHED_PROCESS`;先验证保留
`CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW` 时,父进程结束后 helper 仍能存活并运行。
2. 不把 `Popen()` 成功等同于 helper 已启动。让 helper 在等待目标 PID 前先原子写一个
`ready`/`waiting` 标记,应用收到握手后才设置最终 commit 并退出;握手超时则留在应用内显示
明确错误。
3. 给成功路径补结构化日志:`prepare_result_received``helper_spawn_requested`
`helper_ready``worker_finished``request_quit``about_to_quit`。当前只有异常日志,现场无法
区分“100% 后仍在 fsync/校验”“helper 启动失败”和“quit 清理较慢”。
4. 对 package 声明版本和安装器版本做一致性校验,拒绝低于当前版本或不同于
`latest_version` 的安装器。
## 建议回归测试
### Qt/session 测试
1. **真实 queued delivery 成功路径**:用 `QThreadPool` 启动 `_Task`,以 event loop 等待,断言
`result slot -> apply return -> finished slot -> request_quit` 严格顺序,并断言所有 UI/controller
槽都在 GUI 线程。
2. **signals 生命周期**:启动任务后删除局部 worker/signals 引用并强制 GC,仍应收到 result 和
finishedfinished 后断开连接并 `deleteLater()`,最终 weakref 应释放,避免长期检查导致残留。
3. **apply 门控**:用 `Event` 暂停 fake apply,断言暂停期间不会调用 quit;释放后 finished 只触发
一次 quit。
4. **apply 失败**`apply_downloaded_update()``AppUpdateError` 时不 quit、
`_apply_committed` 恢复 false;另外补非 `AppUpdateError` 异常,避免意外异常留下 commit=true 后
仍被 finished 退出。
5. **controller 集成**:在真实 `QApplication.exec()` 中调用 controller `request_quit()`spy
`aboutToQuit``app_updater.shutdown()` 和远程 client close,断言各一次且总时长有上界。
### Windows helper 测试
1. **sentinel 启动测试(当前代码应失败)**:生成只写 sentinel 的 PowerShell 文件,使用生产
`_spawn_inno_setup_applier()` 启动,2 秒内必须看到 sentinel;不能只断言 `Popen` 被调用。
2. **父进程退出测试**:子 Python 进程启动 helper 后立即退出;helper 应先记录 waiting/ready
再观察父 PID 消失并写第二个 sentinel,证明去掉 `DETACHED_PROCESS` 后不会被父退出连带杀死。
3. **完整握手测试**:应用只有在 helper ready 后才调用 quithelper 未 ready、提前退出或无法写
日志时,应用保留并展示可重试错误。
4. **打包 smoke**:从实际 PyInstaller onedir/installer 环境执行上述测试,不能只在源码虚拟环境
mock `subprocess.Popen`
## 仓库说明
`AGENTS.md` 声明项目由 Trellis 管理,但当前工作区没有 `.trellis/` 目录,因而无法读取
`.trellis/workflow.md` 或 layer spec;本次按根指令执行并在此记录。工作树原本已有大量未提交
修改,本次没有改动其中任何生产源码或既有测试。
@@ -0,0 +1,115 @@
# Windows 更新安装交接故障诊断
## 结论
“下载完成后显示即将关闭,但程序不退出/不安装”不是下载失败。当前现场同时存在三个问题,其中第 1 项能够确定性复现用户看到的主症状,第 2 项是本次现场已经发生但被代码吞掉的 helper 启动失败,第 3 项是必须立即纠正的发布配置错误。
1. **确定性根因:强制更新对话框拒绝了 `QApplication.quit()` 触发的关闭事件。** 下载 job 完成后,session 启动 helper、设置 `_apply_committed=True`,随后在 `finished` 回调中调用应用级 `request_quit()`;但对话框此时仍是 `offer.force=True``_busy=True`,其 `closeEvent()` 无条件 `ignore()`。Qt 明确允许窗口通过 Close event 阻止 `quit()`,所以事件循环不退出,`aboutToQuit`/`ApplicationController.shutdown()` 也不会发生。helper 又先等待当前 PID 消失,于是交接形成闭环等待。
2. **独立的已确认问题:PowerShell helper 进程被创建后,在执行脚本首条日志之前就退出了,而父进程把“CreateProcess 成功”误判成“helper 已接管”。** 现场有精确的 PowerShell 启动事件,但没有 `update_helper.log`、没有 `inno_setup.log`、没有存活 helper/installer 进程。代码丢弃 `Popen` 句柄、不检查早退、也没有 ready handshake,因此 UI 仍停在“即将关闭”。现有证据不足以还原该次子进程的退出码;这正是当前可观测性缺口。
3. **发布配置错误:服务端宣称最新/最低版本为 `1.3.0`,实际下发的安装器却是 `1.1.0`。** 本机当前运行 `1.2.0`,所以即使退出与 helper 均修复,也会尝试降级安装,而不是升级到 `1.3.0`。修复客户端前应先停止这条强制更新配置。
## 代码交接链路与根因证据
### 1. UI 已经请求退出,但强制对话框否决退出
当前交接顺序是:
1. `_start_install()` 设置 busy、启动 QThreadPool 下载 job`app/src/doctor_workstation/ui/dialogs/app_update.py:481-555`)。
2. job 下载并校验 Inno Setup EXE,返回 `_PreparedUpdate``:511-545`)。
3. `_finish_install()` 先显示“即将关闭程序并自动安装”,再调用 `dialog.set_apply_committed()`;该方法把 `_busy` 保持为 true`:616-628``:275-283`)。
4. `apply_downloaded_update()` 进入 `apply_inno_setup_update()`,写脚本并 `Popen` PowerShell helper`app/src/doctor_workstation/services/app_update.py:429-472`)。
5. QRunnable 随后发送 `finished``_on_install_finished()` 清理 active signals 并调用 `_complete_quit()`UI `:634-641`)。
6. `_complete_quit()` 调用 controller `request_quit()`controller 用 `QTimer.singleShot(0, self.application.quit)` 请求正常退出(UI `:472-479``app/src/doctor_workstation/app.py:1081-1086`)。
7. 但是更新对话框的 `closeEvent()``offer.force` **或** `_busy` 为真时执行 `event.ignore()`UI `:296-300`)。此时两个条件都为真。
使用当前 PySide6 做了不改文件的最小事件循环复现:显示一个 modal dialog,其 `closeEvent()` 执行 `ignore()`50 ms 后调用 `QApplication.quit()`,并设置 2 秒进程级 watchdog。输出为:
```text
calling quit
closeEvent ignored
CODE=9
```
也就是 `quit()` 确实到达了窗口,但被 Close event 否决,事件循环直到 watchdog 才终止。Qt 官方文档同样说明 `QCoreApplication.quit()` 可能被仍未关闭的窗口或被忽略的 Quit/Close event 阻止:<https://doc.qt.io/qt-6/qcoreapplication.html#quit>。
这也解释了为何 controller 的 `aboutToQuit -> shutdown()` 接线本身没有帮助:`aboutToQuit` 只有在退出请求未被阻止时才会发出(`app.py:425-427``:1088-1105`)。
### 2. Inno helper 的等待设计放大了 UI 退出缺陷
生成的 `install_update.ps1` 首先写 `waiting for pid ...`,然后无限轮询 `Get-Process -Id $TargetPid`;只有目标 PID 消失后才启动安装器(service `:566-627`)。父进程传入的是 `os.getpid()``:637-672`)。因此,只要 Qt 主进程被对话框留下,安装器就不可能启动。
安装器参数本身与 Inno 静默更新意图一致:`/VERYSILENT``/SUPPRESSMSGBOXES``/NORESTART``/CLOSEAPPLICATIONS``/NOFORCECLOSEAPPLICATIONS``/NORESTARTAPPLICATIONS``/LOG=...`;返回 `0`/`3010` 后才重启已安装的 `DoctorWorkstation.exe`,其他返回码重启旧程序(`:599-627`)。`powershell.exe -File <script> <script args>` 的排列也符合 Windows PowerShell 5.1 的 `-File` 契约:<https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe?view=powershell-5.1>。
不过当前等待还有两个健壮性问题:
- 循环无超时、只按整数 PID 查询;极端情况下 PID 被复用会继续等待无关进程。更安全的是先取得特定 `Process` 对象/句柄,再等待该对象退出,而不是每 400 ms 重新按 ID 查找。
- helper 的成功标准只是 `subprocess.Popen(...)` 没有同步抛出 `OSError``Popen` 返回后句柄立即丢失,没有 child PID 日志、早退检测、stderr 捕获或 ready handshake`:637-672`)。
当前还同时设置 `DETACHED_PROCESS``CREATE_NEW_PROCESS_GROUP``CREATE_NO_WINDOW``:645-670`)。Microsoft 文档指出 `CREATE_NO_WINDOW``DETACHED_PROCESS` 同用时会被忽略,因此这组 flag 至少是冗余且不能证明 helper 已进入脚本:<https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags>。本次现场只能确认 PowerShell 在脚本体前早退,不能仅凭事件日志断言具体是 flag、stdio、执行策略还是主机初始化中的哪一个原因;修复应以“可确认接管”为契约,而不是猜一个退出原因。
### 3. `apply_downloaded_update()` 的路由本身正确
`package_type=inno_setup` 会进入 `apply_inno_setup_update()`;后者要求 Windows、可解析的 frozen install root、存在的已安装 EXE,以及扩展名为 `.exe` 且 DOS header 为 `MZ` 的安装器(service `:390-401``:429-483`)。现场已经生成 `install_update.ps1`,证明路由、install root、EXE/PE 基本校验均已通过;故障发生在 helper 启动及应用退出交接之后。
## 本机现场证据(2026-08-28Asia/Shanghai
- 运行进程:PID `36304``C:\Program Files\ZYT\DoctorWorkstation\DoctorWorkstation.exe`,启动于 `18:08:13`;检查时仍 `Responding=True`、主窗口可见,文件 `ProductVersion=1.2.0`
- 应用日志:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\Logs\doctor-workstation.log`
- `18:08:13`:应用启动。
- `18:08:15`:以 `current_version=1.2.0&platform=windows&arch=x64` 检查更新成功。
- `18:08:32`:下载 `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe` 返回 HTTP 200。
- 此后没有安装/helper/退出阶段日志,也没有 Python 异常。
- 工作区:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\updates\1_3_0`
- 安装器大小 `162,873,123` 字节,SHA-256 `0CDD38DB6EEF5E7B7380FFAE3FBC407B602EB916C68AE4C8F29496B621F50789``ProductVersion=1.1.0`,未签名。
- `install_update.ps1``18:08:37` 生成,PowerShell AST 解析无语法错误。
- `update_helper.log` 不存在;`inno_setup.log` 不存在。
- Windows PowerShell Operational 日志:`18:08:37.828` 有 Event `40961`“Powershell 控制台正在启动”,没有配对的 ready `40962`;检查时也没有命令行指向 `install_update.ps1` 的 PowerShell 或 Inno Setup 进程。这证明子进程被创建过,但没有进入脚本首条 `Write-Log`
- crash log 只有各次进程启动标记,本次没有崩溃堆栈。
- 实时只读请求更新接口得到:`latest_version=1.3.0``min_version=1.3.0``force=true`,但 URL、filename、size、sha256 全都对应上述 `1.1.0` 安装器。本机实测文件 hash 与接口 SHA 一致,说明下载正确,错误在发布元数据/产物绑定。
## 最小安全修复
### P0:先修发布配置
在正确的 `1.3.0` 安装器上传并核对 `ProductVersion`、filename、size、SHA-256 前,立即关闭这条强制更新或将其设为不可安装。不要让 `latest_version=1.3.0` 继续绑定 `1.1.0` 安装器。客户端后续应增加“offer 版本与包版本”的发布门禁;仅校验 HTTPS、SHA 和 `MZ` 不能防止签名正确的旧包被错误发布。
### P0:让应用级退出能够越过“用户不可关闭”的对话框门禁
不要把“禁止用户关闭强制更新弹窗”和“禁止应用已提交后的受控退出”共用同一个 `closeEvent` 条件。建议增加明确的 `_allow_application_exit` 状态:
- 用户点击标题栏关闭/Escape 时仍然拒绝;
- helper 已确认接管,或用户点击专用“退出软件”且更新 worker 已安全结束时,session 先设置 allow 状态并关闭/隐藏该 dialog,再调用 controller `request_quit()`
- `closeEvent()` 仅在 `not _allow_application_exit and (offer.force or _busy)` 时 ignore。
直接在主线程调用 `QCoreApplication.exit(0)`也能绕过 Close event,但会绕过其他窗口的正常 close 协议;相比之下,显式放行本更新对话框后继续走既有 `QApplication.quit -> aboutToQuit -> shutdown` 更小、更安全。
### P0:把 helper“已接管”变成可验证状态
`_spawn_inno_setup_applier()` 应返回并保留 `Popen`/child PID,且 helper 在做任何 PID 等待前原子写入 ready 标记(或首条结构化 bootstrap log)。父进程应异步等待一个很短且有界的 ready 窗口:
- ready 到达且 child 仍存活后,才设置 `_apply_committed=True`、放行 dialog close、请求主程序退出;
- child 在 ready 前退出时,读取退出码/bootstrap stderr,留在当前 UI 显示错误,不退出主程序;
- 使用 `-NonInteractive`,明确重定向 stdin/stdout/stderr 到日志或 `DEVNULL`;规范化 creation flags,不同时依赖会被忽略的 `DETACHED_PROCESS + CREATE_NO_WINDOW`
- helper 顶层捕获脚本初始化、首条日志、PID wait、安装器启动等所有异常,并写明阶段和退出码。
这样即使本次 PowerShell 早退的底层原因在另一台机器上不同,也不会再出现“UI 已锁死并宣称即将关闭,但其实无人接管”的假成功。
## 建议测试
现有 `app/tests/test_app_update.py``app/tests/test_app_update_ui.py``28 passed`,但没有覆盖真实交接:service 测试只截获 `_spawn_inno_setup_applier` 并检查脚本文本;UI 测试只断言 mock `host.request_quit` 被调用,没有运行 `QApplication` 事件循环,也没有验证强制 dialog 是否会否决 quit。现有 installer smoke 直接执行安装器,也绕过了应用退出与 helper。
建议至少增加:
1. **Qt 子进程回归(必须)**:显示 `force=True` 且 busy/apply committed 的真实 `AppUpdateDialog`,触发 session 完成退出,以 watchdog 保底;断言事件循环正常返回 `0``aboutToQuit` 发生,而不是被 `closeEvent` 卡住。
2. **显式退出路径**:强制更新下载中点击“退出软件”,worker `finished` 后同样能退出;result 已排队但退出先处理时不得启动 helper。
3. **helper ready 成功**:使用临时 noop PowerShell fixture 和真实生产 creation flags,断言 child PID、ready、等待目标进程退出、后续阶段按顺序发生,并覆盖路径含空格。
4. **helper 早退**:脚本缺失/解析失败/首条日志失败时,断言父进程取得非零退出码、不设置 committed、不退出、UI 可重试且有明确日志路径。
5. **PID 身份**:目标进程退出后即使整数 PID 被模拟复用,也不会等待或误伤新进程;等待有诊断超时但绝不在无法确认旧进程退出时启动安装。
6. **隔离端到端 Inno 更新**:从一个测试 frozen app 发起 handoff,确认旧 PID 消失、helper log 产生、安装器 log 产生、目标版本真正安装、只重启一次。不要只测试安装器单独运行。
7. **发布契约**:服务端 `latest_version`、package filename/manifest `ProductVersion`、SHA/size 必须属于同一版本;构造 `latest=1.3.0 + package=1.1.0` 时发布或客户端安装必须失败。
## 审阅边界
- 已读取根 `AGENTS.md`;仓库不存在 `.trellis/`,因此没有额外 workflow/spec 可读取。
- 工作树原本已有大量未提交改动,包括本报告涉及的生产文件和测试;本次未修改、覆盖或回退它们。
- 除新增本文档外,没有修改生产代码或测试。
+7 -2
View File
@@ -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"
+26 -15
View File
@@ -400,16 +400,17 @@ class ApplicationController(QObject):
"""Own windows, repositories and the authenticated application lifecycle."""
def __init__(self, application: QApplication, config: AppConfig) -> None:
super().__init__()
self.application = application
self.config = config
self.token_store = TokenStore(config.config_dir / "credentials.json")
self.demo_repository = DemoDoctorRepository()
super().__init__()
self.application = application
self.config = config
self.debug_mode = bool(getattr(config, "debug_mode", False))
self.token_store = TokenStore(config.config_dir / "credentials.json")
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
self.remote_repository: RemoteDoctorRepository | None = None
self.login_window: LoginWindow | None = None
self.shell_window: ShellWindow | None = None
self.current_repository: Any = None
self.current_demo_mode = config.demo_mode
self.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] = {}
@@ -505,10 +506,11 @@ class ApplicationController(QObject):
if self.login_window is not None:
self.login_window.config = self.config
def _on_demo_mode_changed(self, enabled: bool) -> None:
self.current_demo_mode = enabled
if enabled:
self._cancel_session_restore()
def _on_demo_mode_changed(self, enabled: bool) -> None:
allowed = self.debug_mode and enabled
self.current_demo_mode = allowed
if allowed:
self._cancel_session_restore()
def _rebuild_remote_repository(self) -> None:
old = self.remote_repository
@@ -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()
@@ -1072,16 +1075,24 @@ class ApplicationController(QObject):
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = app_icon_path()
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
def request_quit(self) -> None:
"""Queue a normal application exit so owned resources are released."""
if self._shutting_down:
return
QTimer.singleShot(0, self.application.quit)
def shutdown(self) -> None:
"""Invalidate asynchronous restoration and release owned resources."""
if self._shutting_down:
return
self._shutting_down = True
self._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())
+27 -6
View File
@@ -14,6 +14,8 @@ from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from doctor_workstation import DEBUG_MODE, ONLINE_API_BASE_URL
try:
from dotenv import load_dotenv
except ImportError: # pragma: no cover - optional during pure unit tests
@@ -90,7 +92,8 @@ class AppConfig:
"""Runtime configuration loaded from environment and user preferences."""
api_base_url: str = ""
demo_mode: bool = True
demo_mode: bool = False
debug_mode: bool = DEBUG_MODE
video_mode: str = "embedded"
video_web_url: str = ""
verify_ssl: bool = True
@@ -115,18 +118,28 @@ class AppConfig:
if load_dotenv is not None:
load_dotenv(dotenv_path=env_file, override=False)
raw_url = os.getenv("DOCTOR_API_BASE_URL", "")
debug_mode = bool(DEBUG_MODE)
raw_url = (
os.getenv("DOCTOR_API_BASE_URL", "") if debug_mode else ONLINE_API_BASE_URL
)
try:
api_url = normalize_api_base_url(raw_url)
except ValueError:
except ValueError as error:
if not debug_mode:
raise ValueError("ONLINE_API_BASE_URL 必须是有效的 HTTP(S) 域名") from error
api_url = ""
if not debug_mode and not api_url:
raise ValueError("正式模式下 ONLINE_API_BASE_URL 不能为空")
config = cls(
api_base_url=api_url,
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), True),
demo_mode=_as_bool(os.getenv("DOCTOR_DEMO_MODE"), False) if debug_mode else False,
debug_mode=debug_mode,
video_mode=os.getenv("DOCTOR_VIDEO_MODE", "embedded").strip().lower(),
video_web_url=os.getenv("DOCTOR_VIDEO_WEB_URL", "").strip(),
verify_ssl=_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True),
verify_ssl=(
_as_bool(os.getenv("DOCTOR_VERIFY_SSL"), True) if debug_mode else True
),
request_timeout=_safe_timeout(os.getenv("DOCTOR_REQUEST_TIMEOUT")),
log_level=os.getenv("DOCTOR_LOG_LEVEL", "INFO").strip().upper(),
)
@@ -137,7 +150,9 @@ class AppConfig:
payload = json.loads(self.preferences_file.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return self
allowed = {item.name for item in fields(self)}
allowed = {item.name for item in fields(self)} - {"debug_mode"}
if not self.debug_mode:
allowed -= {"api_base_url", "demo_mode", "verify_ssl"}
clean: dict[str, Any] = {key: value for key, value in payload.items() if key in allowed}
if "api_base_url" in clean:
try:
@@ -159,12 +174,18 @@ class AppConfig:
target = self.preferences_file
temporary = target.with_suffix(".tmp")
payload = asdict(self)
payload.pop("debug_mode", None)
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
with suppress(OSError):
os.chmod(temporary, 0o600)
temporary.replace(target)
def with_updates(self, **changes: Any) -> AppConfig:
changes.pop("debug_mode", None)
if not self.debug_mode:
changes.pop("api_base_url", None)
changes.pop("demo_mode", None)
changes.pop("verify_ssl", None)
if "api_base_url" in changes:
changes["api_base_url"] = normalize_api_base_url(str(changes["api_base_url"]))
if "video_mode" in changes and changes["video_mode"] not in {"embedded", "browser"}:
File diff suppressed because it is too large Load Diff
@@ -1705,6 +1705,24 @@ class DemoDoctorRepository:
}
return deepcopy(dictionaries.get(dictionary_type, []))
def list_chat_notifications(self) -> list[dict[str, Any]]:
"""Demo mode never queues server-side chat notifications."""
return []
def get_dictionaries(
self, dictionary_types: Sequence[str]
) -> dict[str, list[dict[str, Any]]]:
"""Mirror the remote batch dictionary contract used by readonly screens."""
result: dict[str, list[dict[str, Any]]] = {}
for dictionary_type in dictionary_types:
clean = str(dictionary_type or "").strip()
if not clean or clean in result:
continue
result[clean] = self.get_dictionary(clean)
return result
def list_patients(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Patient]:
@@ -455,6 +455,14 @@ class DoctorRepository(Protocol):
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
"""Return one configuration dictionary."""
def get_dictionaries(
self, dictionary_types: Sequence[str]
) -> dict[str, list[dict[str, Any]]]:
"""Return several configuration dictionaries in one call."""
def list_chat_notifications(self) -> list[dict[str, Any]]:
"""Consume this account's queued chat notifications."""
def get_patient_order(self, order_id: int) -> dict[str, Any]:
"""Return a patient-scoped order detail."""
@@ -1761,6 +1769,40 @@ class RemoteDoctorRepository:
payload = self.client.get("config/dict", {"type": clean})
return _dictionary_rows(payload, clean)
def get_dictionaries(
self, dictionary_types: Sequence[str]
) -> dict[str, list[dict[str, Any]]]:
"""Load several dictionaries with one request.
``ConfigLogic::getDictByType`` splits ``type`` on commas and answers
``{type_value: [rows...]}``, so readonly screens can translate every
dictionary field without one round trip per type.
"""
wanted = [
str(dictionary_type or "").strip()
for dictionary_type in dictionary_types
if str(dictionary_type or "").strip()
]
ordered = list(dict.fromkeys(wanted))
if not ordered:
return {}
payload = self.client.get("config/dict", {"type": ",".join(ordered)})
return {
dictionary_type: _dictionary_rows(payload, dictionary_type)
for dictionary_type in ordered
}
def list_chat_notifications(self) -> list[dict[str, Any]]:
"""Consume the queued chat notifications for the signed-in account.
与后台一致:``ChatController::notifications`` 取一次即消费(服务端删除缓存),
所以每条通知只会被送到一个已登录的客户端一次,不能重复轮询后再补偿。
"""
payload = self.client.get("chat/notifications")
return _mapping_rows(payload)
def list_patients(
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
) -> PageResult[Patient]:
@@ -0,0 +1,433 @@
"""登录后常驻的聊天通知:患者打开会话、离开会话、面诊结束。
与后台 ``admin/src/components/chat-notify-toast`` 同一套合同轮询
``chat/notifications``服务端取一次即消费把结果堆成右上角卡片点击卡片进入
对应工作面关闭按钮单独移除桌面端额外做了一件网页做不到的事新通知到达时让
任务栏闪一下医生切到别的窗口也能看见
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from PySide6.QtCore import QEvent, QObject, Qt, QTimer, Signal
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from .widgets import display_text, first_value, run_async
# 后台 ChatNotifyLogic 写入的三种 type。
PATIENT_OPENED_CHAT = "patient_opened_chat"
PATIENT_LEFT_CHAT = "patient_left_chat"
CONSULTATION_COMPLETE = "consultation_complete"
_TITLES: dict[str, str] = {
PATIENT_OPENED_CHAT: "患者打开会话",
PATIENT_LEFT_CHAT: "患者离开会话",
CONSULTATION_COMPLETE: "面诊结束",
}
_MAX_CARDS = 5
_POLL_INTERVAL_MS = 5_000
CHAT_NOTIFICATION_QSS = """
QFrame#ChatNotifyCard {
background-color: #FFFFFF;
border: 1px solid #BBF0CE;
border-radius: 12px;
}
QFrame#ChatNotifyCard[kind="left"] { border-color: #D8DEEE; }
QFrame#ChatNotifyCard[kind="complete"] { border-color: #C3D6FF; }
QLabel#ChatNotifyBadge {
min-width: 34px;
max-width: 34px;
min-height: 34px;
max-height: 34px;
color: #FFFFFF;
background-color: #22C55E;
border-radius: 9px;
font-size: 15px;
font-weight: 700;
}
QLabel#ChatNotifyBadge[kind="left"] { background-color: #8A94B3; }
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #3B82F6; }
QLabel#ChatNotifyTitle { color: #1F2A44; font-size: 13px; font-weight: 700; }
QLabel#ChatNotifyDesc { color: #4A5878; font-size: 12px; }
QLabel#ChatNotifyTime { color: #8A94B3; font-size: 11px; }
QPushButton#ChatNotifyOpen {
min-height: 26px;
padding: 0 10px;
color: #3F4E75;
background-color: #F4F6FC;
border: 1px solid #DDE3F2;
border-radius: 7px;
font-size: 12px;
font-weight: 600;
}
QPushButton#ChatNotifyOpen:hover {
color: #4451E2;
background-color: #EEF1FF;
border-color: #8D9BFF;
}
QPushButton#ChatNotifyClose {
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
color: #8A94B3;
background-color: transparent;
border: 0;
border-radius: 6px;
font-size: 14px;
}
QPushButton#ChatNotifyClose:hover { color: #4A5878; background-color: #EDF0F7; }
"""
@dataclass(frozen=True)
class ChatNotification:
"""One queued notification, normalised from the admin payload."""
id: str
kind: str
patient_name: str
patient_id: str
doctor_name: str
diagnosis_id: int
created_at: int
@property
def title(self) -> str:
return _TITLES.get(self.kind, "系统通知")
@property
def description(self) -> str:
patient = self.patient_name or "患者"
if self.kind == CONSULTATION_COMPLETE:
doctor = self.doctor_name or "医生"
return f"{patient} 的面诊已由 {doctor} 完成,请及时跟进"
if self.kind == PATIENT_LEFT_CHAT:
return f"{patient} 已离开问诊会话页面"
return f"{patient} 已打开与您的会话,请及时查看"
@property
def action_text(self) -> str:
if self.kind == CONSULTATION_COMPLETE:
return "查看诊单"
if self.kind == PATIENT_OPENED_CHAT:
return "去接诊台"
return ""
def parse_notification(row: Any) -> ChatNotification | None:
"""Normalise one server row; unknown or id-less rows are dropped."""
if not isinstance(row, Mapping):
return None
identifier = str(first_value(row, "id", default="") or "").strip()
kind = str(first_value(row, "type", default=PATIENT_OPENED_CHAT) or "").strip()
if not identifier or kind not in _TITLES:
return None
try:
diagnosis_id = int(first_value(row, "diagnosis_id", default=0) or 0)
except (TypeError, ValueError):
diagnosis_id = 0
try:
created_at = int(first_value(row, "created_at", default=0) or 0)
except (TypeError, ValueError):
created_at = 0
return ChatNotification(
id=identifier,
kind=kind,
patient_name=display_text(first_value(row, "patient_name"), ""),
patient_id=str(first_value(row, "patient_id", default="") or "").strip(),
doctor_name=display_text(first_value(row, "doctor_name"), ""),
diagnosis_id=diagnosis_id,
created_at=created_at,
)
def relative_time(created_at: int, *, now: float | None = None) -> str:
"""Match the admin card's 刚刚 / N 分钟前 wording."""
if not created_at:
return ""
current = datetime.now().timestamp() if now is None else now
delta = max(0, int(current - created_at))
if delta < 60:
return "刚刚"
if delta < 3600:
return f"{delta // 60} 分钟前"
if delta < 86400:
return f"{delta // 3600} 小时前"
return datetime.fromtimestamp(created_at).strftime("%m-%d %H:%M")
class _NotificationCard(QFrame):
"""One dismissible card; the whole surface is a click target."""
opened = Signal(str)
dismissed = Signal(str)
def __init__(self, notification: ChatNotification, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.notification = notification
self.setObjectName("ChatNotifyCard")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
kind = (
"complete"
if notification.kind == CONSULTATION_COMPLETE
else "left"
if notification.kind == PATIENT_LEFT_CHAT
else "opened"
)
self.setProperty("kind", kind)
root = QHBoxLayout(self)
root.setContentsMargins(13, 11, 10, 12)
root.setSpacing(11)
badge = QLabel("" if kind == "complete" else "" if kind == "left" else "")
badge.setObjectName("ChatNotifyBadge")
badge.setProperty("kind", kind)
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
root.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
body = QVBoxLayout()
body.setContentsMargins(0, 0, 0, 0)
body.setSpacing(3)
title = QLabel(notification.title)
title.setObjectName("ChatNotifyTitle")
body.addWidget(title)
description = QLabel(notification.description)
description.setObjectName("ChatNotifyDesc")
description.setWordWrap(True)
body.addWidget(description)
self.time_label = QLabel(relative_time(notification.created_at))
self.time_label.setObjectName("ChatNotifyTime")
body.addWidget(self.time_label)
if notification.action_text:
actions = QHBoxLayout()
actions.setContentsMargins(0, 4, 0, 0)
actions.setSpacing(8)
self.open_button = QPushButton(notification.action_text)
self.open_button.setObjectName("ChatNotifyOpen")
self.open_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.open_button.clicked.connect(lambda: self.opened.emit(self.notification.id))
actions.addWidget(self.open_button)
actions.addStretch(1)
body.addLayout(actions)
root.addLayout(body, 1)
self.close_button = QPushButton("×")
self.close_button.setObjectName("ChatNotifyClose")
self.close_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.close_button.setToolTip("忽略这条通知")
self.close_button.clicked.connect(lambda: self.dismissed.emit(self.notification.id))
root.addWidget(self.close_button, 0, Qt.AlignmentFlag.AlignTop)
def refresh_time(self) -> None:
self.time_label.setText(relative_time(self.notification.created_at))
def mouseReleaseEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
if event.button() == Qt.MouseButton.LeftButton:
self.opened.emit(self.notification.id)
super().mouseReleaseEvent(event)
class ChatNotificationCenter(QWidget):
"""Polls the server queue and stacks cards over the shell's top-right corner."""
notification_activated = Signal(object)
def __init__(
self,
repository: Any,
host: QWidget,
*,
interval_ms: int = _POLL_INTERVAL_MS,
) -> None:
super().__init__(host)
self.repository = repository
self.setObjectName("ChatNotifyLayer")
self.setStyleSheet(CHAT_NOTIFICATION_QSS)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self._cards: dict[str, _NotificationCard] = {}
self._pending: list[ChatNotification] = []
self._loading = False
self._generation = 0
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
self._layout = layout
self.hide()
self._timer = QTimer(self)
self._timer.setInterval(max(1_000, int(interval_ms)))
self._timer.timeout.connect(self.poll)
self._clock = QTimer(self)
self._clock.setInterval(30_000)
self._clock.timeout.connect(self._refresh_times)
host.installEventFilter(self)
# ----- polling ------------------------------------------------------
def start(self) -> None:
if not self._timer.isActive():
self._timer.start()
self._clock.start()
self.poll()
def stop(self) -> None:
self._generation += 1
self._timer.stop()
self._clock.stop()
def poll(self) -> None:
"""Ask for queued notifications; overlapping polls are skipped.
服务端取一次即消费重叠请求会让通知丢在被丢弃的那次响应里
"""
if self._loading:
return
fetch = getattr(self.repository, "list_chat_notifications", None)
if not callable(fetch):
return
self._loading = True
generation = self._generation
run_async(
fetch,
on_success=lambda rows: self._loaded(generation, rows),
on_error=lambda _error: None, # 静默失败,避免打断问诊
on_finished=self._finished,
)
def _finished(self) -> None:
self._loading = False
def _loaded(self, generation: int, rows: Any) -> None:
if generation != self._generation:
return
self.add_notifications(rows)
# ----- cards --------------------------------------------------------
@property
def pending(self) -> list[ChatNotification]:
return list(self._pending)
def add_notifications(self, rows: Any) -> int:
"""Show new cards; returns how many were actually added."""
if isinstance(rows, Mapping) or not isinstance(rows, Sequence):
candidates: Sequence[Any] = [rows]
else:
candidates = rows
added = 0
for row in candidates:
notification = parse_notification(row)
if notification is None or notification.id in self._cards:
continue
self._add_card(notification)
added += 1
if added:
self._trim()
self._relayout()
self._alert_taskbar()
return added
def _add_card(self, notification: ChatNotification) -> None:
card = _NotificationCard(notification, self)
card.opened.connect(self._activate)
card.dismissed.connect(self.dismiss)
self._cards[notification.id] = card
self._pending.insert(0, notification)
self._layout.insertWidget(0, card)
def _trim(self) -> None:
while len(self._pending) > _MAX_CARDS:
self.dismiss(self._pending[-1].id, relayout=False)
def dismiss(self, notification_id: str, *, relayout: bool = True) -> None:
card = self._cards.pop(str(notification_id), None)
self._pending = [item for item in self._pending if item.id != str(notification_id)]
if card is not None:
self._layout.removeWidget(card)
card.hide()
card.deleteLater()
if relayout:
self._relayout()
def clear(self) -> None:
for notification_id in list(self._cards):
self.dismiss(notification_id, relayout=False)
self._relayout()
def _activate(self, notification_id: str) -> None:
card = self._cards.get(str(notification_id))
if card is None:
return
notification = card.notification
self.dismiss(notification_id)
self.notification_activated.emit(notification)
def _refresh_times(self) -> None:
for card in self._cards.values():
card.refresh_time()
def _alert_taskbar(self) -> None:
"""Flash the taskbar entry when the doctor is working in another window."""
application = QApplication.instance()
window = self.window()
if application is None or window is None or window.isActiveWindow():
return
application.alert(window, 3_000)
# ----- placement ----------------------------------------------------
def _relayout(self) -> None:
if not self._cards:
self.hide()
return
host = self.parentWidget()
if host is None:
return
width = min(360, max(260, host.width() - 48))
self.setFixedWidth(width)
self.adjustSize()
self.move(max(12, host.width() - width - 24), 74)
self.show()
self.raise_()
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt virtual
if watched is self.parentWidget() and event.type() in {
QEvent.Type.Resize,
QEvent.Type.Show,
}:
self._relayout()
return False
__all__ = [
"CHAT_NOTIFICATION_QSS",
"CONSULTATION_COMPLETE",
"PATIENT_LEFT_CHAT",
"PATIENT_OPENED_CHAT",
"ChatNotification",
"ChatNotificationCenter",
"parse_notification",
"relative_time",
]
@@ -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": "离异"}
# 后台 yesNoText1 有,其余 无。
_YES_NO_FIELDS = frozenset(
{
"trauma_history",
"surgery_history",
"allergy_history",
"family_history",
"pregnancy_history",
}
)
_CREATE_SOURCE_LABELS: dict[str, str] = {
"mnp": "小程序建档",
"mnp_daily": "小程序快捷建档",
"admin": "后台创建",
"doctor": "医生创建",
}
_RECORD_SOURCE_LABELS: dict[str, str] = {"0": "医生录入", "1": "患者自录"}
# 纯内部列:启停标记、统计端展示位、排班偏移与软删除时间对医生没有意义。
INTERNAL_FIELDS = frozenset(
{
"status",
"show_card",
"revisit_slot_start_offset",
"delete_time",
"is_delete",
"is_deleted",
"sort",
"assistant_id",
"doctor_id",
"admin_id",
}
)
# 附件字段单独渲染成缩略图,不再以 URL 文本出现在字段网格里。
IMAGE_FIELDS = frozenset(
{
"tongue_images",
"report_files",
"images",
"breakfast_images",
"lunch_images",
"dinner_images",
}
)
_TIMESTAMP_SUFFIXES = ("_time", "_at", "_date")
_TIMESTAMP_MIN = 10**9 # 2001-09-09,早于本项目任何真实数据
_TIMESTAMP_MAX = 4 * 10**9 # 2096 年,之后按普通数字显示
# 只读页的单位,与 admin PatientCaseCard / BloodRecordList 一致。
_UNIT_SUFFIXES: dict[str, str] = {
"height": " cm",
"weight": " kg",
"systolic_pressure": " mmHg",
"diastolic_pressure": " mmHg",
"fasting_blood_sugar": " mmol/L",
"postprandial_blood_sugar": " mmol/L",
"other_blood_sugar": " mmol/L",
"blood_sugar": " mmol/L",
"duration": " 分钟",
"diabetes_discovery_year": "",
}
def unit_suffix(field: str, value: Any) -> str:
"""Return the unit a numeric readonly field should carry, if any."""
suffix = _UNIT_SUFFIXES.get(str(field or "").strip())
if not suffix:
return ""
text = str(value).strip()
if not text or text.endswith(suffix.strip()):
return ""
# 只给纯数字补单位,"17多"、"五年" 这类自由文本保持原样。
normalized = text.replace(".", "", 1)
return suffix if normalized.isdigit() else ""
def format_timestamp(value: Any, *, with_time: bool = True) -> str | None:
"""把后端的 Unix 秒级时间戳转成中文界面用的日期时间。"""
if isinstance(value, bool):
return None
try:
seconds = int(str(value).strip())
except (TypeError, ValueError):
return None
if not _TIMESTAMP_MIN <= seconds <= _TIMESTAMP_MAX:
return None
moment = datetime.fromtimestamp(seconds)
return moment.strftime("%Y-%m-%d %H:%M" if with_time else "%Y-%m-%d")
def is_timestamp_field(field: str) -> bool:
return str(field or "").endswith(_TIMESTAMP_SUFFIXES)
def gender_label(value: Any) -> str | None:
return _GENDER_LABELS.get(str(value or "").strip().lower())
def split_values(value: Any) -> list[str]:
"""按后台 enrichDiagnosisLabels 的方式拆多值字段。"""
if value in (None, "", []):
return []
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
items = [str(item or "").strip() for item in value]
else:
items = [part.strip() for part in _split_text(str(value))]
return [item for item in items if item]
def _split_text(text: str) -> list[str]:
normalized = text.replace("", ",").replace("", ",")
return normalized.split(",")
class TermIndex:
"""字典翻译表:实时字典覆盖内置种子,两者都缺就回显原值。"""
def __init__(self, dictionaries: Mapping[str, Any] | None = None) -> None:
self._dictionaries: dict[str, dict[str, str]] = {
dictionary_type: dict(options)
for dictionary_type, options in SEED_DICTIONARIES.items()
}
self.merge(dictionaries)
def merge(self, dictionaries: Mapping[str, Any] | None) -> None:
"""并入 ``config/dict`` 下发的 ``{type: [{name, value}, ...]}``。"""
if not isinstance(dictionaries, Mapping):
return
for dictionary_type, rows in dictionaries.items():
options = _options_from_rows(rows)
if options:
self._dictionaries.setdefault(str(dictionary_type), {}).update(options)
def dictionary(self, dictionary_type: str) -> dict[str, str]:
return dict(self._dictionaries.get(str(dictionary_type), {}))
def dictionary_label(self, field: str, value: Any) -> str | None:
"""翻译一个字典字段;不是字典字段或没有可翻译内容时返回 None。"""
dictionary_type = FIELD_DICTIONARIES.get(str(field or "").strip())
if dictionary_type is None:
return None
options = self._dictionaries.get(dictionary_type, {})
items = split_values(value)
if not items:
return None
labels = [options.get(item, item) for item in items]
return "".join(label for label in labels if label) or None
def value_label(self, field: str, value: Any) -> str | None:
"""翻译字典项或枚举;无法翻译时返回 None,由调用方回显原值。"""
key = str(field or "").strip()
if value in (None, "", [], {}):
return None
dictionary_label = self.dictionary_label(key, value)
if dictionary_label is not None:
return dictionary_label
if key in {"gender", "patient_gender", "sex"}:
return gender_label(value)
if key in {"marital_status", "marriage"}:
return _MARITAL_LABELS.get(str(value).strip())
if key in _YES_NO_FIELDS:
text = str(value).strip()
if text in {"0", "1"}:
return "" if text == "1" else ""
return None
if key == "create_source":
return _CREATE_SOURCE_LABELS.get(str(value).strip().lower())
if key == "source":
return _RECORD_SOURCE_LABELS.get(str(value).strip())
if is_timestamp_field(key):
return format_timestamp(value, with_time=not key.endswith("_date"))
return None
def display(self, source: Any, field: str, *, default: str = "") -> str:
"""按 admin ``textOf`` 的口径取值:先 ``<field>_text``,再字典/枚举,最后原值。"""
mapping = source if isinstance(source, Mapping) else {}
key = str(field or "").strip()
translated = mapping.get(f"{key}_text")
if translated in (None, "", []):
translated = mapping.get(f"{key}_desc")
if translated not in (None, "", []):
return _join(translated) + unit_suffix(key, translated)
raw = mapping.get(key)
if raw in (None, "", [], {}):
return default
labelled = self.value_label(key, raw)
if labelled is not None:
return labelled
rendered = _join(raw)
return rendered + unit_suffix(key, rendered)
_SHARED_INDEX = TermIndex()
def shared_terms() -> TermIndex:
"""The process-wide index every readonly screen renders through.
只读界面只用它翻译展示文案所以共享一份即可任何界面取回实时字典后
处方快照那种拿不到 ``*_text`` 的旧数据也能跟着翻译正确
"""
return _SHARED_INDEX
def merge_shared_dictionaries(dictionaries: Mapping[str, Any] | None) -> None:
_SHARED_INDEX.merge(dictionaries)
def _join(value: Any) -> str:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return "".join(str(item).strip() for item in value if str(item).strip())
return str(value).strip()
def _options_from_rows(rows: Any) -> dict[str, str]:
"""从 ``config/dict`` 的行里取 ``value -> name``。"""
options: dict[str, str] = {}
if isinstance(rows, Mapping):
for value, name in rows.items():
code = str(value).strip()
label = str(name).strip()
if code and label:
options[code] = label
return options
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes, bytearray)):
return options
for row in rows:
if not isinstance(row, Mapping):
continue
code = str(row.get("value", "")).strip()
label = str(row.get("name", "")).strip()
if code and label:
options[code] = label
return options
__all__ = [
"DICTIONARY_TYPES",
"FIELD_DICTIONARIES",
"IMAGE_FIELDS",
"INTERNAL_FIELDS",
"MULTI_VALUE_DICTIONARIES",
"SEED_DICTIONARIES",
"SINGLE_VALUE_DICTIONARIES",
"TermIndex",
"format_timestamp",
"merge_shared_dictionaries",
"shared_terms",
"gender_label",
"is_timestamp_field",
"split_values",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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:
+69 -21
View File
@@ -460,6 +460,7 @@ class LoginWindow(QMainWindow):
super().__init__(parent)
self.repository = repository
self.config = config
self.debug_mode = bool(getattr(config, "debug_mode", False))
if demo_repository is None:
demo_repository = getattr(config, "demo_repository", None)
self.demo_repository = demo_repository
@@ -751,6 +752,7 @@ class LoginWindow(QMainWindow):
if self.demo_repository is None:
self.demo_check.setToolTip("当前未配置演示数据")
self.demo_check.toggled.connect(self._on_demo_toggled)
self.demo_check.setVisible(self.debug_mode)
choices.addWidget(self.demo_check)
card_layout.addLayout(choices)
card_layout.addSpacing(26)
@@ -765,8 +767,12 @@ class LoginWindow(QMainWindow):
self.login_button.setGraphicsEffect(login_shadow)
self.login_button.clicked.connect(self.submit)
card_layout.addWidget(self.login_button)
card_layout.addSpacing(20)
self.debug_settings_section = QWidget()
self.debug_settings_section.setObjectName("DebugSettingsSection")
debug_settings_layout = QVBoxLayout(self.debug_settings_section)
debug_settings_layout.setContentsMargins(0, 0, 0, 0)
debug_settings_layout.setSpacing(0)
debug_settings_layout.addSpacing(20)
divider = QHBoxLayout()
divider.setSpacing(18)
line_left = QFrame()
@@ -782,8 +788,8 @@ class LoginWindow(QMainWindow):
line_right.setFrameShape(QFrame.Shape.HLine)
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
divider.addWidget(line_right, 1)
card_layout.addLayout(divider)
card_layout.addSpacing(20)
debug_settings_layout.addLayout(divider)
debug_settings_layout.addSpacing(20)
self.server_toggle = _ServerButton("服务器设置 +")
self.server_toggle.setObjectName("ServerSettingsToggle")
@@ -791,7 +797,7 @@ class LoginWindow(QMainWindow):
self.server_toggle.setCheckable(True)
self.server_toggle.setFixedHeight(56)
self.server_toggle.clicked.connect(self._toggle_server_panel)
card_layout.addWidget(self.server_toggle)
debug_settings_layout.addWidget(self.server_toggle)
self.server_panel = QFrame()
self.server_panel.setObjectName("SubtleCard")
@@ -850,9 +856,11 @@ class LoginWindow(QMainWindow):
self.server_hint.setWordWrap(True)
server_layout.addWidget(self.server_hint)
self.server_panel.setVisible(False)
card_layout.addSpacing(9)
card_layout.addWidget(self.server_panel)
card_layout.addSpacing(12)
debug_settings_layout.addSpacing(9)
debug_settings_layout.addWidget(self.server_panel)
debug_settings_layout.addSpacing(12)
self.debug_settings_section.setVisible(self.debug_mode)
card_layout.addWidget(self.debug_settings_section)
footnote_row = QHBoxLayout()
footnote_row.setContentsMargins(0, 0, 0, 0)
@@ -922,8 +930,13 @@ class LoginWindow(QMainWindow):
configured_url = getattr(self.config, "base_url", "") or getattr(
self.config, "api_base_url", ""
)
restored_url = (
self.settings.value("server/base_url", configured_url)
if self.debug_mode
else configured_url
)
self.server_url_edit.setText(
str(self.settings.value("server/base_url", configured_url) or "")
str(restored_url or "")
)
try:
configured_timeout = getattr(self.config, "request_timeout", 60)
@@ -937,15 +950,22 @@ class LoginWindow(QMainWindow):
configured_verify_ssl = _setting_bool(
getattr(self.config, "verify_ssl", True), True
)
verify_ssl = _setting_bool(
self.settings.value("server/verify_ssl", configured_verify_ssl),
configured_verify_ssl,
verify_ssl = (
_setting_bool(
self.settings.value("server/verify_ssl", configured_verify_ssl),
configured_verify_ssl,
)
if self.debug_mode
else configured_verify_ssl
)
self.allow_self_signed_check.setChecked(not verify_ssl)
if self.demo_repository is not None and bool(
if self.debug_mode and self.demo_repository is not None and bool(
getattr(self.config, "demo_mode", False)
):
self.demo_check.setChecked(True)
elif not self.debug_mode:
self.demo_check.setChecked(False)
self.active_repository = self.repository
self.account_edit.setText(remembered)
self.restore_remembered_credentials()
if remembered:
@@ -954,6 +974,8 @@ class LoginWindow(QMainWindow):
self.account_edit.setFocus()
def _credential_scope(self) -> str:
if not self.debug_mode:
return str(getattr(self.config, "api_base_url", "") or "").strip().rstrip("/")
if hasattr(self, "server_url_edit"):
scope = self.server_url_edit.text().strip()
if scope:
@@ -1000,6 +1022,12 @@ class LoginWindow(QMainWindow):
self.reveal_button.setText("隐藏" if visible else "显示")
def _on_demo_toggled(self, enabled: bool) -> None:
if enabled and not self.debug_mode:
self.demo_check.blockSignals(True)
self.demo_check.setChecked(False)
self.demo_check.blockSignals(False)
self.active_repository = self.repository
return
self.active_repository = self.demo_repository if enabled else self.repository
self.server_toggle.setEnabled(not enabled and not self._loading)
self.demo_mode_changed.emit(enabled)
@@ -1013,6 +1041,10 @@ class LoginWindow(QMainWindow):
self.password_edit.setPlaceholderText("请输入密码")
def _toggle_server_panel(self, expanded: bool) -> None:
if not self.debug_mode:
self.server_toggle.setChecked(False)
self.server_panel.hide()
return
self.server_panel.setVisible(expanded)
self.server_toggle.setText("服务器设置 -" if expanded else "服务器设置 +")
self.server_panel.updateGeometry()
@@ -1027,6 +1059,13 @@ class LoginWindow(QMainWindow):
self._apply_server_settings(announce=True)
def _apply_server_settings(self, *, announce: bool) -> bool:
if not self.debug_mode:
base_url = str(getattr(self.config, "api_base_url", "") or "").strip()
if base_url:
self.server_url_edit.setText(base_url)
return True
self.error_banner.show_message("线上服务器地址尚未配置,请联系管理员。", "warning")
return False
base_url = self.server_url_edit.text().strip().rstrip("/")
if base_url and not base_url.startswith(
("https://", "http://localhost", "http://127.0.0.1")
@@ -1079,7 +1118,7 @@ class LoginWindow(QMainWindow):
def submit(self) -> None:
if self._loading:
return
demo_mode = self.demo_check.isChecked()
demo_mode = self.debug_mode and self.demo_check.isChecked()
remember_account = self.remember_check.isChecked()
account = self.account_edit.text().strip()
password = self.password_edit.text()
@@ -1150,9 +1189,13 @@ class LoginWindow(QMainWindow):
self.password_edit.setEnabled(not loading)
self.remember_check.setEnabled(not loading)
self.reveal_button.setEnabled(not loading)
self.demo_check.setEnabled(not loading and self.demo_repository is not None)
self.server_toggle.setEnabled(not loading and not self.demo_check.isChecked())
self.server_panel.setEnabled(not loading)
self.demo_check.setEnabled(
self.debug_mode and not loading and self.demo_repository is not None
)
self.server_toggle.setEnabled(
self.debug_mode and not loading and not self.demo_check.isChecked()
)
self.server_panel.setEnabled(self.debug_mode and not loading)
self.server_url_edit.setEnabled(not loading)
self.timeout_spin.setEnabled(not loading)
self.allow_self_signed_check.setEnabled(not loading)
@@ -1184,7 +1227,7 @@ class LoginWindow(QMainWindow):
if remember_account is None:
remember_account = self.remember_check.isChecked()
scope = self._credential_scope()
is_demo = bool(payload.get("demo_mode"))
is_demo = self.debug_mode and bool(payload.get("demo_mode"))
password_saved = False
clearer = getattr(self.credential_store, "clear_password", None)
if (
@@ -1223,13 +1266,18 @@ class LoginWindow(QMainWindow):
def _on_login_error(self, error: Exception) -> None:
error_text = str(error).lower()
if (
certificate_error = (
"certificate_verify_failed" in error_text
or "self-signed certificate" in error_text
):
)
if certificate_error and self.debug_mode:
self.server_toggle.setChecked(True)
self._toggle_server_panel(True)
message = friendly_error(error)
message = (
friendly_error(error)
if self.debug_mode or not certificate_error
else "服务器证书校验失败,请联系管理员检查线上域名和证书配置。"
)
self.error_banner.show_message(message, "danger")
self.login_failed.emit(message)
self.password_edit.selectAll()
+41 -3
View File
@@ -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)
+136 -1
View File
@@ -12,9 +12,11 @@ from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
QDialog,
QLabel,
QPushButton,
QTextBrowser,
QVBoxLayout,
QWidget,
)
@@ -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 "&lt;7.0" in html
assert "&amp;" in html
assert "<7.0" not in html
def test_structured_report_uses_a_readable_column_width(application: QApplication) -> None:
reply = (
"针对该患者的用药复核评估如下:\n\n"
"### 1. 当前用药状态评估\n"
"- 无当前处方药物: 病例数据中明确记录患者目前没有服药,系统内也没有有效处方记录。\n"
"- 结论: 患者当前未使用任何药物,不存在药物相互作用或配伍禁忌风险。\n"
"### 2. 临床风险与干预提示\n"
"- 糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖高于一般控制目标且未接受药物治疗。\n"
"重要提示: 本分析不能替代执业医师的面诊与完整病历评估。\n"
)
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 16:32")
assert bubble.finalize_clinical_analysis() is True
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
assert panel is not None
# 报告收窄到易读行宽,而不是继续用多栏面板的 1080。
assert panel.PREFERRED_MAX_WIDTH == 880
assert bubble._bubble_frame.maximumWidth() == 880
sections = panel.findChildren(ai_consult_module.QFrame, "AiConsultReportSection")
assert len(sections) == 2
assert panel.findChildren(ai_consult_module.QFrame, "AiConsultGapRow") == []
bubble.deleteLater()
def _fitted_bubble(reply: str, width: int = 900) -> Any:
host = QDialog()
host.setObjectName("AiConsultDialog")
host.setStyleSheet(ai_consult_module.AI_CONSULT_QSS)
layout = QVBoxLayout(host)
layout.setContentsMargins(12, 12, 12, 12)
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 16:32")
assert bubble.finalize_clinical_analysis() is True
layout.addWidget(bubble)
layout.addStretch(1)
host.setFixedWidth(width)
host.show()
for _ in range(12):
QApplication.processEvents()
host.adjustSize()
for _ in range(6):
QApplication.processEvents()
return host, bubble
@pytest.mark.parametrize(
"reply",
[
LONG_CLINICAL_REPLY,
(
"针对该患者的用药复核评估如下:\n\n"
"### 1. 当前用药状态评估\n"
"- 无当前处方药物: 病例数据中明确记录患者目前没有服药,系统内也没有有效处方记录,"
"因此不存在药物相互作用或配伍禁忌风险,无需再做安全性复核。\n"
"### 2. 临床风险与干预提示\n"
"- 糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖高于一般控制目标且未接受任何药物治疗,"
"存在长期高血糖导致微血管及大血管并发症的风险,需要尽快评估。\n"
),
],
ids=["clinical", "structured"],
)
def test_report_bubbles_report_the_height_they_actually_paint(
reply: str,
application: QApplication,
) -> None:
"""否则聊天区会按高估的高度撑出滚动空白,打开就是一片空白要往上滑。"""
host, bubble = _fitted_bubble(reply)
assert bubble.height() > 0
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2
host.close()
host.deleteLater()
def test_risk_block_uses_the_red_alert_palette() -> None:
qss = ai_consult_module.AI_CONSULT_QSS
risk_card = qss.split("QFrame#AiConsultRiskCard {", 1)[1].split("}", 1)[0]
assert "#FEF3F2" in risk_card
assert "#F1B35C" not in risk_card # 旧的橙色描边
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
assert "#C0392B" in marker
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
qss = ai_consult_module.AI_CONSULT_QSS
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1)
assert len(body) == 2 or "font-size: 13px" in qss
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
assert "font-size: 13px" in block
assert "font-size: 11px" not in block
+217 -22
View File
@@ -29,6 +29,19 @@ def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture(autouse=True)
def offline_thumbnails(monkeypatch: pytest.MonkeyPatch) -> None:
"""离线传输:缩略图停在 loading 状态,工作站用例永不真正联网。"""
def hold(self: Any, source: str) -> None:
self._source = str(source).strip()
self._invalidate_request()
self.setToolTip(self._source)
self._show_loading()
monkeypatch.setattr(ai_consult_module._RemoteImageButton, "load_url", hold)
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
@@ -468,17 +481,33 @@ def test_patient_report_response_owner_must_match_exactly(
dialog.close()
def test_exam_tab_filters_foreign_attachments_and_blocks_file_urls(
def test_exam_tab_previews_images_in_app_and_blocks_file_urls(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[str] = []
previews: list[tuple[tuple[str, ...], int]] = []
monkeypatch.setattr(
ai_consult_module,
"open_safe_http_url",
lambda target: opened.append(target) or True,
)
class RecordingPreviewDialog(QWidget):
def __init__(
self,
sources: Any,
*,
index: int = 0,
names: Any = None,
title: str = "",
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
previews.append((tuple(sources), int(index)))
monkeypatch.setattr(ai_consult_module, "ImagePreviewDialog", RecordingPreviewDialog)
dialog = _open_dialog(application, WorkspaceRepository())
pane = dialog.records["检查检验"]
dialog.tabs.setCurrentIndex(2)
@@ -498,20 +527,187 @@ def test_exam_tab_filters_foreign_attachments_and_blocks_file_urls(
assert len(thumbnails) == 1
assert thumbnails[0].isEnabled()
assert thumbnails[0].accessibleName() == "舌苔图片点击查看"
assert thumbnails[0].property("loadState") == "blocked"
assert thumbnails[0].property("loadState") == "loading"
assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined]
assert pane.retry_button.isHidden() # type: ignore[attr-defined]
image_button = next(button for button in buttons if "甲舌苔照片.jpg" in button.text())
assert image_button.text().endswith("· 预览")
report_button = next(button for button in buttons if "甲血糖报告.pdf" in button.text())
assert report_button.text().endswith("· 打开")
unsafe = next(button for button in buttons if "本地危险附件" in button.text())
assert not unsafe.isEnabled()
for button in buttons:
button.click()
assert len(opened) == 2
assert all(target.startswith(("http://", "https://")) for target in opened)
assert all(not target.startswith("file:") for target in opened)
thumbnails[0].click()
# 图片留在工作站内预览,只有非图片附件才交给系统打开。
assert opened == ["https://media.example.invalid/甲/report.pdf"]
assert previews == [(("https://media.example.invalid/甲/tongue.jpg",), 0)] * 2
dialog.close()
def test_tongue_thumbnail_auto_get_requires_configured_https_origin(
class ProductionShapeRepository(WorkspaceRepository):
"""按线上 readonlyDetail 的真实返回构造:既有 code,也有后端补的 *_text。"""
def get_diagnosis_detail(
self,
diagnosis_id: int,
*,
readonly: bool = False,
) -> dict[str, Any]:
detail = dict(super().get_diagnosis_detail(diagnosis_id, readonly=readonly))
diagnosis = dict(detail["diagnosis"])
diagnosis.update(
{
"gender": 1,
"gender_text": "",
"diagnosis_type": "follow_up",
"diagnosis_type_text": "复诊",
"eye_condition": "blurred,dry",
"eye_condition_text": "模糊、干涩",
"skin_condition": "dry,itching",
"skin_condition_text": "干燥、瘙痒",
"urine_condition": "yellow_urine",
"urine_condition_text": "尿黄",
"fatty_liver_degree": "mild",
"fatty_liver_degree_text": "轻度",
"past_history": "hypertension",
"past_history_text": "高血压",
"trauma_history": 0,
"trauma_history_text": "",
# 诊单表里的技术列:医生页面不应出现这些英文列名。
"status": 1,
"show_card": 1,
"revisit_slot_start_offset": 0,
"delete_time": None,
"assistant_id": 131,
"assign_read_at": 1787882294,
"shipped_non_er_assistant_cleared_at": 0,
"external_userid": "",
"is_view": 0,
"create_time": 1787882294,
"update_time": 1787882303,
"tongue_images": [
"https://media.example.invalid/11702/a.jpg",
"https://media.example.invalid/11702/b.jpg",
],
}
)
detail["diagnosis"] = diagnosis
return detail
def test_case_tab_hides_raw_columns_and_never_shows_text_mirror_fields(
application: QApplication,
immediate_async: None,
) -> None:
dialog = _open_dialog(application, ProductionShapeRepository())
dialog.tabs.setCurrentIndex(1)
application.processEvents()
text = _pane_text(dialog.records["病历资料"])
# 后端补的 *_text 只用来取值,不再作为独立英文字段列出来。
for mirror in (
"diagnosis type text",
"eye condition text",
"gender text",
"past history text",
):
assert mirror not in text
# 技术列不再泄漏英文列名。
for internal in (
"assign read at",
"shipped non er assistant cleared at",
"external userid",
"is view",
"assistant id",
"show card",
"revisit slot start offset",
"delete time",
):
assert internal not in text
# 有中文名的字段照常显示,取的是后端翻译过的值。
assert "复诊" in text
assert "模糊、干涩" in text
assert "1787882294" not in text
assert "https://media.example.invalid/11702/a.jpg" not in text
assert len(dialog.records["病历资料"].findChildren(QPushButton, "AiConsultTongueThumb")) == 2
dialog.close()
class RawCodeRepository(WorkspaceRepository):
"""只读接口偶尔缺少 `*_text`(历史数据 / 快照),此时必须自己翻译字典 code。"""
def get_diagnosis_detail(
self,
diagnosis_id: int,
*,
readonly: bool = False,
) -> dict[str, Any]:
detail = dict(super().get_diagnosis_detail(diagnosis_id, readonly=readonly))
diagnosis = dict(detail["diagnosis"])
diagnosis.update(
{
"gender": 1,
"diagnosis_type": "follow_up",
"appetite": "dry,bitter",
"weight_change": "lose_10_jin",
"fatty_liver_degree": "mild",
"allergy_history": 0,
"status": 1,
"show_card": 1,
"revisit_slot_start_offset": 0,
"delete_time": None,
"create_source": "admin",
"create_time": 1783838927,
"tongue_images": ["https://media.example.invalid/501/tongue-raw.jpg"],
}
)
detail["diagnosis"] = diagnosis
patient = dict(detail.get("patient") or {})
patient.update({"gender": 1, "marital_status": 1})
detail["patient"] = patient
return detail
def test_case_and_health_tabs_translate_codes_and_hide_internal_columns(
application: QApplication,
immediate_async: None,
) -> None:
dialog = _open_dialog(application, RawCodeRepository())
dialog.tabs.setCurrentIndex(1)
application.processEvents()
case_text = _pane_text(dialog.records["病历资料"])
# 字典 code、性别与是否类枚举、时间戳都按后台只读页的口径显示。
assert "干、苦" in case_text
assert "瘦10斤" in case_text
assert "轻度" in case_text
assert "复诊" in case_text
assert "后台创建" in case_text
assert "dry,bitter" not in case_text
assert "lose_10_jin" not in case_text
assert "follow_up" not in case_text
assert "1783838927" not in case_text
# 纯内部列不再泄漏给医生。
for internal in ("show card", "revisit slot start offset", "delete time"):
assert internal not in case_text
# 舌象附件渲染成缩略图,不再是一长串 URL 文本。
assert "https://media.example.invalid/501/tongue-raw.jpg" not in case_text
thumbnails = dialog.records["病历资料"].findChildren(QPushButton, "AiConsultTongueThumb")
assert len(thumbnails) == 1
dialog.tabs.setCurrentIndex(4)
application.processEvents()
health_text = _pane_text(dialog.records["健康档案"])
assert "性别\n" in health_text
assert "过敏史\n" in health_text
assert "162 cm" in health_text
assert dialog.records["健康档案"].findChildren(QPushButton, "AiConsultTongueThumb")
dialog.close()
def test_tongue_thumbnails_load_safe_http_sources_and_skip_unsafe_schemes(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
@@ -533,23 +729,22 @@ def test_tongue_thumbnail_auto_get_requires_configured_https_origin(
RecordingRemoteImageButton,
)
untrusted = _open_dialog(application, WorkspaceRepository())
assert requested == []
untrusted.close()
trusted_repository = WorkspaceRepository()
trusted_repository.trusted_media_domains = ["media.example.invalid"]
assert not ai_consult_module._trusted_thumbnail_url(
trusted_repository,
"http://media.example.invalid/甲/tongue.jpg",
)
assert not ai_consult_module._trusted_thumbnail_url(
trusted_repository,
"https://sub.media.example.invalid/甲/tongue.jpg",
)
trusted = _open_dialog(application, trusted_repository)
dialog = _open_dialog(application, WorkspaceRepository())
# 舌象照片按字段直接渲染,PDF 报告与 file:// 附件不会发起任何图片请求。
assert requested == ["https://media.example.invalid/甲/tongue.jpg"]
trusted.close()
assert not ai_consult_module._previewable_attachment(
"tongue_images",
"file:///C:/private/tongue.jpg",
)
assert not ai_consult_module._previewable_attachment(
"report_files",
"https://media.example.invalid/甲/report.pdf",
)
assert ai_consult_module._previewable_attachment(
"report_files",
"https://media.example.invalid/甲/report.PNG",
)
dialog.close()
def test_three_prescription_cards_open_exact_details_and_reject_wrong_or_late_ids(
+536 -357
View File
@@ -1,94 +1,124 @@
"""Desktop auto-update check, download and payload discovery."""
from __future__ import annotations
import hashlib
import zipfile
from pathlib import Path
import httpx
import pytest
from doctor_workstation.services import app_update
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_ARCHIVE,
PACKAGE_TYPE_INNO_SETUP,
AppUpdateError,
UpdatePackage,
apply_extracted_update,
apply_inno_setup_update,
compare_version,
discover_payload,
download_package,
fetch_update_offer,
normalize_version,
package_filename,
parse_update_offer,
safe_extract_zip,
validate_installer_download_policy,
validate_windows_installer,
)
def test_normalize_and_compare_versions() -> None:
assert normalize_version("0.2") == "0.2.0"
assert normalize_version("1.2.3.4") == "1.2.3"
assert normalize_version("nope") == ""
assert compare_version("0.1.0", "0.2.0") < 0
assert compare_version("0.2.0", "0.2.0") == 0
assert compare_version("1.0.0", "0.9.9") > 0
def test_parse_offer_requires_hash_before_install() -> None:
offer = parse_update_offer(
{
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"package": {
"url": "https://cdn.example.com/app.zip",
"sha256": "",
"size": 12,
"filename": "app.zip",
},
"can_install": True,
},
current_version="0.1.0",
)
assert offer.has_update is True
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
"""Desktop auto-update check, download and payload discovery."""
from __future__ import annotations
import hashlib
import zipfile
from pathlib import Path
import httpx
import pytest
from doctor_workstation.services import app_update
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_ARCHIVE,
PACKAGE_TYPE_INNO_SETUP,
AppUpdateError,
UpdatePackage,
apply_extracted_update,
apply_inno_setup_update,
compare_version,
discover_payload,
download_package,
fetch_update_offer,
normalize_version,
package_filename,
parse_update_offer,
safe_extract_zip,
validate_installer_download_policy,
validate_windows_installer,
)
def test_normalize_and_compare_versions() -> None:
assert normalize_version("0.2") == "0.2.0"
assert normalize_version("1.2.3.4") == "1.2.3"
assert normalize_version("nope") == ""
assert compare_version("0.1.0", "0.2.0") < 0
assert compare_version("0.2.0", "0.2.0") == 0
assert compare_version("1.0.0", "0.9.9") > 0
def test_parse_offer_requires_hash_before_install() -> None:
offer = parse_update_offer(
{
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"package": {
"url": "https://cdn.example.com/app.zip",
"sha256": "",
"size": 12,
"filename": "app.zip",
},
"can_install": True,
},
current_version="0.1.0",
)
assert offer.has_update is True
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_accepts_explicit_inno_setup_type() -> None:
offer = parse_update_offer(
{
"has_update": True,
"enabled": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
"sha256": "a" * 64,
"size": 123,
"filename": "DoctorWorkstation-Setup.exe",
"type": "inno_setup",
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is True
assert offer.package is not None
offer = parse_update_offer(
{
"has_update": True,
"enabled": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
"sha256": "a" * 64,
"size": 123,
"filename": "DoctorWorkstation-Setup.exe",
"type": "inno_setup",
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is True
assert offer.package is not None
assert offer.package.type == PACKAGE_TYPE_INNO_SETUP
def test_parse_offer_rejects_package_version_mismatch() -> None:
offer = parse_update_offer(
{
"enabled": True,
"has_update": True,
"force": True,
"can_install": True,
"latest_version": "1.3.0",
"platform": "windows",
"arch": "x64",
"package": {
"type": PACKAGE_TYPE_INNO_SETUP,
"url": "https://cdn.example.com/DoctorWorkstation-Setup-1.1.0.exe",
"filename": "DoctorWorkstation-Setup-1.1.0.exe",
"sha256": "a" * 64,
"size": 1024,
},
},
current_version="1.2.0",
platform_name="windows",
arch="x64",
)
assert offer.has_update is True
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
assert "安装包版本 1.1.0 与发布版本 1.3.0 不一致" in offer.install_unavailable_reason
def test_parse_offer_disables_insecure_inno_setup_transport() -> None:
offer = parse_update_offer(
{
@@ -113,235 +143,236 @@ def test_parse_offer_disables_insecure_inno_setup_transport() -> None:
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
offer = parse_update_offer(
{
"has_update": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/update.bin",
"sha256": "a" * 64,
"type": package_type,
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
base = {
"has_update": True,
"latest_version": "0.1.0",
"platform": "windows",
"arch": "x64",
"can_install": False,
}
stale = parse_update_offer(
base,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
wrong_platform = parse_update_offer(
{**base, "latest_version": "0.2.0", "platform": "macos"},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert stale.has_update is False
assert wrong_platform.has_update is False
def test_fetch_update_offer_uses_check_endpoint() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={
"code": 1,
"data": {
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"title": "医生工作站 0.2.0",
"notes": "修复登录",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation.zip",
"sha256": "a" * 64,
"size": 2048,
"filename": "DoctorWorkstation.zip",
},
"can_install": True,
},
},
)
with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client:
offer = fetch_update_offer(
client,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.has_update is True
assert offer.force is True
assert offer.can_install is True
assert offer.package is not None
assert "setting.desktop_workstation/check" in str(requests[0].url)
assert "current_version=0.1.0" in str(requests[0].url)
assert "platform=windows" in str(requests[0].url)
def test_safe_extract_rejects_zip_slip(tmp_path: Path) -> None:
archive = tmp_path / "evil.zip"
with zipfile.ZipFile(archive, "w") as bundle:
bundle.writestr("../outside.txt", "nope")
with pytest.raises(AppUpdateError, match="非法路径"):
safe_extract_zip(archive, tmp_path / "out")
def test_discover_windows_payload_prefers_internal_onedir(tmp_path: Path) -> None:
wrapped = tmp_path / "DoctorWorkstation"
wrapped.mkdir()
(wrapped / "_internal").mkdir()
(wrapped / "DoctorWorkstation.exe").write_bytes(b"mz")
(tmp_path / "Start_DoctorWorkstation.bat").write_text("start", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="windows") == wrapped
def test_discover_macos_payload_finds_app_bundle(tmp_path: Path) -> None:
app = tmp_path / "DoctorWorkstation.app"
macos = app / "Contents" / "MacOS"
macos.mkdir(parents=True)
(macos / "DoctorWorkstation").write_text("bin", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="macos") == app
def test_download_package_verifies_sha256_and_reports_progress(tmp_path: Path) -> None:
payload = b"doctor-workstation-zip"
digest = hashlib.sha256(payload).hexdigest()
progress: list[tuple[int, int]] = []
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(
200,
content=payload,
headers={"content-length": str(len(payload))},
)
destination = tmp_path / "pkg.zip"
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256=digest,
progress=lambda received, total: progress.append((received, total)),
transport=httpx.MockTransport(handler),
)
assert destination.read_bytes() == payload
assert progress[-1][0] == len(payload)
def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=b"tampered")
destination = tmp_path / "pkg.zip"
with pytest.raises(AppUpdateError, match="校验失败"):
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256="b" * 64,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
payload = b"short"
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=payload)
destination = tmp_path / "pkg.exe"
with pytest.raises(AppUpdateError, match="文件大小"):
download_package(
"https://cdn.example.com/pkg.exe",
destination,
sha256=hashlib.sha256(payload).hexdigest(),
expected_size=len(payload) + 1,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
assert not (tmp_path / "pkg.exe.part").exists()
def test_windows_installer_download_policy_requires_verified_https() -> None:
with pytest.raises(AppUpdateError, match="HTTPS"):
validate_installer_download_policy(
"http://cdn.example.com/setup.exe",
verify_ssl=True,
)
with pytest.raises(AppUpdateError, match="证书校验"):
validate_installer_download_policy(
"https://cdn.example.com/setup.exe",
verify_ssl=False,
)
validate_installer_download_policy(
"http://127.0.0.1/setup.exe",
verify_ssl=True,
)
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
installer = tmp_path / "Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
assert validate_windows_installer(installer) == installer.resolve()
invalid = tmp_path / "invalid.exe"
invalid.write_bytes(b"PK")
with pytest.raises(AppUpdateError, match="PE"):
validate_windows_installer(invalid)
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
installer = tmp_path / "DoctorWorkstation-Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
spawned: dict[str, Path] = {}
monkeypatch.setattr(app_update.sys, "platform", "win32")
def capture_spawn(
script: Path,
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
offer = parse_update_offer(
{
"has_update": True,
"latest_version": "0.2.0",
"platform": "windows",
"arch": "x64",
"package": {
"url": "https://cdn.example.com/update.bin",
"sha256": "a" * 64,
"type": package_type,
},
"can_install": True,
},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.can_install is False
assert offer.force is False
assert offer.package is None
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
base = {
"has_update": True,
"latest_version": "0.1.0",
"platform": "windows",
"arch": "x64",
"can_install": False,
}
stale = parse_update_offer(
base,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
wrong_platform = parse_update_offer(
{**base, "latest_version": "0.2.0", "platform": "macos"},
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert stale.has_update is False
assert wrong_platform.has_update is False
def test_fetch_update_offer_uses_check_endpoint() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={
"code": 1,
"data": {
"has_update": True,
"force": True,
"enabled": True,
"latest_version": "0.2.0",
"title": "医生工作站 0.2.0",
"notes": "修复登录",
"package": {
"url": "https://cdn.example.com/DoctorWorkstation.zip",
"sha256": "a" * 64,
"size": 2048,
"filename": "DoctorWorkstation.zip",
},
"can_install": True,
},
},
)
with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client:
offer = fetch_update_offer(
client,
current_version="0.1.0",
platform_name="windows",
arch="x64",
)
assert offer.has_update is True
assert offer.force is True
assert offer.can_install is True
assert offer.package is not None
assert "setting.desktop_workstation/check" in str(requests[0].url)
assert "current_version=0.1.0" in str(requests[0].url)
assert "platform=windows" in str(requests[0].url)
def test_safe_extract_rejects_zip_slip(tmp_path: Path) -> None:
archive = tmp_path / "evil.zip"
with zipfile.ZipFile(archive, "w") as bundle:
bundle.writestr("../outside.txt", "nope")
with pytest.raises(AppUpdateError, match="非法路径"):
safe_extract_zip(archive, tmp_path / "out")
def test_discover_windows_payload_prefers_internal_onedir(tmp_path: Path) -> None:
wrapped = tmp_path / "DoctorWorkstation"
wrapped.mkdir()
(wrapped / "_internal").mkdir()
(wrapped / "DoctorWorkstation.exe").write_bytes(b"mz")
(tmp_path / "Start_DoctorWorkstation.bat").write_text("start", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="windows") == wrapped
def test_discover_macos_payload_finds_app_bundle(tmp_path: Path) -> None:
app = tmp_path / "DoctorWorkstation.app"
macos = app / "Contents" / "MacOS"
macos.mkdir(parents=True)
(macos / "DoctorWorkstation").write_text("bin", encoding="utf-8")
assert discover_payload(tmp_path, platform_name="macos") == app
def test_download_package_verifies_sha256_and_reports_progress(tmp_path: Path) -> None:
payload = b"doctor-workstation-zip"
digest = hashlib.sha256(payload).hexdigest()
progress: list[tuple[int, int]] = []
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(
200,
content=payload,
headers={"content-length": str(len(payload))},
)
destination = tmp_path / "pkg.zip"
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256=digest,
progress=lambda received, total: progress.append((received, total)),
transport=httpx.MockTransport(handler),
)
assert destination.read_bytes() == payload
assert progress[-1][0] == len(payload)
def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=b"tampered")
destination = tmp_path / "pkg.zip"
with pytest.raises(AppUpdateError, match="校验失败"):
download_package(
"https://cdn.example.com/pkg.zip",
destination,
sha256="b" * 64,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
payload = b"short"
def handler(request: httpx.Request) -> httpx.Response:
del request
return httpx.Response(200, content=payload)
destination = tmp_path / "pkg.exe"
with pytest.raises(AppUpdateError, match="文件大小"):
download_package(
"https://cdn.example.com/pkg.exe",
destination,
sha256=hashlib.sha256(payload).hexdigest(),
expected_size=len(payload) + 1,
transport=httpx.MockTransport(handler),
)
assert not destination.exists()
assert not (tmp_path / "pkg.exe.part").exists()
def test_windows_installer_download_policy_requires_verified_https() -> None:
with pytest.raises(AppUpdateError, match="HTTPS"):
validate_installer_download_policy(
"http://cdn.example.com/setup.exe",
verify_ssl=True,
)
with pytest.raises(AppUpdateError, match="证书校验"):
validate_installer_download_policy(
"https://cdn.example.com/setup.exe",
verify_ssl=False,
)
validate_installer_download_policy(
"http://127.0.0.1/setup.exe",
verify_ssl=True,
)
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
installer = tmp_path / "Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
assert validate_windows_installer(installer) == installer.resolve()
invalid = tmp_path / "invalid.exe"
invalid.write_bytes(b"PK")
with pytest.raises(AppUpdateError, match="PE"):
validate_windows_installer(invalid)
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
installer = tmp_path / "DoctorWorkstation-Setup.exe"
installer.write_bytes(b"MZ" + b"\0" * 32)
spawned: dict[str, Path] = {}
monkeypatch.setattr(app_update.sys, "platform", "win32")
def capture_spawn(
script: Path,
*,
installer: Path,
restart_exe: Path,
helper_log_file: Path,
installer_log_file: Path,
ready_file: Path,
) -> None:
spawned.update(
script=script,
@@ -349,44 +380,192 @@ def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
restart_exe=restart_exe,
helper_log_file=helper_log_file,
installer_log_file=installer_log_file,
ready_file=ready_file,
)
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
apply_inno_setup_update(installer, install_root=install_root)
script_text = spawned["script"].read_text(encoding="utf-8-sig")
assert spawned["installer"] == installer.resolve()
assert spawned["restart_exe"] == installed_exe
assert "/VERYSILENT" in script_text
assert "/RESTARTEXITCODE=3010" in script_text
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
apply_inno_setup_update(installer, install_root=install_root)
script_text = spawned["script"].read_text(encoding="utf-8-sig")
assert spawned["installer"] == installer.resolve()
assert spawned["restart_exe"] == installed_exe
assert "/VERYSILENT" in script_text
assert "/RESTARTEXITCODE=3010" in script_text
assert "/NOFORCECLOSEAPPLICATIONS" in script_text
assert "$HelperLogFile" in script_text
assert "$InstallerLogFile" in script_text
assert "$ReadyFile" in script_text
assert "helper ready" in script_text
assert "Restart-Application" in script_text
def test_inno_helper_uses_runnable_flags_and_waits_for_ready(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
script = tmp_path / "install_update.ps1"
script.write_text("", encoding="utf-8")
installer = tmp_path / "Setup.exe"
restart_exe = tmp_path / "DoctorWorkstation.exe"
helper_log = tmp_path / "helper.log"
installer_log = tmp_path / "inno.log"
ready_file = tmp_path / "helper.ready"
captured: dict[str, object] = {}
class FakeProcess:
def poll(self) -> None:
return None
def fake_popen(args: list[str], **kwargs: object) -> FakeProcess:
captured["args"] = args
captured.update(kwargs)
ready_file.write_text("ready", encoding="utf-8")
return FakeProcess()
monkeypatch.setattr(app_update.subprocess, "DETACHED_PROCESS", 8, raising=False)
monkeypatch.setattr(app_update.subprocess, "CREATE_NEW_PROCESS_GROUP", 512, raising=False)
monkeypatch.setattr(app_update.subprocess, "CREATE_NO_WINDOW", 134217728, raising=False)
monkeypatch.setattr(app_update.subprocess, "Popen", fake_popen)
app_update._spawn_inno_setup_applier(
script,
installer=installer,
restart_exe=restart_exe,
helper_log_file=helper_log,
installer_log_file=installer_log,
ready_file=ready_file,
)
flags = int(captured["creationflags"])
detached = int(getattr(app_update.subprocess, "DETACHED_PROCESS", 0))
assert not detached or flags & detached == 0
assert flags & int(getattr(app_update.subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
assert flags & int(getattr(app_update.subprocess, "CREATE_NO_WINDOW", 0))
assert "-ReadyFile" in captured["args"]
def test_inno_helper_reports_exit_before_ready(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
script = tmp_path / "install_update.ps1"
script.write_text("", encoding="utf-8")
class ExitedProcess:
def poll(self) -> int:
return 23
monkeypatch.setattr(app_update.subprocess, "Popen", lambda *args, **kwargs: ExitedProcess())
with pytest.raises(OSError, match="提前退出(代码 23"):
app_update._spawn_inno_setup_applier(
script,
installer=tmp_path / "Setup.exe",
restart_exe=tmp_path / "DoctorWorkstation.exe",
helper_log_file=tmp_path / "helper.log",
installer_log_file=tmp_path / "inno.log",
ready_file=tmp_path / "helper.ready",
)
@pytest.mark.skipif(app_update.sys.platform != "win32", reason="Windows helper contract")
def test_inno_helper_executes_bootstrap_with_production_flags(tmp_path: Path) -> None:
script = tmp_path / "helper probe.ps1"
script.write_text(
"\n".join(
[
"param(",
"[int]$TargetPid, [string]$Installer, [string]$RestartExe,",
"[string]$HelperLogFile, [string]$InstallerLogFile, [string]$ReadyFile",
")",
'Set-Content -LiteralPath $ReadyFile -Value "ready" -Encoding UTF8',
]
),
encoding="utf-8-sig",
)
ready_file = tmp_path / "helper.ready"
app_update._spawn_inno_setup_applier(
script,
installer=tmp_path / "Setup.exe",
restart_exe=tmp_path / "DoctorWorkstation.exe",
helper_log_file=tmp_path / "helper.log",
installer_log_file=tmp_path / "inno.log",
ready_file=ready_file,
)
assert ready_file.read_text(encoding="utf-8-sig").strip() == "ready"
@pytest.mark.skipif(app_update.sys.platform != "win32", reason="Windows helper contract")
def test_inno_helper_survives_launcher_process_exit(tmp_path: Path) -> None:
script = tmp_path / "helper parent-exit probe.ps1"
script.write_text(
"\n".join(
[
"param(",
"[int]$TargetPid, [string]$Installer, [string]$RestartExe,",
"[string]$HelperLogFile, [string]$InstallerLogFile, [string]$ReadyFile",
")",
'Set-Content -LiteralPath $ReadyFile -Value "ready" -Encoding UTF8',
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) {",
" Start-Sleep -Milliseconds 50",
"}",
'Set-Content -LiteralPath $HelperLogFile -Value "parent-exited" -Encoding UTF8',
]
),
encoding="utf-8-sig",
)
helper_log = tmp_path / "helper.log"
ready_file = tmp_path / "helper.ready"
launcher = (
"from pathlib import Path; import sys; "
"from doctor_workstation.services.app_update import _spawn_inno_setup_applier; "
"root=Path(sys.argv[1]); "
"_spawn_inno_setup_applier(root/'helper parent-exit probe.ps1', "
"installer=root/'Setup.exe', restart_exe=root/'DoctorWorkstation.exe', "
"helper_log_file=root/'helper.log', installer_log_file=root/'inno.log', "
"ready_file=root/'helper.ready')"
)
launched = app_update.subprocess.run(
[app_update.sys.executable, "-c", launcher, str(tmp_path)],
cwd=str(Path.cwd()),
capture_output=True,
text=True,
timeout=10,
)
assert launched.returncode == 0, launched.stderr
deadline = app_update.time.monotonic() + 5.0
while not helper_log.is_file() and app_update.time.monotonic() < deadline:
app_update.time.sleep(0.05)
assert ready_file.is_file()
assert helper_log.read_text(encoding="utf-8-sig").strip() == "parent-exited"
def test_archive_applier_restarts_from_install_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = tmp_path / "payload"
payload.mkdir()
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
captured: dict[str, Path] = {}
script = tmp_path / "apply.ps1"
script.write_text("", encoding="utf-8")
def capture_script(**kwargs: Path) -> Path:
captured.update(kwargs)
return script
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
apply_extracted_update(payload, install_root=install_root)
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = tmp_path / "payload"
payload.mkdir()
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
install_root = tmp_path / "installed"
install_root.mkdir()
installed_exe = install_root / "DoctorWorkstation.exe"
installed_exe.write_bytes(b"MZ")
captured: dict[str, Path] = {}
script = tmp_path / "apply.ps1"
script.write_text("", encoding="utf-8")
def capture_script(**kwargs: Path) -> Path:
captured.update(kwargs)
return script
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
apply_extracted_update(payload, install_root=install_root)
assert captured["restart_exe"] == installed_exe
@@ -409,17 +588,17 @@ def test_inno_setup_applier_reports_helper_start_failure(
with pytest.raises(AppUpdateError, match="无法启动 Windows 更新助手"):
apply_inno_setup_update(installer, install_root=install_root)
def test_package_filename_defaults_match_package_type() -> None:
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
installer = UpdatePackage(
"https://cdn.example.com/",
"a" * 64,
0,
"",
type=PACKAGE_TYPE_INNO_SETUP,
)
assert package_filename(archive, "0.2.0").endswith(".zip")
assert package_filename(installer, "0.2.0").endswith(".exe")
assert archive.type == PACKAGE_TYPE_ARCHIVE
def test_package_filename_defaults_match_package_type() -> None:
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
installer = UpdatePackage(
"https://cdn.example.com/",
"a" * 64,
0,
"",
type=PACKAGE_TYPE_INNO_SETUP,
)
assert package_filename(archive, "0.2.0").endswith(".zip")
assert package_filename(installer, "0.2.0").endswith(".exe")
assert archive.type == PACKAGE_TYPE_ARCHIVE
+250 -70
View File
@@ -1,70 +1,250 @@
"""Update dialog contract for optional and forced desktop upgrades."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.app_update import UpdateOffer, UpdatePackage
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog
from doctor_workstation.ui.theme import apply_theme
def _offer(*, force: bool, can_install: bool = True) -> UpdateOffer:
package = (
UpdatePackage(
url="https://cdn.example.com/DoctorWorkstation.zip",
sha256="a" * 64,
size=1024,
filename="DoctorWorkstation.zip",
)
if can_install
else None
)
return UpdateOffer(
has_update=True,
force=force,
enabled=True,
current_version="0.1.0",
latest_version="0.2.0",
min_version="",
title="医生工作站 0.2.0",
notes="修复若干问题",
platform="windows",
arch="x64",
package=package,
can_install=can_install,
)
def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=False))
dialog.show()
app.processEvents()
assert dialog.later_button.isVisible()
assert dialog.update_button.text() == "立即更新"
assert dialog.notes.toPlainText() == "修复若干问题"
dialog.close()
def test_forced_update_dialog_hides_defer_and_blocks_escape(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=True))
dialog.show()
app.processEvents()
assert not dialog.later_button.isVisible()
assert "必须更新" in dialog.badge.text()
dialog.close()
app.processEvents()
assert dialog.isVisible()
dialog.offer = _offer(force=False)
dialog._busy = False
dialog.close()
"""Update dialog contract for optional and forced desktop upgrades."""
from __future__ import annotations
import os
from dataclasses import replace
from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QObject, Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_INNO_SETUP,
UpdateOffer,
UpdatePackage,
)
from doctor_workstation.ui.dialogs.app_update import AppUpdateDialog, AppUpdateSession
from doctor_workstation.ui.theme import apply_theme
def _offer(
*,
force: bool,
can_install: bool = True,
install_unavailable_reason: str = "",
) -> UpdateOffer:
package = (
UpdatePackage(
url="https://cdn.example.com/DoctorWorkstation.zip",
sha256="a" * 64,
size=1024,
filename="DoctorWorkstation.zip",
)
if can_install
else None
)
return UpdateOffer(
has_update=True,
force=force,
enabled=True,
current_version="0.1.0",
latest_version="0.2.0",
min_version="",
title="医生工作站 0.2.0",
notes="修复若干问题",
platform="windows",
arch="x64",
package=package,
can_install=can_install,
install_unavailable_reason=install_unavailable_reason,
)
def test_optional_update_dialog_allows_later(application: QApplication | None = None) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=False))
dialog.show()
app.processEvents()
deferred: list[bool] = []
dialog.update_deferred.connect(lambda: deferred.append(True))
assert dialog.later_button.isVisible()
assert dialog.later_button.isEnabled()
assert dialog.later_button.text() == "稍后提醒"
assert not dialog.exit_button.isVisible()
assert dialog.update_button.text() == "立即更新"
assert dialog.notes.toPlainText() == "修复若干问题"
dialog.later_button.click()
assert deferred == [True]
assert not dialog.isVisible()
dialog.deleteLater()
app.processEvents()
def test_forced_update_dialog_has_explicit_exit_and_blocks_implicit_close(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
dialog = AppUpdateDialog(_offer(force=True))
dialog.show()
app.processEvents()
assert not dialog.later_button.isVisible()
assert dialog.exit_button.isVisible()
assert dialog.exit_button.isEnabled()
assert dialog.exit_button.text() == "退出软件"
assert "必须更新" in dialog.badge.text()
dialog.close()
app.processEvents()
assert dialog.isVisible()
QTest.keyClick(dialog, Qt.Key.Key_Escape)
app.processEvents()
assert dialog.isVisible()
dialog.set_busy(True)
dialog.show_download_progress(256, 1024)
app.processEvents()
assert dialog.exit_button.isVisible()
assert dialog.exit_button.isEnabled()
assert not dialog.update_button.isEnabled()
assert not dialog.cancel_button.isVisible()
dialog.close()
app.processEvents()
assert dialog.isVisible()
QTest.keyClick(dialog, Qt.Key.Key_Escape)
app.processEvents()
assert dialog.isVisible()
dialog.allow_application_exit()
dialog.close()
app.processEvents()
assert not dialog.isVisible()
dialog.deleteLater()
app.processEvents()
def test_forced_update_exit_button_emits_dedicated_request(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
dialog = AppUpdateDialog(_offer(force=True))
requests: list[bool] = []
dialog.exit_requested.connect(lambda: requests.append(True))
dialog.show()
dialog.set_busy(True)
app.processEvents()
dialog.exit_button.click()
assert requests == [True]
assert dialog.isVisible()
dialog.hide()
dialog.deleteLater()
app.processEvents()
def test_session_quits_immediately_when_update_has_not_started(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
host = QObject()
quit_requests: list[bool] = []
host.request_quit = lambda: quit_requests.append(True) # type: ignore[attr-defined]
session = AppUpdateSession(host)
dialog = AppUpdateDialog(_offer(force=True))
session.dialog = dialog
dialog.show()
app.processEvents()
session._request_exit(dialog)
assert session._cancel_event.is_set()
assert quit_requests == [True]
assert not dialog.exit_button.isEnabled()
assert not dialog.isVisible()
dialog.hide()
dialog.deleteLater()
app.processEvents()
def test_session_waits_for_update_worker_before_quitting(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
host = QObject()
quit_requests: list[bool] = []
host.request_quit = lambda: quit_requests.append(True) # type: ignore[attr-defined]
session = AppUpdateSession(host)
dialog = AppUpdateDialog(_offer(force=True))
session.dialog = dialog
active_signals = QObject()
session._signals = active_signals # type: ignore[assignment]
session._active_install_signals = active_signals # type: ignore[assignment]
dialog.show()
app.processEvents()
session._request_exit(dialog)
assert session._cancel_event.is_set()
assert quit_requests == []
assert not dialog.exit_button.isEnabled()
assert "退出软件" in dialog.status_label.text()
assert dialog.isVisible()
session._finish_install(active_signals, dialog, object()) # type: ignore[arg-type]
assert quit_requests == []
session._on_install_finished(active_signals) # type: ignore[arg-type]
assert quit_requests == [True]
assert session._active_install_signals is None
assert not dialog.isVisible()
dialog.hide()
dialog.deleteLater()
app.processEvents()
def test_unavailable_update_dialog_shows_policy_reason(
application: QApplication | None = None,
) -> None:
app = application or QApplication.instance() or QApplication([])
apply_theme(app)
reason = "无法自动安装:自动安装 Windows 更新必须开启 HTTPS 证书校验。"
dialog = AppUpdateDialog(
_offer(
force=False,
can_install=False,
install_unavailable_reason=reason,
)
)
dialog.show()
app.processEvents()
assert dialog.status_label.text() == reason
assert not dialog.update_button.isEnabled()
dialog.close()
def test_session_explains_disabled_certificate_verification() -> None:
host = QObject()
host.config = SimpleNamespace(verify_ssl=False) # type: ignore[attr-defined]
session = AppUpdateSession(host)
session._generation = 1
presented: list[UpdateOffer] = []
session._present = presented.append # type: ignore[method-assign]
offer = replace(
_offer(force=True),
package=UpdatePackage(
url="https://cdn.example.com/DoctorWorkstation-Setup.exe",
sha256="a" * 64,
size=1024,
filename="DoctorWorkstation-Setup.exe",
type=PACKAGE_TYPE_INNO_SETUP,
),
)
session._on_offer(offer, interactive=True, generation=1)
assert len(presented) == 1
assert presented[0].can_install is False
assert presented[0].force is False
assert presented[0].package is None
assert "开启 HTTPS 证书校验" in presented[0].install_unavailable_reason
assert "取消勾选“信任自签名证书" in presented[0].install_unavailable_reason
+207
View File
@@ -0,0 +1,207 @@
"""登录后聊天通知的契约:轮询、卡片、点击去向。"""
from __future__ import annotations
import os
from collections.abc import Callable
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui import chat_notifications as chat_module
from doctor_workstation.ui.chat_notifications import (
CONSULTATION_COMPLETE,
PATIENT_LEFT_CHAT,
PATIENT_OPENED_CHAT,
ChatNotificationCenter,
parse_notification,
relative_time,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Callable[..., Any],
*args: Any,
on_success: Callable[[Any], Any] | None = None,
on_error: Callable[[Exception], Any] | None = None,
on_finished: Callable[[], Any] | None = None,
**_kwargs: Any,
) -> object:
try:
result = function(*args)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(chat_module, "run_async", run_immediately)
class _NotifyRepository:
def __init__(self, *batches: list[dict[str, Any]]) -> None:
self.batches = list(batches)
self.calls = 0
def list_chat_notifications(self) -> list[dict[str, Any]]:
self.calls += 1
# 服务端取一次即消费,这里同样只发一次。
return self.batches.pop(0) if self.batches else []
def _row(identifier: str, kind: str = PATIENT_OPENED_CHAT, **extra: Any) -> dict[str, Any]:
row = {
"id": identifier,
"type": kind,
"doctor_id": 7,
"patient_id": "11676",
"patient_name": "甘先生",
"created_at": 1787882294,
}
row.update(extra)
return row
def test_rows_normalize_into_admin_equivalent_cards() -> None:
opened = parse_notification(_row("a1"))
assert opened is not None
assert opened.title == "患者打开会话"
assert opened.description == "甘先生 已打开与您的会话,请及时查看"
assert opened.action_text == "去接诊台"
left = parse_notification(_row("a2", PATIENT_LEFT_CHAT))
assert left is not None
assert left.description == "甘先生 已离开问诊会话页面"
complete = parse_notification(
_row("a3", CONSULTATION_COMPLETE, doctor_name="陈医生", diagnosis_id="8169")
)
assert complete is not None
assert complete.diagnosis_id == 8169
assert complete.description == "甘先生 的面诊已由 陈医生 完成,请及时跟进"
# 缺 id、未知 type、非映射行都不该变成卡片。
assert parse_notification(_row("", PATIENT_OPENED_CHAT)) is None
assert parse_notification(_row("a4", "unknown_business")) is None
assert parse_notification("not-a-row") is None
def test_relative_time_matches_the_admin_wording() -> None:
now = 1787882294 + 0.0
assert relative_time(1787882294, now=now) == "刚刚"
assert relative_time(1787882294 - 120, now=now) == "2 分钟前"
assert relative_time(1787882294 - 7200, now=now) == "2 小时前"
assert relative_time(0, now=now) == ""
def test_center_polls_once_per_tick_and_never_repeats_a_card(
application: QApplication,
immediate_async: None,
) -> None:
host = QWidget()
host.resize(1280, 800)
repository = _NotifyRepository([_row("a1"), _row("a1")], [_row("a2", PATIENT_LEFT_CHAT)])
center = ChatNotificationCenter(repository, host)
center.poll()
assert [item.id for item in center.pending] == ["a1"]
center.poll()
# 同一条通知重复下发也只留一张卡片,新的排在最前面。
assert [item.id for item in center.pending] == ["a2", "a1"]
assert repository.calls == 2
assert center.isVisible() is False or len(center.pending) == 2
center.dismiss("a1")
assert [item.id for item in center.pending] == ["a2"]
center.clear()
assert center.pending == []
host.deleteLater()
def test_center_keeps_only_the_newest_five_cards(
application: QApplication,
immediate_async: None,
) -> None:
host = QWidget()
repository = _NotifyRepository([_row(f"n{index}") for index in range(8)])
center = ChatNotificationCenter(repository, host)
center.poll()
assert [item.id for item in center.pending] == ["n7", "n6", "n5", "n4", "n3"]
host.deleteLater()
def test_activating_a_card_emits_it_once_and_removes_it(
application: QApplication,
immediate_async: None,
) -> None:
host = QWidget()
repository = _NotifyRepository([_row("a1", CONSULTATION_COMPLETE, diagnosis_id=8169)])
center = ChatNotificationCenter(repository, host)
activated: list[Any] = []
center.notification_activated.connect(activated.append)
center.poll()
card = next(iter(center._cards.values()))
card.open_button.click()
assert [item.diagnosis_id for item in activated] == [8169]
assert center.pending == []
host.deleteLater()
def test_center_stays_silent_when_the_source_cannot_answer(
application: QApplication,
immediate_async: None,
) -> None:
class _Failing:
def list_chat_notifications(self) -> list[dict[str, Any]]:
raise RuntimeError("服务暂时不可用")
host = QWidget()
center = ChatNotificationCenter(_Failing(), host)
center.poll()
assert center.pending == []
# 演示仓储与不支持该接口的数据源都不应该报错。
ChatNotificationCenter(DemoDoctorRepository(), host).poll()
ChatNotificationCenter(object(), host).poll()
host.deleteLater()
class _RecordingClient:
def __init__(self, payload: Any) -> None:
self.payload = payload
self.get_calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.get_calls.append((endpoint, dict(params or {})))
return self.payload
def test_remote_consumes_the_same_admin_endpoint() -> None:
client = _RecordingClient([_row("a1")])
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
rows = repository.list_chat_notifications()
assert client.get_calls == [("chat/notifications", {})]
assert [row["id"] for row in rows] == ["a1"]
+82 -2
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from doctor_workstation import config as config_module
from doctor_workstation.config import AppConfig, normalize_api_base_url
@@ -36,8 +38,86 @@ def test_config_update_validates_video_mode() -> None:
def test_config_update_normalizes_ssl_boolean_strings() -> None:
assert AppConfig().with_updates(verify_ssl="false").verify_ssl is False
assert AppConfig(verify_ssl=False).with_updates(verify_ssl="true").verify_ssl is True
assert AppConfig(debug_mode=True).with_updates(verify_ssl="false").verify_ssl is False
assert (
AppConfig(debug_mode=True, verify_ssl=False)
.with_updates(verify_ssl="true")
.verify_ssl
is True
)
def test_production_config_locks_online_server_and_disables_demo(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "preferences.json").write_text(
json.dumps(
{
"api_base_url": "https://stale.example.test/adminapi",
"demo_mode": True,
"verify_ssl": False,
}
),
encoding="utf-8",
)
monkeypatch.setattr(config_module, "DEBUG_MODE", False)
monkeypatch.setattr(
config_module,
"ONLINE_API_BASE_URL",
"https://prod.example.test",
)
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("DOCTOR_API_BASE_URL", "https://env.example.test")
monkeypatch.setenv("DOCTOR_DEMO_MODE", "true")
monkeypatch.setenv("DOCTOR_VERIFY_SSL", "false")
config = AppConfig.load()
assert config.debug_mode is False
assert config.api_base_url == "https://prod.example.test/adminapi"
assert config.demo_mode is False
assert config.verify_ssl is True
updated = config.with_updates(
api_base_url="https://changed.example.test",
demo_mode=True,
verify_ssl=False,
)
assert updated.api_base_url == config.api_base_url
assert updated.demo_mode is False
assert updated.verify_ssl is True
def test_debug_config_keeps_environment_server_controls(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(config_module, "DEBUG_MODE", True)
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("DOCTOR_API_BASE_URL", "http://127.0.0.1:8080")
monkeypatch.setenv("DOCTOR_DEMO_MODE", "true")
monkeypatch.setenv("DOCTOR_VERIFY_SSL", "false")
config = AppConfig.load()
assert config.debug_mode is True
assert config.api_base_url == "http://127.0.0.1:8080/adminapi"
assert config.demo_mode is True
assert config.verify_ssl is False
def test_production_config_rejects_empty_online_domain(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(config_module, "DEBUG_MODE", False)
monkeypatch.setattr(config_module, "ONLINE_API_BASE_URL", "")
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(tmp_path / "config"))
with pytest.raises(ValueError, match="ONLINE_API_BASE_URL 不能为空"):
AppConfig.load()
def test_runtime_directories_can_be_isolated_without_replacing_user_home(
@@ -16,6 +16,7 @@ from doctor_workstation.ui.diagnosis_drawer import (
NotesTimeline,
_RemoteImageButton,
)
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
@pytest.fixture(scope="module")
@@ -112,7 +113,6 @@ def test_remote_image_request_is_thread_owned_and_rejects_stale_results(
object_name="DiagnosisTongueThumb",
parent=owner,
)
button._manager.deleteLater()
manager = _FakeManager(button)
button._manager = manager
manager.queue(_png_bytes(180, 90, "#DC2626"))
@@ -160,7 +160,6 @@ def test_remote_image_uses_text_only_after_request_or_decode_failure(
object_name="DiagnosisChatImage",
parent=owner,
)
button._manager.deleteLater()
manager = _FakeManager(button)
button._manager = manager
manager.queue(b"not-an-image")
@@ -193,7 +192,6 @@ def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_downloa
object_name="DiagnosisTongueThumb",
parent=owner,
)
button._manager.deleteLater()
manager = _FakeManager(button)
button._manager = manager
manager.queue(b"must-not-be-read")
@@ -218,6 +216,110 @@ def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_downloa
assert application.thread() == button.thread()
def _preview_with_offline_transport(
sources: list[str],
*,
index: int = 0,
names: list[str] | None = None,
) -> tuple[ImagePreviewDialog, _FakeManager]:
"""Build a preview window whose downloads are driven by the test, not the network."""
original_request = ImagePreviewDialog._request
ImagePreviewDialog._request = lambda self, target: None # type: ignore[method-assign]
try:
dialog = ImagePreviewDialog(sources, index=index, names=names)
finally:
ImagePreviewDialog._request = original_request # type: ignore[method-assign]
manager = _FakeManager(dialog)
dialog._manager = manager
return dialog, manager
def test_image_preview_pages_the_group_in_app_and_reuses_decoded_images(
application: QApplication,
) -> None:
dialog, manager = _preview_with_offline_transport(
[
"https://media.example.invalid/tongue-1.jpg",
"file:///C:/private/tongue.jpg",
"https://media.example.invalid/tongue-2.jpg",
],
index=2,
names=["舌象附件 1", "本地危险附件", "舌象附件 2"],
)
# file:// 附件既不进入分组,也不会发起任何请求。
assert dialog.sources == [
"https://media.example.invalid/tongue-1.jpg",
"https://media.example.invalid/tongue-2.jpg",
]
assert dialog.current_source == "https://media.example.invalid/tongue-2.jpg"
assert dialog.counter.text() == "第 2 / 2 张"
assert dialog.name_label.text() == "舌象附件 2"
manager.queue(_png_bytes(320, 200, "#DC2626"))
dialog.reload_current()
request = manager.request_objects[-1]
assert request.attribute(QNetworkRequest.Attribute.RedirectPolicyAttribute) == (
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy
)
manager.replies[-1].finished.emit()
assert dialog.canvas.text() == ""
assert not dialog.canvas.pixmap().isNull()
manager.queue(_png_bytes(120, 90, "#16A34A"))
dialog.step(1)
assert dialog.current_source == "https://media.example.invalid/tongue-1.jpg"
manager.replies[-1].finished.emit()
assert not dialog.canvas.pixmap().isNull()
dialog.step(1)
assert dialog.current_source == "https://media.example.invalid/tongue-2.jpg"
assert manager.requests == [
"https://media.example.invalid/tongue-2.jpg",
"https://media.example.invalid/tongue-1.jpg",
]
assert not dialog.canvas.pixmap().isNull()
dialog.close()
def test_image_preview_aborts_oversize_and_falls_back_on_undecodable_payload(
application: QApplication,
) -> None:
dialog, manager = _preview_with_offline_transport(
["https://media.example.invalid/tongue.jpg"]
)
manager.queue(b"must-not-be-read")
dialog.reload_current()
reply = manager.replies[-1]
reply.downloadProgress.emit(dialog._MAX_IMAGE_BYTES, -1)
assert reply.aborted is False
reply.downloadProgress.emit(dialog._MAX_IMAGE_BYTES + 1, -1)
assert reply.aborted is True
reply.finished.emit()
assert reply.read_all_calls == 0
assert "12 MB" in dialog.canvas.text()
manager.queue(b"not-an-image")
dialog.reload_current()
manager.replies[-1].finished.emit()
assert dialog.canvas.pixmap().isNull()
assert "无法在工作站内预览" in dialog.canvas.text()
dialog.close()
def test_image_preview_refuses_a_group_without_any_safe_http_source(
application: QApplication,
) -> None:
dialog = ImagePreviewDialog(["file:///C:/private/tongue.jpg", ""])
assert dialog.has_images() is False
assert dialog.sources == []
assert dialog.current_source == ""
assert not dialog.external_button.isEnabled()
assert not dialog.next_button.isEnabled()
assert "HTTP(S)" in dialog.canvas.text()
dialog.close()
def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
+144
View File
@@ -0,0 +1,144 @@
"""诊单字典 / 枚举 / 时间戳翻译的契约。"""
from __future__ import annotations
from typing import Any
import pytest
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.diagnosis_terms import (
DICTIONARY_TYPES,
MULTI_VALUE_DICTIONARIES,
SINGLE_VALUE_DICTIONARIES,
TermIndex,
format_timestamp,
unit_suffix,
)
def test_seed_dictionary_translates_codes_admin_shows_in_chinese() -> None:
terms = TermIndex()
assert terms.dictionary_label("appetite", "dry,bitter") == "干、苦"
assert terms.dictionary_label("appetite", ["dry", "greasy"]) == "干、腻"
assert terms.dictionary_label("weight_change", "lose_10_jin") == "瘦10斤"
assert terms.dictionary_label("fatty_liver_degree", "mild") == "轻度"
assert terms.dictionary_label("past_history", "hypertension、diabetes") == "高血压、糖尿病"
# 同一个 code 在不同字典里含义不同,翻译必须按字段所属字典走。
assert terms.dictionary_label("skin_condition", "dry") == "干燥"
assert terms.dictionary_label("eye_condition", "dry") == "干涩"
# 不在字典里的自定义值回显原值,不会被吞掉。
assert terms.dictionary_label("appetite", "自定义症状") == "自定义症状"
assert terms.dictionary_label("remark", "任意文本") is None
def test_backend_text_field_wins_over_dictionary_and_raw_value() -> None:
terms = TermIndex()
assert terms.display({"appetite": "dry", "appetite_text": "口干"}, "appetite") == "口干"
assert terms.display({"appetite": "dry"}, "appetite") == ""
assert terms.display({}, "appetite", default="未记录") == "未记录"
def test_live_dictionary_overrides_the_bundled_seed() -> None:
terms = TermIndex()
terms.merge({"appetite": [{"name": "口干", "value": "dry"}]})
assert terms.dictionary_label("appetite", "dry") == "口干"
# 实时字典没覆盖到的条目继续用种子。
assert terms.dictionary_label("appetite", "bitter") == ""
def test_enum_and_timestamp_fields_render_like_the_admin_readonly_page() -> None:
terms = TermIndex()
assert terms.value_label("gender", 1) == ""
assert terms.value_label("gender", "0") == ""
assert terms.value_label("marital_status", "1") == "已婚"
assert terms.value_label("allergy_history", "0") == ""
assert terms.value_label("family_history", 1) == ""
assert terms.value_label("diagnosis_type", "follow_up") == "复诊"
assert terms.value_label("create_source", "admin") == "后台创建"
assert terms.value_label("source", "1") == "患者自录"
assert terms.value_label("create_time", 1783838927) == format_timestamp(1783838927)
assert format_timestamp(1783838927) is not None
assert format_timestamp("2026-08-18 09:20") is None
assert format_timestamp(0) is None
def test_units_only_decorate_numeric_readonly_values() -> None:
assert unit_suffix("height", "162") == " cm"
assert unit_suffix("fasting_blood_sugar", "8.2") == " mmol/L"
assert unit_suffix("diabetes_discovery_year", "6") == ""
# 自由文本("17多"、"五年")不补单位,避免拼出错误的读数。
assert unit_suffix("fasting_blood_sugar", "17多") == ""
assert unit_suffix("diabetes_discovery_year", "五年") == ""
assert unit_suffix("remark", "123") == ""
def test_dictionary_types_cover_every_field_the_backend_translates() -> None:
# 与 AppointmentLogic::enrichDiagnosisLabels 的字段表保持同步。
assert set(SINGLE_VALUE_DICTIONARIES) == {
"diagnosis_type",
"syndrome_type",
"diabetes_type",
"water_intake",
"weight_change",
"fatty_liver_degree",
}
assert set(MULTI_VALUE_DICTIONARIES) == {
"past_history",
"appetite",
"diet_condition",
"body_feeling",
"sleep_condition",
"eye_condition",
"head_feeling",
"sweat_condition",
"skin_condition",
"urine_condition",
"stool_condition",
"kidney_condition",
}
assert set(DICTIONARY_TYPES) == set(SINGLE_VALUE_DICTIONARIES.values()) | set(
MULTI_VALUE_DICTIONARIES.values()
)
class _RecordingClient:
def __init__(self, payload: Any) -> None:
self.payload = payload
self.get_calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.get_calls.append((endpoint, dict(params or {})))
return self.payload
def test_remote_batches_every_dictionary_into_one_request() -> None:
client = _RecordingClient(
{
"appetite": [{"name": "口干", "value": "dry"}],
"weight_change": [{"name": "瘦10斤", "value": "lose_10_jin"}],
}
)
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
dictionaries = repository.get_dictionaries(["appetite", "weight_change", "appetite", ""])
assert client.get_calls == [("config/dict", {"type": "appetite,weight_change"})]
assert list(dictionaries) == ["appetite", "weight_change"]
terms = TermIndex()
terms.merge(dictionaries)
assert terms.dictionary_label("appetite", "dry") == "口干"
@pytest.mark.parametrize("dictionary_type", DICTIONARY_TYPES)
def test_demo_repository_answers_the_batch_dictionary_contract(dictionary_type: str) -> None:
repository = DemoDoctorRepository()
dictionaries = repository.get_dictionaries(DICTIONARY_TYPES)
assert dictionary_type in dictionaries
+29
View File
@@ -1018,6 +1018,35 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
application.processEvents()
def test_case_record_translates_snapshot_dictionary_codes_to_chinese() -> None:
# 处方快照存的是开方当时的原始 code,没有后端补的 *_text。
prescription = {
"id": 21,
"diagnosis_id": 9,
"case_record": {
"diagnosis": {
"appetite": "dry,bitter",
"water_intake": "one_bottle",
"weight_change": "lose_10_jin",
"fatty_liver_degree": "mild",
"past_history": "hypertension,diabetes",
"sleep_condition": "many_dreams",
}
},
}
case_html = dialog_module.render_case_record_html(prescription)
assert "干、苦" in case_html
assert "1瓶矿泉水" in case_html
assert "瘦10斤" in case_html
assert "轻度" in case_html
assert "高血压、糖尿病" in case_html
assert "多梦" in case_html
for code in ("lose_10_jin", "one_bottle", "many_dreams"):
assert code not in case_html
def test_case_record_tab_exports_case_record_as_a3_pdf(
application: QApplication,
tmp_path: Any,
+52
View File
@@ -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 == []
+73
View File
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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};
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 @@
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 @@
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 @@
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,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};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
File diff suppressed because one or more lines are too long
@@ -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,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};
File diff suppressed because one or more lines are too long
@@ -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 +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 @@
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 _};
File diff suppressed because one or more lines are too long
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 @@
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 @@
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};
File diff suppressed because one or more lines are too long
@@ -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 @@
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 @@
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