Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f7287331 | ||
|
|
9d91ff90ae | ||
|
|
1b8e166b33 | ||
|
|
0cea43b027 | ||
|
|
d0e2422155 | ||
|
|
9206d5ea62 | ||
|
|
bd0a3d3b96 | ||
|
|
a5f4c4472d | ||
|
|
9ef6eb8d67 | ||
|
|
d5164b7369 | ||
|
|
cf3fbdc5ef | ||
|
|
5e22d423d4 | ||
|
|
7f1ed49cc8 |
@@ -32,3 +32,4 @@ app/.test-tmp-stream/
|
||||
/.spool
|
||||
TUICallKit-Vue3/.env
|
||||
/.codegraph
|
||||
app/artifacts/
|
||||
|
||||
Generated
+475
-423
File diff suppressed because it is too large
Load Diff
@@ -300,6 +300,10 @@ export interface WecomPromotionBatchUpdatePoolsParams {
|
||||
fallback_url?: string
|
||||
status?: 0 | 1
|
||||
automation_config?: Record<string, unknown>
|
||||
member_status?: {
|
||||
member_admin_ids: number[]
|
||||
status: 0 | 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +313,8 @@ export interface WecomPromotionBatchUpdatePoolResult {
|
||||
success: boolean
|
||||
sync_error?: string
|
||||
sync_queued?: boolean
|
||||
member_matched?: number
|
||||
member_updated?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -318,6 +324,8 @@ export interface WecomPromotionBatchUpdatePoolsResult {
|
||||
failed: number
|
||||
sync_error_count: number
|
||||
sync_queued_count: number
|
||||
member_matched: number
|
||||
member_updated: number
|
||||
results: WecomPromotionBatchUpdatePoolResult[]
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间新增加粉(按员工+客户去重,包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加)<template v-if="canViewDeletedFans">,(-N)表示加粉总数中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
@@ -193,7 +193,7 @@
|
||||
v-if="hasFans(row.add_fans_count)"
|
||||
type="button"
|
||||
class="fan-count-value fan-detail-trigger fan-detail-trigger--table"
|
||||
title="查看该行加粉客户明细"
|
||||
title="查看该行加粉组合明细"
|
||||
@click.stop="openFansDetail(row)"
|
||||
>
|
||||
{{ formatNumber(row.add_fans_count) }}
|
||||
@@ -349,7 +349,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :image-size="68" description="当前条件下暂无加粉客户明细" />
|
||||
<el-empty :image-size="68" description="当前条件下暂无加粉组合明细" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
@@ -448,7 +448,7 @@ const timeOptions = [
|
||||
{ label: '自定义', value: 'custom' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增加粉(含已删除)' },
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增员工+客户组合(不同员工分别计数,含已删除)' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
@@ -518,7 +518,7 @@ const rankingKind = computed(() => dashboard.meta.ranking_kind || (
|
||||
))
|
||||
const showRankings = computed(() => rankingKind.value !== 'hidden')
|
||||
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
|
||||
const fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉客户明细`)
|
||||
const fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉组合明细`)
|
||||
const detailRangeText = computed(() => {
|
||||
const startDate = dashboard.meta.start_date || query.start_date || ''
|
||||
const endDate = dashboard.meta.end_date || query.end_date || ''
|
||||
@@ -637,7 +637,7 @@ async function loadFansDetail() {
|
||||
if (requestId !== latestFansDetailRequestId) return
|
||||
fansDetailRows.value = []
|
||||
fansDetailPager.total = 0
|
||||
ElMessage.error(error?.message || '加粉客户明细加载失败')
|
||||
ElMessage.error(error?.message || '加粉组合明细加载失败')
|
||||
} finally {
|
||||
if (requestId === latestFansDetailRequestId) fansDetailLoading.value = false
|
||||
}
|
||||
|
||||
@@ -74,6 +74,16 @@
|
||||
|
||||
<div v-if="overview.pools.length" class="pool-layout">
|
||||
<aside class="pool-sidebar">
|
||||
<div class="pool-select-all">
|
||||
<el-checkbox
|
||||
:model-value="allManageablePoolsSelected"
|
||||
:indeterminate="someManageablePoolsSelected"
|
||||
:disabled="!manageablePoolIds.length"
|
||||
aria-label="全选可管理的分流方案"
|
||||
@change="toggleAllPoolSelection"
|
||||
>全选</el-checkbox>
|
||||
<span>可选 {{ manageablePoolIds.length }} 个</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="pool in overview.pools"
|
||||
:key="pool.id"
|
||||
@@ -488,6 +498,62 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="batch-config-section">
|
||||
<h3>员工上下线</h3>
|
||||
<p class="batch-section-tip">所选员工会在其已加入的所选方案中统一上线或下线;不属于某个方案的员工不会被加入该方案。为保证方案级原子提交,员工上下线需单独批量保存。</p>
|
||||
<div class="batch-field batch-member-status-field" :class="{ 'is-disabled': !batchConfigApply.member_status }">
|
||||
<el-checkbox v-model="batchConfigApply.member_status" :disabled="savingBatchConfig">批量修改员工上线状态</el-checkbox>
|
||||
<div class="batch-member-status-grid">
|
||||
<el-form-item label="目标状态">
|
||||
<el-radio-group v-model="batchConfigForm.member_status" :disabled="!batchConfigApply.member_status || savingBatchConfig">
|
||||
<el-radio-button :value="1">批量上线</el-radio-button>
|
||||
<el-radio-button :value="0">批量下线</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择员工">
|
||||
<el-tree-select
|
||||
v-model="batchConfigForm.member_admin_ids"
|
||||
:data="batchMemberDepartmentTree"
|
||||
:props="memberTreeProps"
|
||||
:default-expanded-keys="batchMemberTreeDefaultExpandedKeys"
|
||||
:filter-node-method="filterMemberTreeNode"
|
||||
node-key="value"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
show-checkbox
|
||||
check-on-click-node
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:max-collapse-tags="3"
|
||||
:multiple-limit="100"
|
||||
:render-after-expand="false"
|
||||
:disabled="!batchConfigApply.member_status || savingBatchConfig"
|
||||
placeholder="按部门勾选,或搜索姓名 / 部门 / 企微 userid"
|
||||
style="width: 100%"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span class="member-tree-option" :class="`is-${data.kind}`">
|
||||
<span class="member-tree-option__label">{{ data.label }}</span>
|
||||
<span v-if="data.kind === 'department'" class="member-tree-option__count">{{ data.member_count }} 人</span>
|
||||
<span v-else class="member-tree-option__detail">{{ data.detail }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree-select>
|
||||
<span class="form-tip">勾选部门可全选其下员工;已选 {{ batchConfigForm.member_admin_ids.length }} 名。</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="batchConfigApply.member_status && batchConfigForm.member_status === 0 && batchMemberOfflineBlockedPools.length"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="`${batchMemberOfflineBlockedPools[0].name} 至少需要保留一名当前可用的上线员工`"
|
||||
:description="batchMemberOfflineBlockedPools.length > 1 ? `另有 ${batchMemberOfflineBlockedPools.length - 1} 个方案也会没有可用上线员工,请调整选择。` : '请减少下线员工,或先为该方案上线一名当前可用的员工。'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="overview.automation_installed" class="batch-config-section automation-batch-section">
|
||||
<h3>自动化设置</h3>
|
||||
<p class="batch-section-tip">先选择要覆盖的分组。未选择的接待、客户或欢迎语设置不会随本次批量操作改变。</p>
|
||||
@@ -514,7 +580,7 @@
|
||||
@busy="(busy) => batchConfigBusy = busy"
|
||||
/>
|
||||
</section>
|
||||
<el-alert v-else type="warning" show-icon :closable="false" title="自动化配置尚未安装" description="本次仍可批量修改验证方式、兜底链接和运行状态;安装自动化数据表后即可批量修改接待、客户和欢迎语设置。" />
|
||||
<el-alert v-else type="warning" show-icon :closable="false" title="自动化配置尚未安装" description="本次仍可批量修改验证方式、兜底链接、运行状态和员工上下线;安装自动化数据表后即可批量修改接待、客户和欢迎语设置。" />
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -655,6 +721,12 @@ interface PromotionMemberOption {
|
||||
dept_names: string[]
|
||||
}
|
||||
|
||||
interface BatchMemberStatusOption extends PromotionMemberOption {
|
||||
pool_count: number
|
||||
online_count: number
|
||||
offline_count: number
|
||||
}
|
||||
|
||||
interface PromotionOperatorOption {
|
||||
id: number
|
||||
name: string
|
||||
@@ -714,8 +786,8 @@ const poolFormScroll = ref<HTMLElement>()
|
||||
const poolFormError = ref('')
|
||||
const automationBusy = ref(false)
|
||||
const accessForm = reactive({ pool_ids: [] as number[], operator_admin_ids: [] as number[], action: 'grant' as 'grant' | 'revoke' })
|
||||
const batchConfigApply = reactive({ skip_verify: false, fallback_url: false, status: false, reception: false, customer: false, welcome: false })
|
||||
const batchConfigForm = reactive({ pool_ids: [] as number[], skip_verify: 0, fallback_url: '', status: 1, automation_config: defaultAutomationConfig() })
|
||||
const batchConfigApply = reactive({ skip_verify: false, fallback_url: false, status: false, member_status: false, reception: false, customer: false, welcome: false })
|
||||
const batchConfigForm = reactive({ pool_ids: [] as number[], skip_verify: 0, fallback_url: '', status: 1, member_admin_ids: [] as number[], member_status: 1 as 0 | 1, automation_config: defaultAutomationConfig() })
|
||||
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)
|
||||
@@ -738,7 +810,84 @@ const selectedPool = computed(() => overview.pools.find((item: any) => Number(it
|
||||
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 manageablePoolIds = computed(() => overview.pools
|
||||
.filter((pool: any) => pool.can_manage_access)
|
||||
.map((pool: any) => Number(pool.id)))
|
||||
const selectedManageablePoolCount = computed(() => {
|
||||
const selected = new Set(selectedPoolIds.value)
|
||||
return manageablePoolIds.value.filter((id) => selected.has(id)).length
|
||||
})
|
||||
const allManageablePoolsSelected = computed(() => manageablePoolIds.value.length > 0
|
||||
&& selectedManageablePoolCount.value === manageablePoolIds.value.length)
|
||||
const someManageablePoolsSelected = computed(() => selectedManageablePoolCount.value > 0
|
||||
&& selectedManageablePoolCount.value < manageablePoolIds.value.length)
|
||||
const batchConfigPools = computed(() => overview.pools.filter((item: any) => batchConfigForm.pool_ids.includes(Number(item.id))))
|
||||
const batchMemberStatusOptions = computed<BatchMemberStatusOption[]>(() => {
|
||||
const memberOptionById = new Map(overview.member_options.map((member) => [Number(member.id), member]))
|
||||
const grouped = new Map<number, BatchMemberStatusOption & { pool_ids: Set<number> }>()
|
||||
batchConfigPools.value.forEach((pool: any) => {
|
||||
const poolId = Number(pool.id)
|
||||
const memberRules = Array.isArray(pool.member_rules) ? pool.member_rules : []
|
||||
memberRules.forEach((rule: any) => {
|
||||
const adminId = Number(rule.admin_id)
|
||||
if (adminId <= 0) return
|
||||
const member = memberOptionById.get(adminId)
|
||||
const current = grouped.get(adminId) || {
|
||||
id: adminId,
|
||||
name: String(rule.name || member?.name || rule.userid || `员工 ${adminId}`),
|
||||
userid: String(member?.userid || rule.userid || ''),
|
||||
display_dept_id: Number(member?.display_dept_id || 0),
|
||||
dept_ids: Array.isArray(member?.dept_ids) ? member.dept_ids.map(Number).filter((id) => id > 0) : [],
|
||||
dept_names: Array.isArray(member?.dept_names)
|
||||
? member.dept_names.map(String).filter(Boolean)
|
||||
: (Array.isArray(rule.dept_names) ? rule.dept_names.map(String).filter(Boolean) : []),
|
||||
pool_count: 0,
|
||||
online_count: 0,
|
||||
offline_count: 0,
|
||||
pool_ids: new Set<number>()
|
||||
}
|
||||
if (!current.pool_ids.has(poolId)) {
|
||||
current.pool_ids.add(poolId)
|
||||
current.pool_count++
|
||||
if (Number(rule.enabled) === 1) current.online_count++
|
||||
else current.offline_count++
|
||||
}
|
||||
grouped.set(adminId, current)
|
||||
})
|
||||
})
|
||||
return [...grouped.values()]
|
||||
.map(({ pool_ids: _poolIds, ...member }) => member)
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||
})
|
||||
const batchMemberStatusById = computed(() => new Map(
|
||||
batchMemberStatusOptions.value.map((member) => [member.id, member])
|
||||
))
|
||||
const batchMemberDepartmentTree = computed(() => buildMemberDepartmentTree(
|
||||
overview.department_options,
|
||||
batchMemberStatusOptions.value,
|
||||
{
|
||||
selectableDepartments: true,
|
||||
memberDetail: (member) => {
|
||||
const status = batchMemberStatusById.value.get(member.id)
|
||||
return status
|
||||
? `${status.pool_count} 个方案 · 已上线 ${status.online_count} / 已下线 ${status.offline_count}`
|
||||
: ''
|
||||
}
|
||||
}
|
||||
))
|
||||
const batchMemberTreeDefaultExpandedKeys = computed(() => batchMemberDepartmentTree.value.map((node) => node.value))
|
||||
const batchMemberOfflineBlockedPools = computed(() => {
|
||||
if (!batchConfigApply.member_status || batchConfigForm.member_status !== 0) return []
|
||||
const targetAdminIds = new Set(batchConfigForm.member_admin_ids.map(Number))
|
||||
return batchConfigPools.value.filter((pool: any) => {
|
||||
const rules = Array.isArray(pool.member_rules) ? pool.member_rules : []
|
||||
const targetedEnabled = rules.some((rule: any) => Number(rule.enabled) === 1 && targetAdminIds.has(Number(rule.admin_id)))
|
||||
const remainingAvailable = rules.some((rule: any) => Number(rule.enabled) === 1
|
||||
&& (batchConfigApply.reception || rule.reception_available)
|
||||
&& !targetAdminIds.has(Number(rule.admin_id)))
|
||||
return targetedEnabled && !remainingAvailable
|
||||
})
|
||||
})
|
||||
const batchPrimaryMemberSets = computed(() => batchConfigPools.value.map((pool: any) => new Set<number>(
|
||||
(Array.isArray(pool.member_admin_ids) ? pool.member_admin_ids : []).map(Number)
|
||||
)))
|
||||
@@ -811,6 +960,10 @@ function togglePoolSelection(pool: any, checked: unknown) {
|
||||
selectedPoolIds.value = [...next]
|
||||
}
|
||||
|
||||
function toggleAllPoolSelection(checked: unknown) {
|
||||
selectedPoolIds.value = checked ? [...manageablePoolIds.value] : []
|
||||
}
|
||||
|
||||
function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
|
||||
const manageableIds = new Set(overview.pools
|
||||
.filter((pool: any) => pool.can_manage_access)
|
||||
@@ -823,6 +976,7 @@ function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
|
||||
skip_verify: false,
|
||||
fallback_url: false,
|
||||
status: false,
|
||||
member_status: false,
|
||||
reception: false,
|
||||
customer: false,
|
||||
welcome: false
|
||||
@@ -832,6 +986,8 @@ function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
|
||||
skip_verify: Number(reference?.skip_verify) === 1 ? 1 : 0,
|
||||
fallback_url: String(reference?.fallback_url || ''),
|
||||
status: Number(reference?.status) === 1 ? 1 : 0,
|
||||
member_admin_ids: [],
|
||||
member_status: 1,
|
||||
automation_config: cloneAutomationConfig(reference?.automation_config)
|
||||
})
|
||||
batchConfigError.value = ''
|
||||
@@ -860,9 +1016,23 @@ async function saveBatchConfig() {
|
||||
batchConfigError.value = ''
|
||||
const hasAutomationChange = overview.automation_installed
|
||||
&& (batchConfigApply.reception || batchConfigApply.customer || batchConfigApply.welcome)
|
||||
if (!batchConfigApply.skip_verify && !batchConfigApply.fallback_url && !batchConfigApply.status && !hasAutomationChange) {
|
||||
const hasMemberStatusChange = batchConfigApply.member_status
|
||||
const hasPoolConfigChange = batchConfigApply.skip_verify || batchConfigApply.fallback_url || batchConfigApply.status || hasAutomationChange
|
||||
if (!batchConfigApply.skip_verify && !batchConfigApply.fallback_url && !batchConfigApply.status && !hasMemberStatusChange && !hasAutomationChange) {
|
||||
return setBatchConfigError('请至少勾选一项需要批量修改的配置')
|
||||
}
|
||||
if (hasMemberStatusChange && hasPoolConfigChange) {
|
||||
return setBatchConfigError('员工上下线需要单独批量保存,请取消其他方案配置修改')
|
||||
}
|
||||
if (hasMemberStatusChange && !batchConfigForm.member_admin_ids.length) {
|
||||
return setBatchConfigError('请至少选择一名需要批量上线或下线的员工')
|
||||
}
|
||||
if (hasMemberStatusChange && batchConfigForm.member_admin_ids.length > 100) {
|
||||
return setBatchConfigError('单次最多设置 100 名员工,请减少选择范围')
|
||||
}
|
||||
if (hasMemberStatusChange && batchConfigForm.member_status === 0 && batchMemberOfflineBlockedPools.value.length) {
|
||||
return setBatchConfigError(`${batchMemberOfflineBlockedPools.value[0].name} 至少需要保留一名当前可用的上线员工`)
|
||||
}
|
||||
if (batchConfigApply.fallback_url && batchConfigForm.fallback_url.trim() && !isWebUrl(batchConfigForm.fallback_url.trim())) {
|
||||
return setBatchConfigError('兜底获客助手链接必须为有效的 HTTP/HTTPS 地址')
|
||||
}
|
||||
@@ -889,10 +1059,17 @@ async function saveBatchConfig() {
|
||||
fallback_url?: string
|
||||
status?: 0 | 1
|
||||
automation_config?: Record<string, unknown>
|
||||
member_status?: { member_admin_ids: number[]; status: 0 | 1 }
|
||||
} = {}
|
||||
if (batchConfigApply.skip_verify) changes.skip_verify = batchConfigForm.skip_verify === 1 ? 1 : 0
|
||||
if (batchConfigApply.fallback_url) changes.fallback_url = batchConfigForm.fallback_url.trim()
|
||||
if (batchConfigApply.status) changes.status = batchConfigForm.status === 1 ? 1 : 0
|
||||
if (hasMemberStatusChange) {
|
||||
changes.member_status = {
|
||||
member_admin_ids: [...batchConfigForm.member_admin_ids],
|
||||
status: batchConfigForm.member_status
|
||||
}
|
||||
}
|
||||
if (hasAutomationChange) {
|
||||
const serialized = serializeAutomationConfig(automation) as unknown as Record<string, unknown>
|
||||
const automationPatch: Record<string, unknown> = {}
|
||||
@@ -919,19 +1096,25 @@ async function saveBatchConfig() {
|
||||
batchConfigDialogVisible.value = false
|
||||
selectedPoolIds.value = []
|
||||
await loadOverview()
|
||||
const memberStatusText = batchConfigForm.member_status === 1 ? '上线' : '下线'
|
||||
const memberResultText = hasMemberStatusChange
|
||||
? (result.member_updated > 0
|
||||
? `,${result.member_updated} 条员工规则已${memberStatusText}`
|
||||
: `,所选员工均已处于${memberStatusText}状态`)
|
||||
: ''
|
||||
if (result.failed > 0) {
|
||||
const detail = result.results
|
||||
.filter((item) => !item.success)
|
||||
.slice(0, 2)
|
||||
.map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`)
|
||||
.join(';')
|
||||
ElMessage.warning(`已更新 ${result.updated} 个方案,${result.failed} 个失败${detail ? `。${detail}` : ''}`)
|
||||
ElMessage.warning(`已更新 ${result.updated} 个方案,${result.failed} 个失败${memberResultText}${detail ? `。${detail}` : ''}`)
|
||||
} else if (result.sync_error_count > 0) {
|
||||
ElMessage.warning(`已更新 ${result.updated} 个方案,其中 ${result.sync_error_count} 个企微范围将在后台自动重试同步`)
|
||||
ElMessage.warning(`已更新 ${result.updated} 个方案${memberResultText},其中 ${result.sync_error_count} 个企微范围将在后台自动重试同步`)
|
||||
} else if (result.sync_queued_count > 0) {
|
||||
ElMessage.success(`已批量更新 ${result.updated} 个分流方案,${result.sync_queued_count} 个企微链接配置将在后台同步`)
|
||||
ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText},${result.sync_queued_count} 个企微链接配置将在后台同步`)
|
||||
} else {
|
||||
ElMessage.success(`已批量更新 ${result.updated} 个分流方案`)
|
||||
ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
setBatchConfigError(error?.message || '批量修改分流方案失败')
|
||||
@@ -946,6 +1129,7 @@ function openAccessDialog(poolIds: number[] = selectedPoolIds.value) {
|
||||
.map((pool: any) => Number(pool.id)))
|
||||
const ids = [...new Set(poolIds.map(Number).filter((id) => manageableIds.has(id)))]
|
||||
if (!ids.length) return ElMessage.warning('请先选择可管理的分流方案')
|
||||
if (ids.length > 100) return ElMessage.warning('单次最多设置 100 个分流方案')
|
||||
Object.assign(accessForm, {
|
||||
pool_ids: ids,
|
||||
operator_admin_ids: [],
|
||||
@@ -1286,7 +1470,11 @@ function todayCount(row: any) {
|
||||
|
||||
function buildMemberDepartmentTree(
|
||||
departmentOptions: PromotionDepartmentOption[],
|
||||
memberOptions: PromotionMemberOption[]
|
||||
memberOptions: PromotionMemberOption[],
|
||||
options: {
|
||||
selectableDepartments?: boolean
|
||||
memberDetail?: (member: PromotionMemberOption) => string
|
||||
} = {}
|
||||
): PromotionMemberTreeNode[] {
|
||||
const availableDeptIds = new Set<number>()
|
||||
const collectDeptIds = (departments: PromotionDepartmentOption[]) => {
|
||||
@@ -1331,7 +1519,8 @@ function buildMemberDepartmentTree(
|
||||
const placedMemberIds = new Set<number>()
|
||||
const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => {
|
||||
const departments = member.dept_names.length ? member.dept_names.join(' / ') : '未分部门'
|
||||
const detail = member.userid ? `${departments} · ${member.userid}` : departments
|
||||
const detail = options.memberDetail?.(member)
|
||||
|| (member.userid ? `${departments} · ${member.userid}` : departments)
|
||||
return {
|
||||
value: member.id,
|
||||
label: member.name,
|
||||
@@ -1366,7 +1555,7 @@ function buildMemberDepartmentTree(
|
||||
value: `dept:${departmentId}`,
|
||||
label: departmentName,
|
||||
kind: 'department',
|
||||
disabled: true,
|
||||
disabled: !options.selectableDepartments,
|
||||
member_count: memberCount,
|
||||
search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
||||
children
|
||||
@@ -1383,7 +1572,7 @@ function buildMemberDepartmentTree(
|
||||
value: 'dept:unassigned',
|
||||
label: '未分部门',
|
||||
kind: 'department',
|
||||
disabled: true,
|
||||
disabled: !options.selectableDepartments,
|
||||
member_count: children.length,
|
||||
search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
||||
children
|
||||
@@ -1569,6 +1758,7 @@ h1, h2, h3, p { margin: 0; }
|
||||
.selection-count { color: #117f75; font-size: 11px; font-weight: 600; }
|
||||
.pool-layout { display: grid; grid-template-columns: 252px minmax(0, 1fr); height: clamp(430px, calc(100vh - 330px), 720px); min-height: 430px; border: 1px solid var(--line); border-radius: 11px; overflow: hidden; }
|
||||
.pool-sidebar { min-height: 0; padding: 8px; border-right: 1px solid var(--line); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; background: #f7f9fa; }
|
||||
.pool-select-all { display: flex; position: sticky; z-index: 2; top: 0; align-items: center; justify-content: space-between; min-height: 42px; margin: 0 0 8px; padding: 4px 8px; border-bottom: 1px solid var(--line); background: #f7f9fa; }.pool-select-all :deep(.el-checkbox) { margin-right: 0; }.pool-select-all span { color: #8c98a7; font-size: 10px; }
|
||||
.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; }
|
||||
@@ -1621,10 +1811,11 @@ h1, h2, h3, p { margin: 0; }
|
||||
.batch-pool-summary strong { margin-right: 3px; font-size: 12px; }.batch-pool-summary span { padding: 4px 8px; border-radius: 12px; color: #426078; background: #eaf1f5; font-size: 10px; }.batch-pool-summary small { color: #8491a2; font-size: 10px; }
|
||||
.batch-config-form { margin-top: 16px; }.batch-config-section { margin-bottom: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 9px; background: #fff; }.batch-config-section > h3 { margin: 0 0 14px; color: #303b4d; font-size: 14px; }
|
||||
.batch-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }.batch-field { padding: 12px; border: 1px solid #dfe6ec; border-radius: 8px; transition: opacity .2s ease, background .2s ease; }.batch-field.is-disabled { opacity: .58; background: #f7f8fa; }.batch-field :deep(.el-form-item) { margin: 10px 0 0; }
|
||||
.batch-member-status-field { padding: 14px; }.batch-member-status-grid { display: grid; grid-template-columns: minmax(190px, .7fr) minmax(0, 1.6fr); gap: 14px; }
|
||||
.batch-section-tip { margin: -6px 0 12px; color: #8491a2; font-size: 11px; line-height: 1.6; }.batch-section-selectors { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.batch-section-selectors :deep(.el-checkbox) { margin-right: 0; }.automation-batch-section :deep(.automation-note) { margin-top: 14px; }
|
||||
.uploading-save-tip { color: #b88230; font-size: 12px; margin-right: 16px; }
|
||||
.sync-retry-tip { color: #b88230; font-size: 10px; line-height: 1.6; margin-top: 4px; }
|
||||
:global(.promotion-pool-dialog) { max-width: calc(100vw - 32px); margin-top: 5vh; }
|
||||
@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, .batch-field-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { height: auto; min-height: 0; grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--line); scrollbar-gutter: auto; }.pool-main { overflow: visible; }.member-table-area { height: 420px; min-height: 320px; flex: none; }.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; } }
|
||||
@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, .batch-field-grid, .batch-member-status-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { height: auto; min-height: 0; grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid var(--line); scrollbar-gutter: auto; }.pool-select-all { position: static; min-width: 126px; flex: 0 0 126px; margin: 0 6px 0 0; border-right: 1px solid var(--line); border-bottom: 0; }.pool-main { overflow: visible; }.member-table-area { height: 420px; min-height: 320px; flex: none; }.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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 73 KiB |
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Bundled Noto Sans SC
|
||||
|
||||
`NotoSansSC-VF.ttf` is the unmodified Google Fonts distribution of
|
||||
`NotoSansSC[wght].ttf`, stored under a filename without brackets for simpler
|
||||
resource lookup. No font bytes were changed or subsetted locally.
|
||||
|
||||
- Qt family: `Noto Sans SC`
|
||||
- Font version: `Version 2.004-H2;hotconv 1.0.118;makeotfexe 2.5.65603`
|
||||
- Variable axis: `wght`, 100–900; named instances at every 100, including 600.
|
||||
- Size: 17,772,300 bytes.
|
||||
- SHA-256: `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da`
|
||||
- Official repository revision: `google/fonts@5e35378e6bda803962ee6fd257e444a7d459660d`.
|
||||
- [Pinned font source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf).
|
||||
- [Pinned license source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/OFL.txt).
|
||||
|
||||
The font is distributed under the SIL Open Font License 1.1. Retain
|
||||
`OFL-NotoSansSC.txt`, including its copyright notice, when redistributing the
|
||||
font with the application. The license applies to the font, independently of
|
||||
the application's license.
|
||||
|
||||
Load this local resource through `QFontDatabase.addApplicationFont` after
|
||||
creating `QApplication`, then use the returned family name. The application
|
||||
must not fetch fonts at runtime. The PyInstaller spec already copies the
|
||||
entire `resources` directory, including this directory and its license.
|
||||
|
||||
Google Fonts supplies explicit Regular (400), Medium (500), and SemiBold (600)
|
||||
instances. The Noto CJK upstream 2.004 file lacks a named 600 instance and Qt
|
||||
may select Medium for a plain `font-weight: 600` request; this distribution
|
||||
preserves distinct results with the application's normal QSS font weights.
|
||||
@@ -169,8 +169,30 @@ try {
|
||||
& $Npm run build --prefix $CompanionRoot
|
||||
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
|
||||
|
||||
& $Python -m PyInstaller --noconfirm --clean $Spec
|
||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
|
||||
$BuildPythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $BuildPythonBase) {
|
||||
throw "Unable to resolve the build Python runtime directory"
|
||||
}
|
||||
# Dependency scanning must not collect unrelated ICU/OpenSSL libraries from
|
||||
# an editor's helper tools (for example Poppler) ahead of Windows libraries.
|
||||
$PreviousBuildPath = $env:PATH
|
||||
$BuildRuntimePaths = @(
|
||||
(Split-Path -Parent $Python),
|
||||
$BuildPythonBase,
|
||||
(Join-Path $BuildPythonBase "DLLs"),
|
||||
(Join-Path $env:SystemRoot "System32"),
|
||||
$env:SystemRoot,
|
||||
(Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0")
|
||||
)
|
||||
try {
|
||||
$env:PATH = ($BuildRuntimePaths | Select-Object -Unique) -join [System.IO.Path]::PathSeparator
|
||||
& $Python -m PyInstaller --noconfirm --clean $Spec
|
||||
$PyInstallerExitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
$env:PATH = $PreviousBuildPath
|
||||
}
|
||||
if ($PyInstallerExitCode -ne 0) { throw "PyInstaller build failed" }
|
||||
|
||||
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
|
||||
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
|
||||
|
||||
@@ -8,10 +8,10 @@ from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog
|
||||
from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
@@ -480,12 +480,7 @@ def _run_immediately(
|
||||
|
||||
def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
apply_theme(app)
|
||||
diagnosis_module.run_async = _run_immediately
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
@@ -19,13 +18,6 @@ def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -9,10 +9,11 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QApplication, QToolButton, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||||
|
||||
@@ -339,14 +340,7 @@ def _save_with_payment_qr(
|
||||
|
||||
def _application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
# The offscreen Windows plugin does not enumerate system fonts. Register
|
||||
# the same CJK face used by the production QSS when it is available.
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.core.permissions import PermissionSet
|
||||
@@ -136,12 +135,6 @@ def _settle(app: QApplication, rounds: int = 8) -> None:
|
||||
def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
|
||||
output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
@@ -19,9 +18,6 @@ from doctor_workstation.ui.theme import apply_theme
|
||||
def main() -> int:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
|
||||
if font_path.is_file():
|
||||
QFontDatabase.addApplicationFont(str(font_path))
|
||||
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login("doctor", "doctor123")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Render the clinical reading surfaces with demo data and production fonts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtGui import QFontInfo, QGuiApplication, QPalette
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||
|
||||
|
||||
def _settle(app: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
QThreadPool.globalInstance().waitForDone(3000)
|
||||
app.processEvents()
|
||||
QTest.qWait(100)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=Path("artifacts/ui_comfort"))
|
||||
parser.add_argument("--width", type=int, default=1536)
|
||||
parser.add_argument("--height", type=int, default=912)
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||
)
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
shell = ShellWindow(
|
||||
repo, {"session": session, "demo_mode": True}, permissions=session.permissions
|
||||
)
|
||||
shell.resize(args.width, args.height)
|
||||
shell.show()
|
||||
try:
|
||||
shell.navigate("reception")
|
||||
_settle(app)
|
||||
page = shell.pages["reception"]
|
||||
page._set_queue_filter(None)
|
||||
_settle(app)
|
||||
# A synthetic multiline case tests paragraph rhythm without capturing
|
||||
# a live patient or connecting to a production service.
|
||||
page.case_labels["present"].setText(
|
||||
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
|
||||
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
|
||||
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
|
||||
)
|
||||
app.processEvents()
|
||||
if not shell.grab().save(str(args.output / "reception.png")):
|
||||
raise RuntimeError("Could not save reception preview")
|
||||
daily = next(
|
||||
index
|
||||
for index in range(page.detail_tabs.count())
|
||||
if page.detail_tabs.tabText(index) == "日常记录"
|
||||
)
|
||||
page.detail_tabs.setCurrentIndex(daily)
|
||||
_settle(app)
|
||||
if not shell.grab().save(str(args.output / "daily_records.png")):
|
||||
raise RuntimeError("Could not save daily-record preview")
|
||||
metrics = {
|
||||
"family": QFontInfo(app.font()).family(),
|
||||
"pixel_size": app.font().pixelSize(),
|
||||
"font_strategy": app.font().styleStrategy().value,
|
||||
"font_hinting": app.font().hintingPreference().name,
|
||||
"text_color": app.palette().color(QPalette.ColorRole.Text).name(),
|
||||
"device_pixel_ratio": shell.devicePixelRatioF(),
|
||||
"window": [shell.width(), shell.height()],
|
||||
"daily_table_font": QFontInfo(page.daily_panel.matrix.font()).family(),
|
||||
"daily_table_size": page.daily_panel.matrix.font().pixelSize(),
|
||||
}
|
||||
(args.output / "render.json").write_text(
|
||||
json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
print(args.output)
|
||||
finally:
|
||||
_settle(app)
|
||||
shell.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+380
-380
@@ -5,9 +5,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
|
||||
@@ -27,7 +27,7 @@ from doctor_workstation.config import AppConfig
|
||||
from doctor_workstation.core import Session
|
||||
from doctor_workstation.core.errors import AuthenticationExpiredError
|
||||
from doctor_workstation.logging_setup import configure_logging
|
||||
from doctor_workstation.resources import app_icon_path, video_dist_path
|
||||
from doctor_workstation.resources import app_icon_path, video_dist_path
|
||||
from doctor_workstation.services import (
|
||||
DemoDoctorRepository,
|
||||
RemoteDoctorRepository,
|
||||
@@ -35,179 +35,179 @@ from doctor_workstation.services import (
|
||||
build_repository,
|
||||
)
|
||||
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
|
||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
||||
from doctor_workstation.ui.widgets import (
|
||||
first_value,
|
||||
friendly_error,
|
||||
gender_text,
|
||||
get_value,
|
||||
run_async,
|
||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
||||
from doctor_workstation.ui.widgets import (
|
||||
first_value,
|
||||
friendly_error,
|
||||
gender_text,
|
||||
get_value,
|
||||
run_async,
|
||||
set_authentication_expired_handler,
|
||||
show_toast,
|
||||
)
|
||||
from doctor_workstation.video import BackendMode, launch_video_call
|
||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
||||
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
||||
|
||||
if value in (None, "", [], {}):
|
||||
return ""
|
||||
if isinstance(value, Mapping):
|
||||
parts = [
|
||||
f"{key}:{_video_case_text(item, limit=limit)}"
|
||||
for key, item in value.items()
|
||||
if item not in (None, "", [], {})
|
||||
]
|
||||
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return "、".join(
|
||||
part
|
||||
for item in value
|
||||
if (part := _video_case_text(item, limit=limit))
|
||||
)[:limit]
|
||||
return str(value).strip()[:limit]
|
||||
|
||||
|
||||
def _video_identity(value: Any) -> str:
|
||||
if value in (None, "") or isinstance(value, bool):
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
with suppress(ValueError, TypeError):
|
||||
return str(int(text))
|
||||
return text
|
||||
|
||||
|
||||
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
||||
normalized_actual = _video_identity(actual)
|
||||
return not normalized_actual or normalized_actual == _video_identity(expected)
|
||||
|
||||
|
||||
def _build_video_patient_case(
|
||||
detail: Any,
|
||||
fallback_record: Any,
|
||||
*,
|
||||
diagnosis_id: Any,
|
||||
patient_id: Any,
|
||||
patient_name: str,
|
||||
) -> dict[str, str]:
|
||||
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
||||
|
||||
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
||||
nested = get_value(detail, "data", None)
|
||||
if isinstance(nested, Mapping):
|
||||
detail = nested
|
||||
diagnosis = get_value(detail, "diagnosis", None)
|
||||
if not diagnosis and isinstance(detail, Mapping):
|
||||
diagnosis = detail
|
||||
diagnosis = diagnosis or {}
|
||||
patient = get_value(detail, "patient", None) or {}
|
||||
appointment = get_value(detail, "appointment", None) or {}
|
||||
|
||||
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
||||
detail_patient_id = first_value(
|
||||
diagnosis,
|
||||
"source_patient_id",
|
||||
default=first_value(
|
||||
patient,
|
||||
"source_patient_id",
|
||||
default=None,
|
||||
),
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
detail_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, detail_patient_id):
|
||||
detail = diagnosis = patient = appointment = {}
|
||||
|
||||
fallback_diagnosis_id = first_value(
|
||||
fallback_record,
|
||||
"diagnosis_id",
|
||||
"id",
|
||||
default=None,
|
||||
)
|
||||
fallback_patient_id = first_value(
|
||||
fallback_record,
|
||||
"source_patient_id",
|
||||
"patient_id",
|
||||
default=None,
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
fallback_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
||||
fallback_record = {}
|
||||
sources = (diagnosis, patient, appointment, fallback_record)
|
||||
|
||||
def pick(*keys: str, limit: int = 2000) -> str:
|
||||
for source in sources:
|
||||
value = first_value(source, *keys, default=None)
|
||||
text = _video_case_text(value, limit=limit)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
raw_gender = next(
|
||||
(
|
||||
first_value(source, "gender_desc", "gender", default=None)
|
||||
for source in sources
|
||||
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
||||
"name": pick("patient_name", "name", limit=120)
|
||||
or _video_case_text(patient_name, limit=120)
|
||||
or "患者",
|
||||
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
||||
"age": pick("age", limit=20),
|
||||
"height": pick("height", limit=20),
|
||||
"weight": pick("weight", limit=20),
|
||||
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
||||
"appointmentDate": pick(
|
||||
"appointment_date",
|
||||
"latest_appointment_date",
|
||||
limit=80,
|
||||
),
|
||||
"clinicalDiagnosis": pick(
|
||||
"clinical_diagnosis",
|
||||
"diagnosis_name",
|
||||
"disease_name",
|
||||
),
|
||||
"chiefComplaint": pick("chief_complaint", "complaint"),
|
||||
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
||||
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
||||
"allergyHistory": pick(
|
||||
"allergy_history_text",
|
||||
"allergy_history_desc",
|
||||
"allergy_history",
|
||||
),
|
||||
"personalHistory": pick(
|
||||
"personal_history_text",
|
||||
"personal_history_desc",
|
||||
"personal_history",
|
||||
),
|
||||
"familyHistory": pick(
|
||||
"family_history_text",
|
||||
"family_history_desc",
|
||||
"family_history",
|
||||
),
|
||||
"currentMedication": pick(
|
||||
"current_medications",
|
||||
"current_medicine",
|
||||
"current_medication",
|
||||
),
|
||||
"tongue": pick("tongue", "tongue_coating"),
|
||||
"pulse": pick("pulse", "pulse_condition"),
|
||||
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
||||
"remark": pick("remark"),
|
||||
}
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
||||
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
||||
|
||||
if value in (None, "", [], {}):
|
||||
return ""
|
||||
if isinstance(value, Mapping):
|
||||
parts = [
|
||||
f"{key}:{_video_case_text(item, limit=limit)}"
|
||||
for key, item in value.items()
|
||||
if item not in (None, "", [], {})
|
||||
]
|
||||
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return "、".join(
|
||||
part
|
||||
for item in value
|
||||
if (part := _video_case_text(item, limit=limit))
|
||||
)[:limit]
|
||||
return str(value).strip()[:limit]
|
||||
|
||||
|
||||
def _video_identity(value: Any) -> str:
|
||||
if value in (None, "") or isinstance(value, bool):
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
with suppress(ValueError, TypeError):
|
||||
return str(int(text))
|
||||
return text
|
||||
|
||||
|
||||
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
||||
normalized_actual = _video_identity(actual)
|
||||
return not normalized_actual or normalized_actual == _video_identity(expected)
|
||||
|
||||
|
||||
def _build_video_patient_case(
|
||||
detail: Any,
|
||||
fallback_record: Any,
|
||||
*,
|
||||
diagnosis_id: Any,
|
||||
patient_id: Any,
|
||||
patient_name: str,
|
||||
) -> dict[str, str]:
|
||||
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
||||
|
||||
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
||||
nested = get_value(detail, "data", None)
|
||||
if isinstance(nested, Mapping):
|
||||
detail = nested
|
||||
diagnosis = get_value(detail, "diagnosis", None)
|
||||
if not diagnosis and isinstance(detail, Mapping):
|
||||
diagnosis = detail
|
||||
diagnosis = diagnosis or {}
|
||||
patient = get_value(detail, "patient", None) or {}
|
||||
appointment = get_value(detail, "appointment", None) or {}
|
||||
|
||||
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
||||
detail_patient_id = first_value(
|
||||
diagnosis,
|
||||
"source_patient_id",
|
||||
default=first_value(
|
||||
patient,
|
||||
"source_patient_id",
|
||||
default=None,
|
||||
),
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
detail_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, detail_patient_id):
|
||||
detail = diagnosis = patient = appointment = {}
|
||||
|
||||
fallback_diagnosis_id = first_value(
|
||||
fallback_record,
|
||||
"diagnosis_id",
|
||||
"id",
|
||||
default=None,
|
||||
)
|
||||
fallback_patient_id = first_value(
|
||||
fallback_record,
|
||||
"source_patient_id",
|
||||
"patient_id",
|
||||
default=None,
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
fallback_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
||||
fallback_record = {}
|
||||
sources = (diagnosis, patient, appointment, fallback_record)
|
||||
|
||||
def pick(*keys: str, limit: int = 2000) -> str:
|
||||
for source in sources:
|
||||
value = first_value(source, *keys, default=None)
|
||||
text = _video_case_text(value, limit=limit)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
raw_gender = next(
|
||||
(
|
||||
first_value(source, "gender_desc", "gender", default=None)
|
||||
for source in sources
|
||||
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
||||
"name": pick("patient_name", "name", limit=120)
|
||||
or _video_case_text(patient_name, limit=120)
|
||||
or "患者",
|
||||
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
||||
"age": pick("age", limit=20),
|
||||
"height": pick("height", limit=20),
|
||||
"weight": pick("weight", limit=20),
|
||||
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
||||
"appointmentDate": pick(
|
||||
"appointment_date",
|
||||
"latest_appointment_date",
|
||||
limit=80,
|
||||
),
|
||||
"clinicalDiagnosis": pick(
|
||||
"clinical_diagnosis",
|
||||
"diagnosis_name",
|
||||
"disease_name",
|
||||
),
|
||||
"chiefComplaint": pick("chief_complaint", "complaint"),
|
||||
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
||||
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
||||
"allergyHistory": pick(
|
||||
"allergy_history_text",
|
||||
"allergy_history_desc",
|
||||
"allergy_history",
|
||||
),
|
||||
"personalHistory": pick(
|
||||
"personal_history_text",
|
||||
"personal_history_desc",
|
||||
"personal_history",
|
||||
),
|
||||
"familyHistory": pick(
|
||||
"family_history_text",
|
||||
"family_history_desc",
|
||||
"family_history",
|
||||
),
|
||||
"currentMedication": pick(
|
||||
"current_medications",
|
||||
"current_medicine",
|
||||
"current_medication",
|
||||
),
|
||||
"tongue": pick("tongue", "tongue_coating"),
|
||||
"pulse": pick("pulse", "pulse_condition"),
|
||||
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
||||
"remark": pick("remark"),
|
||||
}
|
||||
|
||||
|
||||
class _ChineseQtTranslator(QTranslator):
|
||||
@@ -313,7 +313,7 @@ class DemoVideoDialog(QDialog):
|
||||
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
|
||||
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
|
||||
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
|
||||
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
|
||||
"QPushButton#Hangup{color:#FFFFFF;background:#C23D4E;border-color:#C23D4E;}"
|
||||
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
|
||||
)
|
||||
|
||||
@@ -326,7 +326,7 @@ class DemoVideoDialog(QDialog):
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
demo = QLabel("● 演示模式 · 未连接腾讯云")
|
||||
demo.setStyleSheet("color:#7886AA;font-size:12px;")
|
||||
demo.setStyleSheet("color:#707584;font-size:12px;")
|
||||
header.addWidget(demo)
|
||||
self.duration_label = QLabel("00:00")
|
||||
self.duration_label.setStyleSheet("font-weight:700;")
|
||||
@@ -400,22 +400,22 @@ class ApplicationController(QObject):
|
||||
"""Own windows, repositories and the authenticated application lifecycle."""
|
||||
|
||||
def __init__(self, application: QApplication, config: AppConfig) -> None:
|
||||
super().__init__()
|
||||
self.application = application
|
||||
self.config = config
|
||||
self.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
|
||||
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 = self.debug_mode and config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self._video_preview_state: dict[str, Any] | None = None
|
||||
self._video_preview_generation = 0
|
||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self._video_preview_state: dict[str, Any] | None = None
|
||||
self._video_preview_generation = 0
|
||||
self._restore_generation = 0
|
||||
self._restore_in_progress = False
|
||||
self._restore_worker: Any = None
|
||||
@@ -506,11 +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:
|
||||
allowed = self.debug_mode and enabled
|
||||
self.current_demo_mode = allowed
|
||||
if allowed:
|
||||
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
|
||||
@@ -670,8 +670,8 @@ class ApplicationController(QObject):
|
||||
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
|
||||
return
|
||||
|
||||
demo_mode = self.debug_mode and bool(payload.get("demo_mode"))
|
||||
payload["demo_mode"] = 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()
|
||||
@@ -715,17 +715,17 @@ class ApplicationController(QObject):
|
||||
self._logout(message="登录状态已失效,请重新登录。")
|
||||
return True
|
||||
|
||||
def _logout(self, *, message: str = "") -> None:
|
||||
"""Clear authenticated resources and return to the login window."""
|
||||
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
def _logout(self, *, message: str = "") -> None:
|
||||
"""Clear authenticated resources and return to the login window."""
|
||||
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
for call in calls:
|
||||
with suppress(Exception):
|
||||
call.close()
|
||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||
self.video_calls.clear()
|
||||
self.video_pending.clear()
|
||||
self.video_calls.clear()
|
||||
self.video_pending.clear()
|
||||
for dialog in self.demo_video_dialogs.values():
|
||||
dialog.close()
|
||||
self.demo_video_dialogs.clear()
|
||||
@@ -748,8 +748,8 @@ class ApplicationController(QObject):
|
||||
patient_id = payload.get("patient_id")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
patient_name = str(payload.get("patient_name") or "患者")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
fallback_record = payload.get("record")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
fallback_record = payload.get("record")
|
||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||
return
|
||||
@@ -803,49 +803,49 @@ class ApplicationController(QObject):
|
||||
marker = object()
|
||||
self.video_pending[call_key] = marker
|
||||
|
||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||
ticket = repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
detail: Any = {}
|
||||
detail_loader = getattr(repository, "patient_detail", None)
|
||||
if callable(detail_loader):
|
||||
try:
|
||||
detail = detail_loader(int(diagnosis_id))
|
||||
except Exception as error:
|
||||
LOGGER.warning(
|
||||
"patient detail could not be loaded for video call",
|
||||
extra={
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
)
|
||||
patient_case = _build_video_patient_case(
|
||||
detail,
|
||||
fallback_record,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
return ticket, patient_case
|
||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||
ticket = repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
detail: Any = {}
|
||||
detail_loader = getattr(repository, "patient_detail", None)
|
||||
if callable(detail_loader):
|
||||
try:
|
||||
detail = detail_loader(int(diagnosis_id))
|
||||
except Exception as error:
|
||||
LOGGER.warning(
|
||||
"patient detail could not be loaded for video call",
|
||||
extra={
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
)
|
||||
patient_case = _build_video_patient_case(
|
||||
detail,
|
||||
fallback_record,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
return ticket, patient_case
|
||||
|
||||
def request_ticket() -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
run_async(
|
||||
get_video_context,
|
||||
on_success=lambda context: self._launch_video(
|
||||
context[0],
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
run_async(
|
||||
get_video_context,
|
||||
on_success=lambda context: self._launch_video(
|
||||
context[0],
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
call_key=call_key,
|
||||
marker=marker,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=context[1],
|
||||
),
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=context[1],
|
||||
),
|
||||
on_error=lambda error: self._video_ticket_error(
|
||||
call_key,
|
||||
marker,
|
||||
@@ -888,9 +888,9 @@ class ApplicationController(QObject):
|
||||
repository: Any,
|
||||
call_key: str,
|
||||
marker: object,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
@@ -916,13 +916,13 @@ class ApplicationController(QObject):
|
||||
local_dist=video_dist_path(),
|
||||
remote_url=self.config.video_web_url or None,
|
||||
logger=logging.getLogger("doctor_workstation.video"),
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||
self._open_video_diagnosis(current_id)
|
||||
),
|
||||
)
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||
self._open_video_diagnosis(current_id)
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
LOGGER.exception("video call could not be launched")
|
||||
show_toast(
|
||||
@@ -931,123 +931,123 @@ class ApplicationController(QObject):
|
||||
"danger",
|
||||
5600,
|
||||
)
|
||||
return
|
||||
self.video_calls[call_key] = call
|
||||
return
|
||||
self.video_calls[call_key] = call
|
||||
qt_window = getattr(call, "qt_window", None)
|
||||
if qt_window is not None:
|
||||
qt_window.destroyed.connect(
|
||||
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
|
||||
key,
|
||||
expected,
|
||||
)
|
||||
)
|
||||
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||
|
||||
shell = self.shell_window
|
||||
if shell is None or self.current_repository is None:
|
||||
return
|
||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||
if dialog is None:
|
||||
return
|
||||
call = self.video_calls.get(str(diagnosis_id))
|
||||
video_window = getattr(call, "qt_window", None)
|
||||
if video_window is not None:
|
||||
self._show_video_preview(video_window, dialog)
|
||||
|
||||
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
||||
"""Pin a compact call window above the modeless diagnosis drawer."""
|
||||
|
||||
current = self._video_preview_state
|
||||
if current is not None and current.get("window") is not video_window:
|
||||
self._restore_video_preview(activate=False)
|
||||
|
||||
self._video_preview_generation += 1
|
||||
generation = self._video_preview_generation
|
||||
if current is None or current.get("window") is not video_window:
|
||||
try:
|
||||
state = {
|
||||
"window": video_window,
|
||||
"geometry": video_window.geometry(),
|
||||
"minimum_size": video_window.minimumSize(),
|
||||
"maximized": video_window.isMaximized(),
|
||||
"full_screen": video_window.isFullScreen(),
|
||||
"stays_on_top": bool(
|
||||
video_window.windowFlags()
|
||||
& Qt.WindowType.WindowStaysOnTopHint
|
||||
),
|
||||
}
|
||||
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
||||
available = screen.availableGeometry()
|
||||
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||
margin = 18
|
||||
|
||||
video_window.showNormal()
|
||||
video_window.setMinimumSize(440, 300)
|
||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
video_window.resize(preview_width, preview_height)
|
||||
video_window.move(
|
||||
available.x() + available.width() - preview_width - margin,
|
||||
available.y() + margin,
|
||||
)
|
||||
video_window.show()
|
||||
self._video_preview_state = state
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
return
|
||||
|
||||
dialog.finished.connect(
|
||||
lambda _result, expected=generation: self._restore_video_preview(expected)
|
||||
)
|
||||
try:
|
||||
video_window.raise_()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
|
||||
def _restore_video_preview(
|
||||
self,
|
||||
generation: int | None = None,
|
||||
*,
|
||||
activate: bool = True,
|
||||
) -> None:
|
||||
if generation is not None and generation != self._video_preview_generation:
|
||||
return
|
||||
state = self._video_preview_state
|
||||
if state is None:
|
||||
return
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
window = state.get("window")
|
||||
try:
|
||||
window.setWindowFlag(
|
||||
Qt.WindowType.WindowStaysOnTopHint,
|
||||
bool(state.get("stays_on_top")),
|
||||
)
|
||||
window.setMinimumSize(state["minimum_size"])
|
||||
window.setGeometry(state["geometry"])
|
||||
if state.get("full_screen"):
|
||||
window.showFullScreen()
|
||||
elif state.get("maximized"):
|
||||
window.showMaximized()
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
if activate:
|
||||
window.activateWindow()
|
||||
except (AttributeError, RuntimeError):
|
||||
return
|
||||
|
||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||
preview = self._video_preview_state
|
||||
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
if self.video_calls.get(call_key) is call:
|
||||
self.video_calls.pop(call_key, None)
|
||||
)
|
||||
)
|
||||
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||
|
||||
shell = self.shell_window
|
||||
if shell is None or self.current_repository is None:
|
||||
return
|
||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||
if dialog is None:
|
||||
return
|
||||
call = self.video_calls.get(str(diagnosis_id))
|
||||
video_window = getattr(call, "qt_window", None)
|
||||
if video_window is not None:
|
||||
self._show_video_preview(video_window, dialog)
|
||||
|
||||
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
||||
"""Pin a compact call window above the modeless diagnosis drawer."""
|
||||
|
||||
current = self._video_preview_state
|
||||
if current is not None and current.get("window") is not video_window:
|
||||
self._restore_video_preview(activate=False)
|
||||
|
||||
self._video_preview_generation += 1
|
||||
generation = self._video_preview_generation
|
||||
if current is None or current.get("window") is not video_window:
|
||||
try:
|
||||
state = {
|
||||
"window": video_window,
|
||||
"geometry": video_window.geometry(),
|
||||
"minimum_size": video_window.minimumSize(),
|
||||
"maximized": video_window.isMaximized(),
|
||||
"full_screen": video_window.isFullScreen(),
|
||||
"stays_on_top": bool(
|
||||
video_window.windowFlags()
|
||||
& Qt.WindowType.WindowStaysOnTopHint
|
||||
),
|
||||
}
|
||||
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
||||
available = screen.availableGeometry()
|
||||
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||
margin = 18
|
||||
|
||||
video_window.showNormal()
|
||||
video_window.setMinimumSize(440, 300)
|
||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
video_window.resize(preview_width, preview_height)
|
||||
video_window.move(
|
||||
available.x() + available.width() - preview_width - margin,
|
||||
available.y() + margin,
|
||||
)
|
||||
video_window.show()
|
||||
self._video_preview_state = state
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
return
|
||||
|
||||
dialog.finished.connect(
|
||||
lambda _result, expected=generation: self._restore_video_preview(expected)
|
||||
)
|
||||
try:
|
||||
video_window.raise_()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
|
||||
def _restore_video_preview(
|
||||
self,
|
||||
generation: int | None = None,
|
||||
*,
|
||||
activate: bool = True,
|
||||
) -> None:
|
||||
if generation is not None and generation != self._video_preview_generation:
|
||||
return
|
||||
state = self._video_preview_state
|
||||
if state is None:
|
||||
return
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
window = state.get("window")
|
||||
try:
|
||||
window.setWindowFlag(
|
||||
Qt.WindowType.WindowStaysOnTopHint,
|
||||
bool(state.get("stays_on_top")),
|
||||
)
|
||||
window.setMinimumSize(state["minimum_size"])
|
||||
window.setGeometry(state["geometry"])
|
||||
if state.get("full_screen"):
|
||||
window.showFullScreen()
|
||||
elif state.get("maximized"):
|
||||
window.showMaximized()
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
if activate:
|
||||
window.activateWindow()
|
||||
except (AttributeError, RuntimeError):
|
||||
return
|
||||
|
||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||
preview = self._video_preview_state
|
||||
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
if self.video_calls.get(call_key) is call:
|
||||
self.video_calls.pop(call_key, None)
|
||||
|
||||
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
||||
if self.demo_video_dialogs.get(call_key) is dialog:
|
||||
@@ -1072,30 +1072,30 @@ class ApplicationController(QObject):
|
||||
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
|
||||
return complete
|
||||
|
||||
@staticmethod
|
||||
def _apply_window_icon(window: QWidget) -> None:
|
||||
icon_file = app_icon_path()
|
||||
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:
|
||||
@staticmethod
|
||||
def _apply_window_icon(window: QWidget) -> None:
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
window.setWindowIcon(QIcon(str(icon_file)))
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Queue a normal application exit so owned resources are released."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
QTimer.singleShot(0, self.application.quit)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Invalidate asynchronous restoration and release owned resources."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._shutting_down = True
|
||||
self._cancel_session_restore()
|
||||
self.app_updater.shutdown()
|
||||
set_authentication_expired_handler(None)
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
self._shutting_down = True
|
||||
self._cancel_session_restore()
|
||||
self.app_updater.shutdown()
|
||||
set_authentication_expired_handler(None)
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
for call in calls:
|
||||
with suppress(Exception):
|
||||
call.close()
|
||||
@@ -1167,7 +1167,7 @@ def _create_application(argv: list[str]) -> QApplication:
|
||||
application.setOrganizationName("ZhenYangTang")
|
||||
application.setOrganizationDomain("zhenyangtang.com")
|
||||
application.setQuitOnLastWindowClosed(True)
|
||||
icon_file = app_icon_path()
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
application.setWindowIcon(QIcon(str(icon_file)))
|
||||
apply_theme(application)
|
||||
|
||||
@@ -55,26 +55,25 @@ from .widgets import (
|
||||
APPOINTMENT_DRAWER_QSS = r"""
|
||||
QDialog#AppointmentDrawerOverlay {
|
||||
background-color: transparent;
|
||||
color: #111F46;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1A1C1F;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
|
||||
background-color: #FFFFFF;
|
||||
border-left: 1px solid #E6EAF5;
|
||||
border-left: 1px solid #EDEDEE;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
|
||||
background-color: #FFFFFF;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #E6EAF5;
|
||||
border-bottom: 1px solid #EDEDEE;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
||||
@@ -86,14 +85,14 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: #7886AA;
|
||||
color: #606163;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
|
||||
color: #4451E2;
|
||||
background-color: #F0F2FF;
|
||||
color: #1A1C1F;
|
||||
background-color: #F0F0F0;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
|
||||
@@ -109,13 +108,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
|
||||
color: #7886AA;
|
||||
color: #606163; font-size: 13px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox,
|
||||
@@ -123,28 +122,28 @@ QDialog#AppointmentDrawerOverlay QLineEdit,
|
||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
background-color: #FFFFFF;
|
||||
color: #111F46;
|
||||
selection-background-color: #5761F4;
|
||||
selection-color: #FFFFFF;
|
||||
color: #1A1C1F;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F; font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||||
padding: 7px 11px;
|
||||
padding: 7px 11px; font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox:hover,
|
||||
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
|
||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
|
||||
border-color: #5761F4;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox:focus,
|
||||
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
|
||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
|
||||
border: 2px solid #8D9BFF;
|
||||
border: 2px solid #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
||||
@@ -154,81 +153,81 @@ QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
|
||||
background-color: #FFFFFF;
|
||||
color: #111F46;
|
||||
border: 1px solid #E6EAF5;
|
||||
selection-background-color: #5761F4;
|
||||
selection-color: #FFFFFF;
|
||||
outline: 0;
|
||||
color: #1A1C1F;
|
||||
border: 1px solid #EDEDEE;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F;
|
||||
outline: 0; font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton {
|
||||
min-height: 24px;
|
||||
spacing: 8px;
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
|
||||
border-color: #5761F4;
|
||||
border-color: #4156C4;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border: 5px solid #5761F4;
|
||||
border: 5px solid #4F63D9;
|
||||
border-radius: 7px;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
|
||||
color: #4451E2;
|
||||
color: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
|
||||
min-height: 38px;
|
||||
max-height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 8px;
|
||||
background-color: #FFFFFF;
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
|
||||
color: #4451E2;
|
||||
border-color: #5761F4;
|
||||
background-color: #F0F2FF;
|
||||
color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
|
||||
border-color: #8D9BFF;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
|
||||
color: #FFFFFF;
|
||||
border-color: #5761F4;
|
||||
background-color: #5761F4;
|
||||
border-color: #4F63D9;
|
||||
background-color: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
|
||||
background-color: #F7F9FE;
|
||||
background-color: #F7F7F7;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
||||
@@ -238,87 +237,83 @@ QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: #4451E2;
|
||||
color: #4F63D9;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
|
||||
background-color: #F0F2FF;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
|
||||
min-width: 110px;
|
||||
min-height: 70px;
|
||||
padding: 0 8px;
|
||||
border: 2px solid #E6EAF5;
|
||||
border: 2px solid #EDEDEE;
|
||||
border-radius: 8px;
|
||||
background-color: #FFFFFF;
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus {
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background-color: #F4F4F5;
|
||||
color: #7886AA;
|
||||
font-size: 12px;
|
||||
background-color: #F7F7F7;
|
||||
color: #606163;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
|
||||
color: #17A77D;
|
||||
color: #287B65;
|
||||
background-color: #EAF9F3;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
|
||||
color: #4451E2;
|
||||
border-color: #5761F4;
|
||||
background-color: #F0F2FF;
|
||||
color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
|
||||
border-color: #8D9BFF;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
|
||||
color: #FFFFFF;
|
||||
border-color: #5761F4;
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #5761F4,
|
||||
stop:1 #7769F7
|
||||
);
|
||||
border-color: #4F63D9;
|
||||
background: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotTime,
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus {
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime[slotSelected="true"],
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus {
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||||
background-color: rgba(255, 255, 255, 46);
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
|
||||
color: #A4ADC3;
|
||||
border-color: #E6EAF5;
|
||||
background-color: #F0F2F8;
|
||||
color: #8E8F90;
|
||||
border-color: #EDEDEE;
|
||||
background-color: #F7F7F7;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotTime,
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
|
||||
color: #A4ADC3;
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime:disabled,
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||||
color: #8E8F90;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
|
||||
background-color: #F0F2F8;
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||||
background-color: #F7F7F7;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
||||
@@ -326,13 +321,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
|
||||
color: #7886AA;
|
||||
color: #606163;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
|
||||
background-color: #F0F4FF;
|
||||
border: 1px solid #DDE5FF;
|
||||
background-color: #EEF1FA;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
@@ -355,65 +350,65 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
|
||||
color: #4D69ED;
|
||||
color: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
|
||||
color: #D38625;
|
||||
color: #A9691D;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
|
||||
color: #F15B67;
|
||||
color: #BE4B58;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
|
||||
color: #17A77D;
|
||||
color: #287B65;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
|
||||
background-color: #FFFFFF;
|
||||
border: 0;
|
||||
border-top: 1px solid #E6EAF5;
|
||||
border-top: 1px solid #EDEDEE;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
|
||||
min-height: 30px;
|
||||
max-height: 30px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
background-color: #FFFFFF;
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
|
||||
color: #4451E2;
|
||||
border-color: #5761F4;
|
||||
background-color: #F0F2FF;
|
||||
color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
|
||||
border-color: #8D9BFF;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
|
||||
color: #FFFFFF;
|
||||
border-color: #5761F4;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5761F4, stop:1 #7769F7);
|
||||
border-color: #4F63D9;
|
||||
background: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
|
||||
color: #FFFFFF;
|
||||
border-color: #4C57E9;
|
||||
background-color: #4C57E9;
|
||||
border-color: #4156C4;
|
||||
background-color: #4156C4;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
|
||||
color: #FFFFFF;
|
||||
border-color: #E6EAF5;
|
||||
background-color: #A4ADC3;
|
||||
border-color: #EDEDEE;
|
||||
background-color: #8E8F90;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
||||
@@ -422,7 +417,7 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
|
||||
color: #7886AA;
|
||||
color: #606163;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -436,11 +431,11 @@ QDialog#AppointmentDrawerOverlay QScrollBar:vertical {
|
||||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
|
||||
min-height: 30px;
|
||||
border-radius: 3px;
|
||||
background-color: #E6EAF5;
|
||||
background-color: #EDEDEE;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
|
||||
background-color: #8D9BFF;
|
||||
background-color: #E4E4E5;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
|
||||
@@ -722,15 +717,15 @@ class _EmptyIllustration(QWidget):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EEF2F8"))
|
||||
painter.setBrush(QColor("#F7F7F7"))
|
||||
painter.drawEllipse(QRect(9, 48, 62, 8))
|
||||
|
||||
painter.setPen(QPen(QColor("#D8DEEA"), 1))
|
||||
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||||
painter.setBrush(QColor("#FFFFFF"))
|
||||
painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4)
|
||||
painter.setBrush(QColor("#E9EDFF"))
|
||||
painter.setBrush(QColor("#F0F0F0"))
|
||||
painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4)
|
||||
painter.setPen(QPen(QColor("#667085"), 2))
|
||||
painter.setPen(QPen(QColor("#606163"), 2))
|
||||
painter.drawLine(30, 35, 50, 35)
|
||||
painter.drawLine(34, 41, 46, 41)
|
||||
painter.end()
|
||||
@@ -751,7 +746,7 @@ class _HoverLiftButton(QPushButton):
|
||||
shadow = QGraphicsDropShadowEffect(self)
|
||||
shadow.setBlurRadius(12)
|
||||
shadow.setOffset(0, 4)
|
||||
shadow.setColor(QColor(102, 117, 245, 72))
|
||||
shadow.setColor(QColor(26, 28, 31, 72))
|
||||
self.setGraphicsEffect(shadow)
|
||||
self._lifted = True
|
||||
super().enterEvent(event)
|
||||
@@ -784,6 +779,15 @@ class _SlotCard(_HoverLiftButton):
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
layout.addWidget(self.status_label)
|
||||
self.toggled.connect(self._sync_label_selection)
|
||||
|
||||
def _sync_label_selection(self, checked: bool) -> None:
|
||||
# Qt does not reliably resolve ancestor pseudo states for child labels.
|
||||
for label in (self.time_label, self.status_label):
|
||||
label.setProperty("slotSelected", checked)
|
||||
label.style().unpolish(label)
|
||||
label.style().polish(label)
|
||||
label.update()
|
||||
|
||||
|
||||
class AppointmentDrawer(QDialog):
|
||||
@@ -1705,7 +1709,7 @@ class AppointmentDrawer(QDialog):
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), QColor(8, 11, 20, 196))
|
||||
painter.fillRect(self.rect(), QColor(26, 28, 31, 196))
|
||||
painter.end()
|
||||
super().paintEvent(event)
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Page-scoped palette and compact typography for the approved appointment list."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def appointments_stylesheet() -> str:
|
||||
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
_QSS = """
|
||||
#AppointmentsPage { background: #F3F7FD; color: #273244; }
|
||||
#AppointmentsPage QLabel, #AppointmentsPage QPushButton,
|
||||
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox,
|
||||
#AppointmentsPage QTabBar, #AppointmentsPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="muted"],
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumb"],
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
|
||||
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
|
||||
color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#AppointmentsPage QFrame#AppointmentFilterPanel,
|
||||
#AppointmentsPage QFrame#AppointmentMainCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#AppointmentsPage QPushButton {
|
||||
min-height: 30px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||
border-radius: 6px; background: #FFFFFF;
|
||||
}
|
||||
#AppointmentsPage QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
|
||||
#AppointmentsPage QPushButton:pressed { background: #DCEAFF; }
|
||||
#AppointmentsPage QPushButton:focus { border-color: #75A5F0; }
|
||||
#AppointmentsPage QPushButton[variant="primary"] {
|
||||
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||
}
|
||||
#AppointmentsPage QPushButton[variant="primary"]:hover { background: #155BCC; }
|
||||
#AppointmentsPage QPushButton[variant="primary"]:pressed { background: #124EA9; }
|
||||
#AppointmentsPage QPushButton[variant="danger"] {
|
||||
color: #B84652; background: #FFFFFF; border-color: #EFC8CE;
|
||||
}
|
||||
#AppointmentsPage QPushButton[variant="danger"]:hover { background: #FFF0F2; }
|
||||
#AppointmentsPage QPushButton[variant="ghost"] { background: transparent; border-color: transparent; }
|
||||
#AppointmentsPage QPushButton:disabled {
|
||||
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox {
|
||||
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
|
||||
}
|
||||
#AppointmentsPage QLineEdit:focus, #AppointmentsPage QComboBox:focus { border-color: #75A5F0; }
|
||||
#AppointmentsPage QLineEdit QToolButton {
|
||||
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
|
||||
}
|
||||
#AppointmentsPage QLineEdit QToolButton:focus { background: #EAF2FF; }
|
||||
#AppointmentsPage QPushButton#AppointmentSearchButton {
|
||||
min-height: 39px; max-height: 39px; min-width: 52px;
|
||||
}
|
||||
#AppointmentsPage QLineEdit#AppointmentPatientSearch { min-height: 39px; max-height: 39px; }
|
||||
#AppointmentsPage QPushButton[appointmentStat="true"] {
|
||||
min-height: 34px; max-height: 34px; padding: 0 10px; background: #F4F7FC;
|
||||
border-color: transparent; color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: #1555B6; background: #EAF2FF; }
|
||||
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
|
||||
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"]:!checked {
|
||||
color: #9C681F; background: #FFF6E7; border-color: #EEDCBF;
|
||||
}
|
||||
#AppointmentsPage QLabel#FilterRowLabel { color: #5D6B80; font-size: 13px; }
|
||||
#AppointmentsPage QLabel#FilterDivider { color: #DBE5F2; padding: 0 10px; }
|
||||
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
|
||||
min-width: 52px; min-height: 32px; padding: 0 12px; color: #5D6B80;
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: #1555B6; background: #F7FAFE; }
|
||||
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
|
||||
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
|
||||
}
|
||||
#AppointmentsPage QPushButton[filterChoice="true"] {
|
||||
min-height: 30px; max-height: 30px; padding: 0 12px; color: #5D6B80;
|
||||
background: transparent; border-color: transparent; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[filterChoice="true"]:checked { color: #1555B6; background: #EAF2FF; }
|
||||
#AppointmentsPage QFrame#AppointmentToolbar { background: transparent; border: 0; }
|
||||
#AppointmentsPage QPushButton[compactAction="true"] {
|
||||
min-height: 36px; max-height: 36px; padding: 0 17px; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:enabled {
|
||||
color: #1769E8; border-color: #ADC8F0;
|
||||
}
|
||||
#AppointmentsPage QTableWidget#AppointmentTable {
|
||||
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
|
||||
}
|
||||
#AppointmentsPage QTableWidget#AppointmentTable::item { padding: 0; border: 0; border-bottom: 1px solid #E6EDF6; }
|
||||
#AppointmentsPage QTableWidget#AppointmentTable::item:selected { background: #EAF2FF; color: #273244; }
|
||||
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
|
||||
min-height: 40px; padding: 0; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-top: 1px solid #E1E9F4; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#AppointmentsPage QWidget[appointmentSelectionHost="true"] {
|
||||
background: transparent; border-left: 3px solid transparent;
|
||||
}
|
||||
#AppointmentsPage QWidget[appointmentSelectionHost="true"][selected="true"] { border-left-color: #1769E8; }
|
||||
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
|
||||
width: 16px; height: 16px; background: #FFFFFF; border: 1px solid #C8D5E6; border-radius: 3px;
|
||||
}
|
||||
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||
#AppointmentsPage QWidget[appointmentInfoHost="true"],
|
||||
#AppointmentsPage QWidget[appointmentImHost="true"] { background: transparent; }
|
||||
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
|
||||
min-height: 20px; max-height: 20px; padding: 0 6px; color: #1555B6;
|
||||
background: #EAF2FF; border-radius: 4px; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: #9C681F; background: #FFF3DD; }
|
||||
#AppointmentsPage QLabel[tableAppointmentStatusKind="muted"] { color: #66758A; background: #EEF2F7; }
|
||||
#AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: #5D6B80; font-size: 13px; }
|
||||
#AppointmentsPage QPushButton[tableCancelAction="true"] {
|
||||
min-height: 20px; max-height: 20px; padding: 0 5px; color: #B84652;
|
||||
background: #FFF0F2; border: 0; border-radius: 4px; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentImAction="true"] {
|
||||
min-width: 62px; min-height: 32px; max-height: 32px; padding: 0 10px;
|
||||
color: #1555B6; background: #EAF2FF; border-color: #C9DCF7; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: #FFFFFF; background: #1769E8; }
|
||||
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: #8A97A9; background: #F3F6FB; border-color: #DFE7F2; }
|
||||
#AppointmentsPage QWidget#Pager { background: #FFFFFF; border: 0; border-top: 1px solid #E1E9F4; }
|
||||
#AppointmentsPage QWidget#Pager QLabel { color: #5D6B80; font-size: 13px; border: 0; }
|
||||
#AppointmentsPage QWidget#Pager QPushButton {
|
||||
min-width: 30px; max-width: 30px; min-height: 32px; max-height: 32px;
|
||||
padding: 0; border: 1px solid #DBE5F2; background: #FFFFFF; color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#AppointmentsPage QWidget#Pager QPushButton[active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||
#AppointmentsPage QWidget#Pager QPushButton:disabled { color: #9AA6B7; background: #F7F9FC; }
|
||||
"""
|
||||
@@ -46,51 +46,51 @@ QFrame#ChatNotifyCard {
|
||||
border: 1px solid #BBF0CE;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#ChatNotifyCard[kind="left"] { border-color: #D8DEEE; }
|
||||
QFrame#ChatNotifyCard[kind="complete"] { border-color: #C3D6FF; }
|
||||
QFrame#ChatNotifyCard[kind="left"] { border-color: #E4E4E5; }
|
||||
QFrame#ChatNotifyCard[kind="complete"] { border-color: #8B9AD9; }
|
||||
QLabel#ChatNotifyBadge {
|
||||
min-width: 34px;
|
||||
max-width: 34px;
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
color: #FFFFFF;
|
||||
background-color: #22C55E;
|
||||
background-color: #287B65;
|
||||
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; }
|
||||
QLabel#ChatNotifyBadge[kind="left"] { background-color: #6A6B6D; }
|
||||
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #4F63D9; }
|
||||
QLabel#ChatNotifyTitle { color: #1A1C1F; font-size: 13px; font-weight: 700; }
|
||||
QLabel#ChatNotifyDesc { color: #1A1C1F; font-size: 12px; }
|
||||
QLabel#ChatNotifyTime { color: #6A6B6D; font-size: 11px; }
|
||||
QPushButton#ChatNotifyOpen {
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
color: #3F4E75;
|
||||
background-color: #F4F6FC;
|
||||
border: 1px solid #DDE3F2;
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton#ChatNotifyOpen:hover {
|
||||
color: #4451E2;
|
||||
background-color: #EEF1FF;
|
||||
border-color: #8D9BFF;
|
||||
color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QPushButton#ChatNotifyClose {
|
||||
min-width: 22px;
|
||||
max-width: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
color: #8A94B3;
|
||||
color: #6A6B6D;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton#ChatNotifyClose:hover { color: #4A5878; background-color: #EDF0F7; }
|
||||
QPushButton#ChatNotifyClose:hover { color: #1A1C1F; background-color: #F7F7F7; }
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Scoped colors and compact typography for the approved consultation list."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def consultations_stylesheet() -> str:
|
||||
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
_QSS = """
|
||||
#DiagnosisIndex, #DiagnosisIndexContent, #DiagnosisPageScroll {
|
||||
background: #F3F7FD; color: #273244; border: 0;
|
||||
}
|
||||
#DiagnosisIndex QLabel, #DiagnosisIndex QPushButton, #DiagnosisIndex QToolButton,
|
||||
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit,
|
||||
#DiagnosisIndex QSpinBox, #DiagnosisIndex QTableView {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#DiagnosisIndex QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#DiagnosisIndex QLabel[role="muted"], #DiagnosisIndex QLabel[role="breadcrumb"],
|
||||
#DiagnosisIndex QLabel[role="breadcrumbCurrent"], #DiagnosisIndex QLabel[role="breadcrumbSeparator"],
|
||||
#DiagnosisIndex QLabel[filterGroup="true"], #DiagnosisIndex QLabel[pagerMuted="true"] {
|
||||
color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisFilterCard, #DiagnosisIndex QFrame#DiagnosisListCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#DiagnosisIndex QFrame#DiagnosisStatusCard, #DiagnosisIndex QFrame#DiagnosisQuickFilters,
|
||||
#DiagnosisIndex QFrame#DiagnosisListToolbar { background: transparent; border: 0; }
|
||||
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
|
||||
background: transparent; border: 0; border-top: 1px solid #E6EDF6; border-radius: 0;
|
||||
}
|
||||
#DiagnosisIndex QPushButton {
|
||||
min-height: 34px; padding: 0 13px; border: 1px solid #DBE5F2;
|
||||
border-radius: 6px; background: #FFFFFF;
|
||||
}
|
||||
#DiagnosisIndex QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
|
||||
#DiagnosisIndex QPushButton:pressed { background: #DCEAFF; }
|
||||
#DiagnosisIndex QPushButton:focus { border-color: #75A5F0; }
|
||||
#DiagnosisIndex QPushButton[variant="primary"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||
#DiagnosisIndex QPushButton[variant="primary"]:hover { background: #155BCC; }
|
||||
#DiagnosisIndex QPushButton[consultationTool="true"] { color: #1555B6; border-color: #C2D5EF; }
|
||||
#DiagnosisIndex QPushButton[consultationDanger="true"] { color: #BE4B58; border-color: #EBCDD2; }
|
||||
#DiagnosisIndex QPushButton:disabled, #DiagnosisIndex QPushButton[consultationDanger="true"]:disabled {
|
||||
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit, #DiagnosisIndex QSpinBox {
|
||||
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
|
||||
font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QLineEdit:focus, #DiagnosisIndex QComboBox:focus,
|
||||
#DiagnosisIndex QDateEdit:focus, #DiagnosisIndex QSpinBox:focus { border-color: #75A5F0; }
|
||||
#DiagnosisIndex QLineEdit QToolButton {
|
||||
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
|
||||
}
|
||||
#DiagnosisIndex QComboBox::drop-down, #DiagnosisIndex QDateEdit::drop-down {
|
||||
width: 22px; border: 0; background: transparent;
|
||||
}
|
||||
#DiagnosisIndex QComboBox QAbstractItemView {
|
||||
color: #273244; background: #FFFFFF; border: 1px solid #DBE5F2;
|
||||
selection-background-color: #EAF2FF; selection-color: #1555B6; outline: 0;
|
||||
}
|
||||
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit,
|
||||
#DiagnosisIndex QWidget#DiagnosisStatusSearch QPushButton { min-height: 39px; max-height: 39px; }
|
||||
#DiagnosisIndex QToolButton { min-width: 0; min-height: 0; border: 0; padding: 0; background: transparent; }
|
||||
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
|
||||
min-height: 32px; max-height: 32px; padding: 0 14px; border: 1px solid transparent;
|
||||
border-radius: 5px; color: #5D6B80; background: transparent; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { background: #F2F7FF; color: #1555B6; }
|
||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked { background: #1769E8; color: #FFFFFF; }
|
||||
#DiagnosisIndex QToolButton[dateChoice="true"] { padding: 0 17px; border-color: #E1E9F4; }
|
||||
#DiagnosisIndex QToolButton[dateChoice="true"]:checked { border-color: #1769E8; }
|
||||
#DiagnosisIndex QToolButton[statusTab="true"] {
|
||||
min-height: 44px; max-height: 44px; padding: 0 20px; border: 0;
|
||||
border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[statusTab="true"]:checked {
|
||||
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
|
||||
}
|
||||
#DiagnosisIndex QToolButton#DiagnosisMoreFilter {
|
||||
min-height: 32px; padding: 0 6px; color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QToolButton#DiagnosisMoreFilter:hover { color: #1769E8; background: #F2F7FF; }
|
||||
#DiagnosisIndex QFrame#DiagnosisFilterDivider { min-width: 1px; max-width: 1px; min-height: 20px; background: #E1E9F4; border: 0; }
|
||||
#DiagnosisIndex QFrame#DiagnosisDateRange {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px;
|
||||
}
|
||||
#DiagnosisIndex QDateEdit[diagnosisRangePart="true"] { min-height: 30px; padding: 0 4px; border: 0; }
|
||||
#DiagnosisIndex QTableView {
|
||||
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
|
||||
}
|
||||
#DiagnosisIndex QTableView QHeaderView::section {
|
||||
min-height: 41px; padding: 0; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[rowLink], #DiagnosisIndex QToolButton[appointmentCancel="true"] {
|
||||
color: #1769E8; min-height: 26px; padding: 0 4px; border: 0; background: transparent; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[appointmentCancel="true"] { color: #BE4B58; }
|
||||
#DiagnosisIndex QToolButton[rowLink]:hover { color: #1555B6; background: #DCEAFF; border-radius: 4px; }
|
||||
#DiagnosisIndex QToolButton[rowLink="muted"], #DiagnosisIndex QLabel[fixedMuted="true"] {
|
||||
color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#DiagnosisIndex QToolButton#DiagnosisRowMore { padding-right: 18px; }
|
||||
#DiagnosisIndex QWidget#DiagnosisFixedCell { background: transparent; }
|
||||
#DiagnosisIndex QLabel#DiagnosisTableEmpty { color: #5D6B80; background: #FFFFFF; }
|
||||
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { color: #BE4B58; }
|
||||
#DiagnosisIndex QTableView#DiagnosisFixedTable { border-left: 1px solid #E1E9F4; }
|
||||
#DiagnosisIndex QWidget#DiagnosisPager { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#DiagnosisIndex QToolButton[pagerButton="true"] {
|
||||
min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; color: #5D6B80; background: #FFFFFF;
|
||||
}
|
||||
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #A4B0C0; background: #F6F8FC; }
|
||||
#DiagnosisIndex QComboBox#DiagnosisPageSize { min-width: 95px; min-height: 30px; }
|
||||
#DiagnosisIndex QSpinBox#DiagnosisPageJumper { min-height: 30px; padding: 0 8px; }
|
||||
#DiagnosisIndex QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#DiagnosisIndex QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#DiagnosisIndex QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#DiagnosisIndex QScrollBar::add-line, #DiagnosisIndex QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#DiagnosisIndex QScrollBar::add-page, #DiagnosisIndex QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -134,63 +134,63 @@ def open_safe_http_url(target: str) -> bool:
|
||||
_INLINE_PLAYER_QSS = """
|
||||
QWidget#DiagnosisInlineRecordingPlayer {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QFrame#DiagnosisInlineRecordingSurface {
|
||||
background: #11182E;
|
||||
background: #1A1C1F;
|
||||
border: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
QLabel#DiagnosisInlineRecordingPlaceholder {
|
||||
color: #C7D0E8;
|
||||
color: #E4E4E5;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
QLabel#DiagnosisInlineRecordingTime {
|
||||
color: #64739A;
|
||||
color: #6A6B6D;
|
||||
font-size: 11px;
|
||||
}
|
||||
QPushButton[recordingControl="true"] {
|
||||
min-height: 24px;
|
||||
max-height: 24px;
|
||||
padding: 0 8px;
|
||||
color: #3F4E75;
|
||||
background: #FAFBFE;
|
||||
border: 1px solid #D8DEEE;
|
||||
color: #1A1C1F;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton[recordingControl="true"]:hover,
|
||||
QPushButton[recordingControl="true"]:focus {
|
||||
color: #4451E2;
|
||||
background: #F0F2FF;
|
||||
border-color: #8D9BFF;
|
||||
color: #4156C4;
|
||||
background: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QPushButton#DiagnosisInlineRecordingPlay {
|
||||
color: #FFFFFF;
|
||||
background: #5761F4;
|
||||
border-color: #5761F4;
|
||||
background: #4F63D9;
|
||||
border-color: #4F63D9;
|
||||
}
|
||||
QPushButton#DiagnosisInlineRecordingPlay:hover,
|
||||
QPushButton#DiagnosisInlineRecordingPlay:focus {
|
||||
color: #FFFFFF;
|
||||
background: #4C57E9;
|
||||
border-color: #4C57E9;
|
||||
background: #4156C4;
|
||||
border-color: #4156C4;
|
||||
}
|
||||
QPushButton[recordingControl="true"]:disabled {
|
||||
color: #A4ADC3;
|
||||
background: #F0F2F8;
|
||||
border-color: #E6EAF5;
|
||||
color: #8E8F90;
|
||||
background: #F7F7F7;
|
||||
border-color: #EDEDEE;
|
||||
}
|
||||
QSlider::groove:horizontal { height: 3px; background: #D8DEEE; border-radius: 1px; }
|
||||
QSlider::sub-page:horizontal { background: #5761F4; border-radius: 1px; }
|
||||
QSlider::groove:horizontal { height: 3px; background: #E4E4E5; border-radius: 1px; }
|
||||
QSlider::sub-page:horizontal { background: #4F63D9; border-radius: 1px; }
|
||||
QSlider::handle:horizontal {
|
||||
width: 10px;
|
||||
margin: -4px 0;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #5761F4;
|
||||
border: 1px solid #4F63D9;
|
||||
border-radius: 5px;
|
||||
}
|
||||
"""
|
||||
@@ -466,11 +466,11 @@ class RecordingPlaybackCell(QWidget):
|
||||
separator = QFrame()
|
||||
separator.setObjectName("DiagnosisRecordingAlternateSeparator")
|
||||
separator.setFrameShape(QFrame.Shape.HLine)
|
||||
separator.setStyleSheet("color:#E6EAF5;")
|
||||
separator.setStyleSheet("color:#EDEDEE;")
|
||||
layout.addWidget(separator)
|
||||
label = QLabel("备用地址")
|
||||
label.setObjectName("DiagnosisRecordingAlternateLabel")
|
||||
label.setStyleSheet("color:#7886AA; font-size:12px;")
|
||||
label.setStyleSheet("color:#606163; font-size:12px;")
|
||||
layout.addWidget(label)
|
||||
links = QHBoxLayout()
|
||||
links.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -490,7 +490,7 @@ class RecordingPlaybackCell(QWidget):
|
||||
layout.addLayout(links)
|
||||
self.link_status = QLabel("")
|
||||
self.link_status.setObjectName("DiagnosisRecordingLinkStatus")
|
||||
self.link_status.setStyleSheet("color:#D94856; font-size:11px;")
|
||||
self.link_status.setStyleSheet("color:#BE4B58; font-size:11px;")
|
||||
self.link_status.setWordWrap(True)
|
||||
self.link_status.hide()
|
||||
layout.addWidget(self.link_status)
|
||||
@@ -722,41 +722,41 @@ def image_display_name(target: str, ordinal: int) -> str:
|
||||
|
||||
_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; }
|
||||
QLabel#DiagnosisImagePreviewName { color: #1A1C1F; font-size: 14px; font-weight: 600; }
|
||||
QLabel#DiagnosisImagePreviewCounter { color: #6A6B6D; font-size: 12px; }
|
||||
QLabel#DiagnosisImagePreviewStatus { color: #6A6B6D; font-size: 12px; }
|
||||
QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #BE4B58; }
|
||||
QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #A9691D; }
|
||||
QScrollArea#DiagnosisImagePreviewViewport {
|
||||
background: #11182E;
|
||||
border: 1px solid #E6EAF5;
|
||||
background: #1A1C1F;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QLabel#DiagnosisImagePreviewCanvas {
|
||||
background: #11182E;
|
||||
color: #C7D0E8;
|
||||
background: #1A1C1F;
|
||||
color: #E4E4E5;
|
||||
font-size: 12px;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"] {
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
color: #3F4E75;
|
||||
background: #FAFBFE;
|
||||
border: 1px solid #D8DEEE;
|
||||
color: #1A1C1F;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"]:hover,
|
||||
QPushButton[imagePreviewControl="true"]:focus {
|
||||
color: #4451E2;
|
||||
background: #F0F2FF;
|
||||
border-color: #8D9BFF;
|
||||
color: #4156C4;
|
||||
background: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"]:disabled {
|
||||
color: #A4ADC3;
|
||||
background: #F0F2F8;
|
||||
border-color: #E6EAF5;
|
||||
color: #8E8F90;
|
||||
background: #F7F7F7;
|
||||
border-color: #EDEDEE;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,13 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..theme import mark_business_dialog
|
||||
from ..widgets import (
|
||||
BusyOverlay,
|
||||
EmptyState,
|
||||
MessageBanner,
|
||||
OverlayHost,
|
||||
Pager,
|
||||
SortableTable,
|
||||
TableColumn,
|
||||
first_value,
|
||||
@@ -33,7 +33,6 @@ from ..widgets import (
|
||||
get_value,
|
||||
invoke,
|
||||
page_items,
|
||||
page_total,
|
||||
run_async,
|
||||
)
|
||||
from .ai_consult import can_open_ai_consult, present_ai_consult
|
||||
@@ -220,8 +219,8 @@ class AiConsultTargetDialog(QDialog):
|
||||
self.body.busy_overlay = self.busy_overlay
|
||||
root.addWidget(self.body, 1)
|
||||
|
||||
self.pager = Pager(self.PAGE_SIZE, self)
|
||||
self.pager.page_changed.connect(self._change_page)
|
||||
self.pager = InfiniteList(self.PAGE_SIZE, self)
|
||||
self.pager.bind(self.table)
|
||||
root.addWidget(self.pager)
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
|
||||
@@ -266,42 +265,41 @@ class AiConsultTargetDialog(QDialog):
|
||||
self.load(1)
|
||||
|
||||
def retry(self) -> None:
|
||||
self.load(self._page)
|
||||
|
||||
def _change_page(self, page: int) -> None:
|
||||
self.load(page)
|
||||
self.load(1)
|
||||
|
||||
def load(self, page: int) -> None:
|
||||
if not self._active:
|
||||
return
|
||||
self._page = max(1, int(page))
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
page_snapshot = self._page
|
||||
keyword_snapshot = self.search_edit.text().strip()
|
||||
self._invalidate_selection()
|
||||
self._set_loading(True)
|
||||
if keyword_snapshot != getattr(self, "_loaded_keyword", None):
|
||||
self._invalidate_selection()
|
||||
self._loaded_keyword = keyword_snapshot
|
||||
self._set_loading(not self.pager.rows)
|
||||
self.banner.clear()
|
||||
|
||||
run_async(
|
||||
lambda: invoke(
|
||||
self.pager.reload(
|
||||
lambda requested_page: invoke(
|
||||
self.repository,
|
||||
"list_ai_patient_options",
|
||||
page_no=page_snapshot,
|
||||
page_no=requested_page,
|
||||
page_size=self.PAGE_SIZE,
|
||||
keyword=keyword_snapshot,
|
||||
),
|
||||
on_success=lambda result: self._apply_result(
|
||||
result, generation, page_snapshot, keyword_snapshot
|
||||
apply=lambda result: self._apply_result(
|
||||
result, generation, keyword_snapshot
|
||||
),
|
||||
on_error=lambda error: self._apply_error(error, generation),
|
||||
runner=run_async,
|
||||
query_key=(keyword_snapshot,),
|
||||
on_finished=lambda: self._finish_loading(generation),
|
||||
)
|
||||
|
||||
def _apply_result(
|
||||
self,
|
||||
result: Any,
|
||||
generation: int,
|
||||
page_snapshot: int,
|
||||
keyword_snapshot: str,
|
||||
) -> None:
|
||||
if not self._is_current(generation):
|
||||
@@ -316,36 +314,32 @@ class AiConsultTargetDialog(QDialog):
|
||||
for target in (AiConsultTarget.from_row(row) for row in page_items(result))
|
||||
if target is not None
|
||||
]
|
||||
total = max(0, page_total(result, len(targets)))
|
||||
page_count = max(1, (total + self.PAGE_SIZE - 1) // self.PAGE_SIZE)
|
||||
if page_snapshot > page_count:
|
||||
self.load(page_count)
|
||||
return
|
||||
|
||||
self._page = page_snapshot
|
||||
self.table.set_rows(targets)
|
||||
self.table.setSortingEnabled(False)
|
||||
self.table.clearSelection()
|
||||
self.pager.update_state(page_snapshot, total)
|
||||
self.empty_state.setVisible(not targets)
|
||||
self.table.setVisible(bool(targets))
|
||||
self._page = self.pager.page
|
||||
self.empty_state.setVisible(not targets and not self.pager.has_more)
|
||||
self.table.setVisible(bool(targets) or self.pager.has_more)
|
||||
self.banner.clear()
|
||||
self._set_loading(False)
|
||||
|
||||
def _apply_error(self, error: Exception, generation: int) -> None:
|
||||
if not self._is_current(generation):
|
||||
return
|
||||
self.table.set_rows(())
|
||||
self.table.setSortingEnabled(False)
|
||||
self.table.clearSelection()
|
||||
self.table.hide()
|
||||
self.empty_state.show()
|
||||
self.pager.update_state(1, 0)
|
||||
if not self.pager.rows:
|
||||
self.table.set_rows(())
|
||||
self.table.setSortingEnabled(False)
|
||||
self.table.clearSelection()
|
||||
self.table.hide()
|
||||
self.empty_state.show()
|
||||
self.banner.show_message(
|
||||
f"患者诊单加载失败:{friendly_error(error)}", "danger"
|
||||
)
|
||||
self._set_loading(False)
|
||||
|
||||
def _finish_loading(self, generation: int) -> None:
|
||||
if self._is_current(generation):
|
||||
self._set_loading(False)
|
||||
|
||||
def _is_current(self, generation: int) -> bool:
|
||||
return self._active and generation == self._generation
|
||||
|
||||
@@ -365,7 +359,6 @@ class AiConsultTargetDialog(QDialog):
|
||||
def _set_loading(self, loading: bool) -> None:
|
||||
self._loading = loading
|
||||
self.table.setEnabled(not loading)
|
||||
self.pager.setEnabled(not loading)
|
||||
self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0)
|
||||
self.busy_overlay.setVisible(loading)
|
||||
if loading:
|
||||
@@ -383,6 +376,7 @@ class AiConsultTargetDialog(QDialog):
|
||||
def done(self, result: int) -> None:
|
||||
self._active = False
|
||||
self._generation += 1
|
||||
self.pager.invalidate()
|
||||
self._search_timer.stop()
|
||||
super().done(result)
|
||||
|
||||
|
||||
@@ -130,9 +130,9 @@ class AppUpdateDialog(QDialog):
|
||||
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
|
||||
self.badge.setObjectName("UpdateBadge")
|
||||
self.badge.setStyleSheet(
|
||||
"color:#B45309;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||
"color:#A9691D;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||
if offer.force
|
||||
else "color:#4451E2;background:#F0F2FF;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||
else "color:#4F63D9;background:#EEF1FA;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||
)
|
||||
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
@@ -146,7 +146,7 @@ class AppUpdateDialog(QDialog):
|
||||
latest = offer.latest_version or "新版本"
|
||||
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
|
||||
self.version_label.setObjectName("UpdateVersionLabel")
|
||||
self.version_label.setStyleSheet("color:#7886AA;")
|
||||
self.version_label.setStyleSheet("color:#606163;")
|
||||
root.addWidget(self.version_label)
|
||||
|
||||
self.notes = QTextEdit()
|
||||
@@ -160,7 +160,7 @@ class AppUpdateDialog(QDialog):
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setObjectName("UpdateStatus")
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
||||
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||
self.status_label.hide()
|
||||
root.addWidget(self.status_label)
|
||||
|
||||
@@ -175,7 +175,7 @@ class AppUpdateDialog(QDialog):
|
||||
|
||||
self.progress_text = QLabel("")
|
||||
self.progress_text.setObjectName("UpdateProgressText")
|
||||
self.progress_text.setStyleSheet("color:#7886AA;font-size:12px;")
|
||||
self.progress_text.setStyleSheet("color:#606163;font-size:12px;")
|
||||
self.progress_text.hide()
|
||||
root.addWidget(self.progress_text)
|
||||
|
||||
@@ -225,7 +225,7 @@ class AppUpdateDialog(QDialog):
|
||||
def show_download_progress(self, received: int, total: int) -> None:
|
||||
self.progress.show()
|
||||
self.progress_text.show()
|
||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
||||
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||
self.status_label.setText("正在下载安装包…")
|
||||
self.status_label.show()
|
||||
if total > 0:
|
||||
@@ -237,7 +237,7 @@ class AppUpdateDialog(QDialog):
|
||||
self.progress_text.setText(_format_bytes(received))
|
||||
|
||||
def show_status(self, message: str, *, determinate: bool = False) -> None:
|
||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
||||
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||
self.status_label.setText(message)
|
||||
self.status_label.show()
|
||||
self.progress.show()
|
||||
@@ -251,7 +251,7 @@ class AppUpdateDialog(QDialog):
|
||||
def show_error(self, message: str) -> None:
|
||||
self._busy = False
|
||||
self.status_label.setText(message)
|
||||
self.status_label.setStyleSheet("color:#F15B67;")
|
||||
self.status_label.setStyleSheet("color:#BE4B58;")
|
||||
self.status_label.show()
|
||||
self.progress.hide()
|
||||
self.progress_text.hide()
|
||||
|
||||
@@ -60,6 +60,7 @@ from ..diagnosis_drawer import (
|
||||
)
|
||||
from ..diagnosis_editors import DailyRecordEditorDialog
|
||||
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..widgets import (
|
||||
display_text,
|
||||
first_value,
|
||||
@@ -190,74 +191,74 @@ _ORDER_OFFSET_HELP = (
|
||||
|
||||
_ORDER_DETAIL_QSS = """
|
||||
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
|
||||
QFrame#DiagnosisOrderDetailScrim { background: rgba(30, 64, 175, 0.18); border: 0; }
|
||||
QFrame#DiagnosisOrderDetailScrim { background: rgba(26, 28, 31, 0.18); border: 0; }
|
||||
QFrame#DiagnosisOrderDetailDrawer {
|
||||
background: #F7F9FE;
|
||||
background: #F7F7F7;
|
||||
border: 0;
|
||||
border-left: 1px solid #DDE7FF;
|
||||
border-left: 1px solid #F0F0F0;
|
||||
}
|
||||
QFrame#DiagnosisOrderDetailHeader {
|
||||
background: #FFFFFF;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #DDE7FF;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
}
|
||||
QLabel#DiagnosisOrderDetailTitle { color: #15224A; font-size: 19px; font-weight: 650; }
|
||||
QLabel#DiagnosisOrderDetailMeta { color: #7481A3; font-size: 12px; }
|
||||
QLabel#DiagnosisOrderDetailTitle { color: #1A1C1F; font-size: 19px; font-weight: 650; }
|
||||
QLabel#DiagnosisOrderDetailMeta { color: #606163; font-size: 12px; }
|
||||
QLabel#DiagnosisOrderReadonlyBadge {
|
||||
color: #3F4E75;
|
||||
background: #F7F9FE;
|
||||
border: 1px solid #E2E7F4;
|
||||
color: #1A1C1F;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F9FE; }
|
||||
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F9FE; }
|
||||
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F7F7; }
|
||||
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F7F7; }
|
||||
QFrame[orderAmountCard="true"] {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #DDE7FF;
|
||||
border: 1px solid #F0F0F0;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QLabel[orderAmountTitle="true"] { color: #7481A3; font-size: 11px; font-weight: 550; }
|
||||
QLabel[orderAmountTitle="true"] { color: #606163; font-size: 11px; font-weight: 550; }
|
||||
QLabel[orderAmountValue="true"] {
|
||||
color: #15224A;
|
||||
color: #1A1C1F;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel[orderAmountTone="danger"] { color: #C43E55; }
|
||||
QLabel[orderAmountTone="success"] { color: #16876C; }
|
||||
QLabel[orderAmountTone="warning"] { color: #9A6813; }
|
||||
QLabel[orderAmountTone="danger"] { color: #BE4B58; }
|
||||
QLabel[orderAmountTone="success"] { color: #287B65; }
|
||||
QLabel[orderAmountTone="warning"] { color: #A9691D; }
|
||||
QFrame[orderDetailSection="true"] {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E2E7F4;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QLabel[orderSectionTitle="true"] { color: #15224A; font-size: 15px; font-weight: 650; }
|
||||
QLabel[orderSectionHint="true"] { color: #7481A3; font-size: 11px; }
|
||||
QLabel[orderSectionTitle="true"] { color: #1A1C1F; font-size: 15px; font-weight: 650; }
|
||||
QLabel[orderSectionHint="true"] { color: #606163; font-size: 11px; }
|
||||
QFrame[orderField="true"] {
|
||||
background: #F2F6FE;
|
||||
border: 1px solid #E2E7F4;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 7px;
|
||||
}
|
||||
QLabel[orderFieldLabel="true"] { color: #7481A3; font-size: 11px; }
|
||||
QLabel[orderFieldValue="true"] { color: #15224A; font-size: 13px; }
|
||||
QLabel[orderFieldLabel="true"] { color: #6A6B6D; font-size: 11px; }
|
||||
QLabel[orderFieldValue="true"] { color: #1A1C1F; font-size: 13px; }
|
||||
QLabel[orderEmptyState="true"] {
|
||||
color: #7481A3;
|
||||
background: #F2F6FE;
|
||||
border: 1px dashed #C9D8F2;
|
||||
color: #606163;
|
||||
background: #F7F7F7;
|
||||
border: 1px dashed #E4E4E5;
|
||||
border-radius: 5px;
|
||||
padding: 18px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #93B4F4; }
|
||||
QLabel#DiagnosisOrderTimelineTime { color: #7481A3; font-size: 11px; }
|
||||
QLabel#DiagnosisOrderTimelineTitle { color: #15224A; font-size: 12px; font-weight: 600; }
|
||||
QLabel#DiagnosisOrderTimelineBody { color: #7481A3; font-size: 12px; }
|
||||
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #8B9AD9; }
|
||||
QLabel#DiagnosisOrderTimelineTime { color: #606163; font-size: 11px; }
|
||||
QLabel#DiagnosisOrderTimelineTitle { color: #1A1C1F; font-size: 12px; font-weight: 600; }
|
||||
QLabel#DiagnosisOrderTimelineBody { color: #606163; font-size: 12px; }
|
||||
QFrame#DiagnosisOrderDetailFooter {
|
||||
background: #FFFFFF;
|
||||
border: 0;
|
||||
border-top: 1px solid #DDE7FF;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -1003,8 +1004,8 @@ class DiagnosisDialog(QDialog):
|
||||
self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.readonly_back_button.setStyleSheet(
|
||||
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
|
||||
"color:#5265F6;font-size:13px;font-weight:500;}"
|
||||
"QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}"
|
||||
"color:#1A1C1F;font-size:13px;font-weight:500;}"
|
||||
"QPushButton:hover,QPushButton:focus{background:#EEF1FA;border-radius:6px;}"
|
||||
)
|
||||
self.readonly_back_button.clicked.connect(self.reject)
|
||||
left_layout.addWidget(self.readonly_back_button)
|
||||
@@ -1532,20 +1533,11 @@ class DiagnosisDialog(QDialog):
|
||||
layout.addWidget(toolbar)
|
||||
self.orders_table = self._new_table("orders", "DiagnosisTableOrders")
|
||||
layout.addWidget(self.orders_table, 1)
|
||||
footer = QHBoxLayout()
|
||||
self.orders_summary = QLabel("共 0 条")
|
||||
self.orders_summary.setObjectName("DiagnosisOrdersSummary")
|
||||
footer.addWidget(self.orders_summary)
|
||||
footer.addStretch(1)
|
||||
self.orders_previous = QPushButton("上一页")
|
||||
self.orders_previous.clicked.connect(lambda: self._change_orders_page(-1))
|
||||
footer.addWidget(self.orders_previous)
|
||||
self.orders_page_label = QLabel("1 / 1")
|
||||
footer.addWidget(self.orders_page_label)
|
||||
self.orders_next = QPushButton("下一页")
|
||||
self.orders_next.clicked.connect(lambda: self._change_orders_page(1))
|
||||
footer.addWidget(self.orders_next)
|
||||
layout.addLayout(footer)
|
||||
self.orders_list = InfiniteList(self._orders_page_size, page)
|
||||
for table in self._table_registry["orders"]:
|
||||
self.orders_list.bind(table)
|
||||
self._orders_footer_layout = layout
|
||||
layout.addWidget(self.orders_list)
|
||||
return page
|
||||
|
||||
def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea:
|
||||
@@ -2021,6 +2013,13 @@ class DiagnosisDialog(QDialog):
|
||||
self._daily_todo_status = None
|
||||
self._orders_page = 1
|
||||
self._orders_total = 0
|
||||
self.orders_list.reset()
|
||||
footer_layout = (
|
||||
self._readonly_sections["orders"].layout()
|
||||
if self._standalone_readonly
|
||||
else self._orders_footer_layout
|
||||
)
|
||||
footer_layout.addWidget(self.orders_list)
|
||||
self._detail = authoritative_detail if authoritative_detail is not None else seed
|
||||
self.save_button.set_state("idle")
|
||||
self.refresh_permissions()
|
||||
@@ -2315,6 +2314,7 @@ class DiagnosisDialog(QDialog):
|
||||
self._generation += 1
|
||||
self._save_generation += 1
|
||||
self._orders_generation += 1
|
||||
self.orders_list.invalidate()
|
||||
self._order_detail_generation += 1
|
||||
self._daily_mutation_generation += 1
|
||||
self._notes_mutation_generation += 1
|
||||
@@ -2639,6 +2639,9 @@ class DiagnosisDialog(QDialog):
|
||||
return
|
||||
if not force and (key in self._loaded_tabs or key in self._loading_tabs):
|
||||
return
|
||||
if key == "orders":
|
||||
self._load_orders()
|
||||
return
|
||||
self._tab_generations[key] += 1
|
||||
generation = self._tab_generations[key]
|
||||
diagnosis_id = self._diagnosis_id
|
||||
@@ -2732,10 +2735,9 @@ class DiagnosisDialog(QDialog):
|
||||
self._fill_prescriptions(page_items(result))
|
||||
elif key == "orders":
|
||||
rows = page_items(result)
|
||||
self._orders_page = 1
|
||||
self._orders_page = self.orders_list.page
|
||||
self._orders_total = page_total(result, len(rows))
|
||||
self._fill_orders(rows)
|
||||
self._update_orders_pager()
|
||||
elif key == "assign":
|
||||
self._fill_assignments(page_items(result))
|
||||
elif key == "appointment":
|
||||
@@ -3448,8 +3450,7 @@ class DiagnosisDialog(QDialog):
|
||||
panel.set_unavailable("切换到聊天记录后加载归档数据。")
|
||||
for panel in self._daily_panels:
|
||||
panel.clear()
|
||||
self.orders_summary.setText("共 0 条")
|
||||
self.orders_page_label.setText("1 / 1")
|
||||
self.orders_list.update_state(1, 0)
|
||||
|
||||
@staticmethod
|
||||
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
|
||||
@@ -4067,6 +4068,9 @@ class DiagnosisDialog(QDialog):
|
||||
)
|
||||
for row_index, row in enumerate(rows):
|
||||
order_id = _int(first_value(row, "id", "order_id"), 0)
|
||||
item = table.item(row_index, 0)
|
||||
if item is not None:
|
||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||
if (
|
||||
self._can_order_detail
|
||||
and order_id > 0
|
||||
@@ -4738,52 +4742,44 @@ class DiagnosisDialog(QDialog):
|
||||
if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id:
|
||||
self._show_message(friendly_error(error), "danger")
|
||||
|
||||
def _update_orders_pager(self) -> None:
|
||||
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
|
||||
self.orders_summary.setText(f"共 {self._orders_total} 条")
|
||||
self.orders_page_label.setText(f"{self._orders_page} / {pages}")
|
||||
self.orders_previous.setEnabled(self._orders_page > 1)
|
||||
self.orders_next.setEnabled(self._orders_page < pages)
|
||||
|
||||
def _change_orders_page(self, offset: int) -> None:
|
||||
def _load_orders(self) -> None:
|
||||
if not self._can_patient_orders or self._diagnosis_id <= 0:
|
||||
return
|
||||
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
|
||||
target_page = self._orders_page + offset
|
||||
if target_page < 1 or target_page > pages:
|
||||
return
|
||||
self._orders_generation += 1
|
||||
generation = self._orders_generation
|
||||
self._tab_generations["orders"] += 1
|
||||
generation = self._tab_generations["orders"]
|
||||
diagnosis_id = self._diagnosis_id
|
||||
patient_id = self._patient_id
|
||||
self._show_message("正在加载患者订单…", "info")
|
||||
run_async(
|
||||
lambda: self._query_orders(diagnosis_id, patient_id, target_page),
|
||||
on_success=lambda result: self._apply_orders_page(
|
||||
result, diagnosis_id, target_page, generation
|
||||
self._loading_tabs.add("orders")
|
||||
if not self.orders_list.rows:
|
||||
self._set_tab_loading("orders")
|
||||
self.orders_list.reload(
|
||||
lambda page: self._query_orders(diagnosis_id, patient_id, page),
|
||||
apply=lambda result: self._apply_orders_result(
|
||||
result, diagnosis_id, generation
|
||||
),
|
||||
on_error=lambda error: self._orders_error(error, diagnosis_id, generation),
|
||||
on_error=lambda error: self._tab_load_error(
|
||||
"orders", error, diagnosis_id, generation
|
||||
),
|
||||
runner=run_async,
|
||||
query_key=(diagnosis_id, patient_id),
|
||||
)
|
||||
|
||||
def _apply_orders_page(
|
||||
def _apply_orders_result(
|
||||
self,
|
||||
result: Any,
|
||||
diagnosis_id: int,
|
||||
page: int,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != self._orders_generation or diagnosis_id != self._diagnosis_id:
|
||||
if (
|
||||
generation != self._tab_generations["orders"]
|
||||
or diagnosis_id != self._diagnosis_id
|
||||
or not self._authoritative_detail_loaded
|
||||
):
|
||||
return
|
||||
rows = page_items(result)
|
||||
self._orders_page = page
|
||||
self._orders_total = page_total(result, len(rows))
|
||||
self._fill_orders(rows)
|
||||
self._update_orders_pager()
|
||||
self._clear_message()
|
||||
|
||||
def _orders_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
|
||||
if generation == self._orders_generation and diagnosis_id == self._diagnosis_id:
|
||||
self._show_message(friendly_error(error), "danger")
|
||||
if self.orders_list.page == 0:
|
||||
self._fill_orders([])
|
||||
return
|
||||
self._apply_tab_result("orders", result, diagnosis_id, generation)
|
||||
|
||||
def _save(self) -> None:
|
||||
if (
|
||||
|
||||
@@ -40,76 +40,76 @@ _STATUS_LABELS = {
|
||||
"invalid": "无效录音",
|
||||
}
|
||||
_STATUS_COLORS = {
|
||||
"recording": "#5364F5",
|
||||
"pending": "#B26A00",
|
||||
"uploading": "#2F6FEB",
|
||||
"uploaded": "#07966B",
|
||||
"failed": "#DC4054",
|
||||
"invalid": "#7886AA",
|
||||
"recording": "#1A1C1F",
|
||||
"pending": "#A9691D",
|
||||
"uploading": "#1A1C1F",
|
||||
"uploaded": "#287B65",
|
||||
"failed": "#BE4B58",
|
||||
"invalid": "#606163",
|
||||
}
|
||||
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
|
||||
|
||||
_LOCAL_AUDIO_QSS = """
|
||||
QDialog#LocalAudioQueueDialog {
|
||||
background: #F6F8FD;
|
||||
color: #111F46;
|
||||
background: #F7F7F7;
|
||||
color: #1A1C1F;
|
||||
}
|
||||
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
|
||||
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E2E7F4;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 14px;
|
||||
}
|
||||
QLabel#LocalAudioQueueTitle {
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
|
||||
color: #6E7C9F;
|
||||
color: #6A6B6D;
|
||||
font-size: 13px;
|
||||
}
|
||||
QLabel[queueSummary="true"] {
|
||||
background: #F3F5FB;
|
||||
border: 1px solid #E6EAF5;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
QPushButton {
|
||||
min-height: 34px;
|
||||
border: 1px solid #D9E0F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
background: #FFFFFF;
|
||||
color: #354365;
|
||||
color: #1A1C1F;
|
||||
padding: 0 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; }
|
||||
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; }
|
||||
QPushButton:hover { background: #EEF1FA; border-color: #8B9AD9; }
|
||||
QPushButton:disabled { color: #8E8F90; background: #F7F7F7; }
|
||||
QPushButton[variant="primary"] {
|
||||
color: #FFFFFF;
|
||||
background: #5661F4;
|
||||
border-color: #5661F4;
|
||||
background: #4F63D9;
|
||||
border-color: #4F63D9;
|
||||
}
|
||||
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; }
|
||||
QPushButton[variant="danger"] { color: #BE4B58; background: #FFF6F7; }
|
||||
QTableWidget#LocalAudioQueueTable {
|
||||
background: #FFFFFF;
|
||||
alternate-background-color: #FAFBFE;
|
||||
alternate-background-color: #F7F7F7;
|
||||
border: 0;
|
||||
gridline-color: #E8ECF5;
|
||||
color: #263452;
|
||||
selection-background-color: #EEF1FF;
|
||||
selection-color: #111F46;
|
||||
gridline-color: #EDEDEE;
|
||||
color: #1A1C1F;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F;
|
||||
}
|
||||
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
|
||||
QHeaderView::section {
|
||||
background: #F5F7FC;
|
||||
color: #53617F;
|
||||
background: #F7F7F7;
|
||||
color: #6A6B6D;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #E1E6F1;
|
||||
border-bottom: 1px solid #EDEDEE;
|
||||
padding: 10px 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -371,7 +371,7 @@ class LocalAudioQueueDialog(QDialog):
|
||||
self._status_column,
|
||||
_STATUS_LABELS.get(record.status, record.status),
|
||||
)
|
||||
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#53617F")))
|
||||
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#606163")))
|
||||
status_item.setToolTip(
|
||||
f"已尝试 {record.attempts} 次"
|
||||
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -143,75 +143,75 @@ def diagnosis_ai_task(prompt: str) -> str:
|
||||
|
||||
PRESCRIPTION_AI_QSS = """
|
||||
QDialog#PrescriptionAiDialog {
|
||||
color: #17203F;
|
||||
background-color: #F7F9FE;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
color: #1A1C1F;
|
||||
background-color: #F7F7F7;
|
||||
|
||||
font-size: 13px;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton {
|
||||
min-height: 34px;
|
||||
padding: 0 16px;
|
||||
color: #4F5B75;
|
||||
color: #1A1C1F;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE3F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 7px;
|
||||
font-weight: 500;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton:hover {
|
||||
color: #4D57D8;
|
||||
background-color: #F0F2FF;
|
||||
border-color: #D8DCFF;
|
||||
color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton:pressed {
|
||||
color: #FFFFFF;
|
||||
background-color: #4D57D8;
|
||||
border-color: #4D57D8;
|
||||
background-color: #354BB4;
|
||||
border-color: #354BB4;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton[variant="primary"] {
|
||||
color: #FFFFFF;
|
||||
background-color: #5761F4;
|
||||
border-color: #5761F4;
|
||||
background-color: #4F63D9;
|
||||
border-color: #4F63D9;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton[variant="primary"]:hover {
|
||||
background-color: #6871F6;
|
||||
border-color: #6871F6;
|
||||
background-color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton[variant="link"] {
|
||||
color: #4D57D8;
|
||||
color: #4F63D9;
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton[variant="link"]:hover {
|
||||
background-color: #F0F2FF;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
QLabel#PrescriptionAiTitle {
|
||||
color: #17203F;
|
||||
color: #1A1C1F;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#PrescriptionAiSubtitle {
|
||||
color: #78849D;
|
||||
color: #606163;
|
||||
font-size: 13px;
|
||||
}
|
||||
QFrame#PrescriptionAiSnapshot {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE3F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QLabel#PrescriptionAiSnapshotLabel {
|
||||
color: #4D57D8;
|
||||
color: #1A1C1F;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QLabel#PrescriptionAiSnapshotBody {
|
||||
color: #26304F;
|
||||
color: #1A1C1F;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
QFrame#PrescriptionAiSummary {
|
||||
background-color: #F0F2FF;
|
||||
background-color: #F0F0F0;
|
||||
border: 0;
|
||||
border-left: 3px solid #5761F4;
|
||||
border-left: 3px solid #4F63D9;
|
||||
border-radius: 0 9px 9px 0;
|
||||
}
|
||||
QFrame#PrescriptionAiCaution {
|
||||
@@ -220,56 +220,56 @@ QFrame#PrescriptionAiCaution {
|
||||
border-radius: 9px;
|
||||
}
|
||||
QLabel#PrescriptionAiSectionTitle {
|
||||
color: #17203F;
|
||||
color: #1A1C1F;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QLabel#PrescriptionAiBody {
|
||||
color: #37415E;
|
||||
color: #1A1C1F;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
QTextBrowser#PrescriptionAiAnswer {
|
||||
color: #34436B;
|
||||
color: #1A1C1F;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E3E8F4;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
selection-color: #17203F;
|
||||
selection-background-color: #DDE2FF;
|
||||
selection-color: #1A1C1F;
|
||||
selection-background-color: #EEF1FA;
|
||||
}
|
||||
QLabel#PrescriptionAiMuted {
|
||||
color: #8A94AA;
|
||||
color: #6A6B6D;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
QFrame#PrescriptionAiMeta {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #E3E8F4;
|
||||
border-bottom: 1px solid #EDEDEE;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTabWidget::pane {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE3F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTabBar::tab {
|
||||
min-height: 36px;
|
||||
padding: 0 18px;
|
||||
color: #78849D;
|
||||
color: #606163;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTabBar::tab:hover {
|
||||
color: #4D57D8;
|
||||
background-color: #F5F7FC;
|
||||
color: #4156C4;
|
||||
background-color: #F7F7F7;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTabBar::tab:selected {
|
||||
color: #4D57D8;
|
||||
background-color: #F0F2FF;
|
||||
border-bottom: 2px solid #5761F4;
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
border-bottom: 2px solid #4F63D9;
|
||||
font-weight: 600;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QScrollArea,
|
||||
@@ -278,15 +278,15 @@ QDialog#PrescriptionAiDialog QScrollArea > QWidget > QWidget {
|
||||
border: 0;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTextEdit {
|
||||
color: #17203F;
|
||||
color: #1A1C1F;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE3F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
selection-background-color: #E3E6FF;
|
||||
selection-background-color: #EEF1FA;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTextEdit:focus {
|
||||
border: 1px solid #5761F4;
|
||||
border: 1px solid #8B9AD9;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
||||
width: 10px;
|
||||
@@ -295,7 +295,7 @@ QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QScrollBar::handle:vertical {
|
||||
min-height: 32px;
|
||||
background-color: #C8D0E0;
|
||||
background-color: #D2D2D3;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
|
||||
@@ -305,20 +305,20 @@ QDialog#PrescriptionAiDialog QScrollBar::sub-line:vertical {
|
||||
"""
|
||||
|
||||
_AI_ANSWER_DOCUMENT_CSS = (
|
||||
"body { color:#34436B; font-size:14px; line-height:1.72; } "
|
||||
"h1 { color:#15224A; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
|
||||
"h2 { color:#15224A; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
|
||||
"h3,h4 { color:#15224A; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
||||
"body { color:#1A1C1F; font-size:14px; line-height:1.72; } "
|
||||
"h1 { color:#1A1C1F; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
|
||||
"h2 { color:#1A1C1F; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
|
||||
"h3,h4 { color:#1A1C1F; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
||||
"p { margin:6px 0; line-height:1.72; } "
|
||||
"ul,ol { margin:7px 0 8px 22px; } li { margin:4px 0; line-height:1.65; } "
|
||||
"strong { color:#15224A; font-weight:700; } "
|
||||
"blockquote { color:#596788; background:#F5F7FC; border-left:3px solid #7B84F7; "
|
||||
"strong { color:#1A1C1F; font-weight:700; } "
|
||||
"blockquote { color:#606163; background:#F7F7F7; border-left:3px solid #8B9AD9; "
|
||||
"margin:9px 0; padding:7px 10px; } "
|
||||
"code { color:#33406B; background:#EEF1FF; } "
|
||||
"pre { color:#33406B; background:#F0F3FA; margin:8px 0; padding:9px; } "
|
||||
"code { color:#1A1C1F; background:#F0F0F0; } "
|
||||
"pre { color:#1A1C1F; background:#F7F7F7; margin:8px 0; padding:9px; } "
|
||||
"table { border-collapse:collapse; margin:8px 0; } "
|
||||
"th,td { border:1px solid #DCE3F2; padding:6px 8px; } "
|
||||
"th { color:#15224A; background:#F5F7FC; font-weight:700; }"
|
||||
"th,td { border:1px solid #EDEDEE; padding:6px 8px; } "
|
||||
"th { color:#1A1C1F; background:#F7F7F7; font-weight:700; }"
|
||||
)
|
||||
_AI_ANSWER_MARKDOWN_FEATURES = (
|
||||
QTextDocument.MarkdownFeature.MarkdownDialectGitHub
|
||||
@@ -1181,7 +1181,7 @@ class PrescriptionAiReportDialog(QDialog):
|
||||
def _list_html(self, items: Any, empty: str) -> str:
|
||||
values = [str(item).strip() for item in (items or []) if str(item).strip()]
|
||||
if not values:
|
||||
return f'<span style="color:#8A94AA;">{html.escape(empty)}</span>'
|
||||
return f'<span style="color:#6A6B6D;">{html.escape(empty)}</span>'
|
||||
bullets = "".join(f"<li>{html.escape(item)}</li>" for item in values)
|
||||
return f'<ul style="margin:0;padding-left:18px;">{bullets}</ul>'
|
||||
|
||||
@@ -1398,7 +1398,7 @@ class PrescriptionAiReportDialog(QDialog):
|
||||
content.setTextFormat(Qt.TextFormat.RichText)
|
||||
cell_layout.addWidget(heading)
|
||||
cell_layout.addWidget(content)
|
||||
grid.addWidget(cell, index // 2, index % 2)
|
||||
grid.addWidget(cell, index // 2, index % 2, Qt.AlignmentFlag.AlignTop)
|
||||
self.host_layout.addWidget(grid_host)
|
||||
if report.get("compatibility_analysis"):
|
||||
self._section("配伍分析", str(report.get("compatibility_analysis") or ""))
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Compact, presentation-only disclosure for page search and overview regions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from PySide6.QtCore import QObject, QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from . import icons
|
||||
|
||||
|
||||
class FilterDisclosure(QObject):
|
||||
"""Keep query values and loading state intact while reclaiming list space.
|
||||
|
||||
Targets should be region containers, not individual permission-controlled
|
||||
controls. Showing a container preserves its children's explicit visibility.
|
||||
"""
|
||||
|
||||
expanded_changed = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget,
|
||||
targets: Sequence[QWidget],
|
||||
*,
|
||||
expanded: bool = False,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._targets = tuple(targets)
|
||||
self._expanded = bool(expanded)
|
||||
self.button = QPushButton(parent)
|
||||
self.button.setObjectName("FilterDisclosureButton")
|
||||
self.button.setCheckable(True)
|
||||
self.button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.button.setFixedHeight(32)
|
||||
self.button.setIconSize(QSize(14, 14))
|
||||
self.button.setStyleSheet("""
|
||||
QPushButton#FilterDisclosureButton {
|
||||
color: #1769E8; background: #FFFFFF; border: 1px solid #DBE5F2;
|
||||
border-radius: 6px; padding: 0 11px; min-height: 30px; max-height: 30px;
|
||||
min-width: 92px; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
QPushButton#FilterDisclosureButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
|
||||
QPushButton#FilterDisclosureButton:checked { background: #EAF2FF; border-color: #ADC8F2; }
|
||||
QPushButton#FilterDisclosureButton:focus { border-color: #1769E8; }
|
||||
QPushButton#FilterDisclosureButton:disabled { color: #8B97A8; border-color: #E3E9F1; }
|
||||
""")
|
||||
self.button.toggled.connect(self.set_expanded)
|
||||
self._apply()
|
||||
|
||||
@property
|
||||
def expanded(self) -> bool:
|
||||
return self._expanded
|
||||
|
||||
def set_expanded(self, expanded: bool) -> None:
|
||||
expanded = bool(expanded)
|
||||
changed = expanded != self._expanded
|
||||
self._expanded = expanded
|
||||
self._apply()
|
||||
if changed:
|
||||
self.expanded_changed.emit(expanded)
|
||||
|
||||
def _apply(self) -> None:
|
||||
# Keep keyboard focus on a visible control when folding a focused form.
|
||||
focused = QApplication.focusWidget()
|
||||
if not self._expanded and focused is not None and any(
|
||||
target is focused or target.isAncestorOf(focused) for target in self._targets
|
||||
):
|
||||
self.button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
for target in self._targets:
|
||||
target.setVisible(self._expanded)
|
||||
blocked = self.button.blockSignals(True)
|
||||
self.button.setChecked(self._expanded)
|
||||
self.button.blockSignals(blocked)
|
||||
label = "收起筛选" if self._expanded else "展开筛选"
|
||||
self.button.setText(label)
|
||||
self.button.setAccessibleName(label)
|
||||
self.button.setAccessibleDescription("显示或收起检索条件和统计信息;收起保留当前筛选条件")
|
||||
self.button.setToolTip("收起保留当前筛选条件" if self._expanded else "展开检索条件和统计信息,当前筛选条件保持不变")
|
||||
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", "#1769E8", 14))
|
||||
@@ -0,0 +1,794 @@
|
||||
"""Single source of truth for every line icon in the workstation.
|
||||
|
||||
Before this module the application drew its icons from nine independent
|
||||
painters (``ui/shell.py`` had two, ``ui/login.py`` two,
|
||||
``ui/pages/reception.py`` four, and ``ui/pages/prescriptions.py``,
|
||||
``ui/pages/patients.py`` and ``ui/diagnosis_index_widgets.py`` one each). They disagreed on everything that
|
||||
makes an icon set read as one family:
|
||||
|
||||
* seven stroke weights - 1.4, 1.5, 1.55, 1.6, 1.7, 2.0 and ``size / 11.5`` px;
|
||||
* four design grids - geometry authored against 14, 16, 18 and 24 px boxes, so
|
||||
the same glyph asked for at another size came out off-centre or clipped;
|
||||
* mixed fills and strokes inside one row of icons (a stroked ``search`` beside a
|
||||
solid ``down`` triangle);
|
||||
* integer ``QRect`` coordinates in the menu painter, which put a 1.6 px stroke
|
||||
across a pixel boundary and rendered visibly softer than its neighbours;
|
||||
* six near-identical indigos and two near-identical reds picked per call site
|
||||
instead of from the palette.
|
||||
|
||||
Everything here is authored once on a 24-unit grid with a 20-unit optical safe
|
||||
area, stroked with one weight formula, and scaled to the requested size by the
|
||||
painter transform. Glyphs are pure stroke unless a filled counter is part of
|
||||
the mark (a list bullet, the dot on an "i"), which keeps the whole set at a
|
||||
single apparent weight.
|
||||
|
||||
Icons are cached as well. List pages build one icon per action button per row,
|
||||
so the previous code re-ran a ``QPainter`` for every visible row on every
|
||||
refresh; the cache turns that into one paint per (kind, colour, size).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||
from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .theme import COLORS, crisp_pixmap
|
||||
|
||||
#: Every glyph is drawn inside this box. Nothing is authored against the pixel
|
||||
#: size the caller asks for, which is what keeps a 14 px and a 24 px request
|
||||
#: optically identical instead of merely proportional.
|
||||
GRID = 24.0
|
||||
|
||||
#: Ideal stroke at the reference grid. ``2 / 24`` is the Feather/Lucide ratio;
|
||||
#: the clamp keeps the line from vanishing at 12 px or turning into a slab at
|
||||
#: 36 px, which is the range the shell actually asks for.
|
||||
_STROKE_RATIO = 2.0 / GRID
|
||||
_STROKE_MIN_PX = 1.25
|
||||
_STROKE_MAX_PX = 2.25
|
||||
|
||||
|
||||
def stroke_px(size: float) -> float:
|
||||
"""Return the on-screen stroke width used for an icon of ``size`` px."""
|
||||
|
||||
return max(_STROKE_MIN_PX, min(_STROKE_MAX_PX, size * _STROKE_RATIO))
|
||||
|
||||
|
||||
# --- Semantic colour roles ------------------------------------------------
|
||||
# Call sites name a role instead of a hex value. The seven painters replaced
|
||||
# here between them hardcoded #5265F6, #5761F4, #5469F0, #5E69F6, #5365F5,
|
||||
# #4965F5 and #6675F5 for what was always meant to be one accent.
|
||||
ROLES = {
|
||||
"default": COLORS["muted"],
|
||||
"muted": COLORS["muted"],
|
||||
"soft": COLORS["text_soft"],
|
||||
"strong": COLORS["text"],
|
||||
"accent": COLORS["indigo"],
|
||||
"on_accent": "#FFFFFF",
|
||||
"success": COLORS["success"],
|
||||
"warning": COLORS["warning"],
|
||||
"danger": COLORS["danger"],
|
||||
"info": COLORS["info"],
|
||||
"disabled": COLORS["disabled_text"],
|
||||
"inverse": "#FFFFFF",
|
||||
}
|
||||
|
||||
|
||||
def resolve_color(color: str) -> str:
|
||||
"""Accept either a semantic role name or a literal colour string."""
|
||||
|
||||
return ROLES.get(color, color)
|
||||
|
||||
|
||||
_GLYPHS: dict[str, Callable[[QPainter, float], None]] = {}
|
||||
|
||||
_Glyph = Callable[[QPainter, float], None]
|
||||
|
||||
|
||||
def _glyph(*names: str) -> Callable[[_Glyph], _Glyph]:
|
||||
def register(fn: _Glyph) -> _Glyph:
|
||||
for name in names:
|
||||
_GLYPHS[name] = fn
|
||||
return fn
|
||||
|
||||
return register
|
||||
|
||||
|
||||
def _line(p: QPainter, x1: float, y1: float, x2: float, y2: float) -> None:
|
||||
p.drawLine(QPointF(x1, y1), QPointF(x2, y2))
|
||||
|
||||
|
||||
def _polyline(p: QPainter, *points: tuple[float, float]) -> None:
|
||||
path = QPainterPath(QPointF(*points[0]))
|
||||
for point in points[1:]:
|
||||
path.lineTo(QPointF(*point))
|
||||
p.drawPath(path)
|
||||
|
||||
|
||||
def _circle(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||
|
||||
|
||||
def _dot(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||
"""Filled counter - used only where the mark itself is solid."""
|
||||
|
||||
pen = p.pen()
|
||||
p.setPen(Qt.PenStyle.NoPen)
|
||||
p.setBrush(pen.color())
|
||||
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.setPen(pen)
|
||||
|
||||
|
||||
def _page(p: QPainter, *, fold: bool = True) -> None:
|
||||
"""Shared document silhouette so every file-like glyph has one outline."""
|
||||
|
||||
path = QPainterPath(QPointF(14.0, 2.5))
|
||||
path.lineTo(QPointF(6.5, 2.5))
|
||||
path.quadTo(QPointF(5.0, 2.5), QPointF(5.0, 4.0))
|
||||
path.lineTo(QPointF(5.0, 20.0))
|
||||
path.quadTo(QPointF(5.0, 21.5), QPointF(6.5, 21.5))
|
||||
path.lineTo(QPointF(17.5, 21.5))
|
||||
path.quadTo(QPointF(19.0, 21.5), QPointF(19.0, 20.0))
|
||||
path.lineTo(QPointF(19.0, 7.5))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
if fold:
|
||||
_polyline(p, (14.0, 2.5), (14.0, 7.5), (19.0, 7.5))
|
||||
|
||||
|
||||
def _sparkle(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||
"""Four-point concave star - the one AI mark used across the product."""
|
||||
|
||||
path = QPainterPath(QPointF(cx, cy - r))
|
||||
path.quadTo(QPointF(cx, cy), QPointF(cx + r, cy))
|
||||
path.quadTo(QPointF(cx, cy), QPointF(cx, cy + r))
|
||||
path.quadTo(QPointF(cx, cy), QPointF(cx - r, cy))
|
||||
path.quadTo(QPointF(cx, cy), QPointF(cx, cy - r))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
|
||||
|
||||
def _panel(p: QPainter) -> None:
|
||||
p.drawRoundedRect(QRectF(2.5, 4.0, 19.0, 16.0), 3.0, 3.0)
|
||||
_line(p, 9.5, 4.0, 9.5, 20.0)
|
||||
|
||||
|
||||
# --- Navigation -----------------------------------------------------------
|
||||
|
||||
|
||||
@_glyph("reception", "workbench", "monitor")
|
||||
def _reception(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(2.5, 3.5, 19.0, 13.5), 3.0, 3.0)
|
||||
_polyline(p, (6.0, 10.5), (8.8, 10.5), (10.6, 7.5), (13.4, 13.5), (15.2, 10.5), (18.0, 10.5))
|
||||
_line(p, 12.0, 17.0, 12.0, 20.5)
|
||||
_line(p, 8.0, 20.5, 16.0, 20.5)
|
||||
|
||||
|
||||
@_glyph("appointments", "calendar")
|
||||
def _calendar(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 16.5), 3.0, 3.0)
|
||||
_line(p, 3.0, 10.0, 21.0, 10.0)
|
||||
_line(p, 8.0, 2.75, 8.0, 7.0)
|
||||
_line(p, 16.0, 2.75, 16.0, 7.0)
|
||||
|
||||
|
||||
@_glyph("prescription_library", "library", "layers")
|
||||
def _layers(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(12.0, 2.5))
|
||||
path.lineTo(QPointF(21.0, 7.0))
|
||||
path.lineTo(QPointF(12.0, 11.5))
|
||||
path.lineTo(QPointF(3.0, 7.0))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
_polyline(p, (3.0, 12.0), (12.0, 16.5), (21.0, 12.0))
|
||||
_polyline(p, (3.0, 16.75), (12.0, 21.25), (21.0, 16.75))
|
||||
|
||||
|
||||
@_glyph("prescriptions", "file_check")
|
||||
def _file_check(p: QPainter, w: float) -> None:
|
||||
_page(p)
|
||||
_polyline(p, (8.5, 15.0), (10.9, 17.4), (15.5, 12.0))
|
||||
|
||||
|
||||
@_glyph("patients", "users")
|
||||
def _users(p: QPainter, w: float) -> None:
|
||||
_circle(p, 9.0, 8.0, 3.5)
|
||||
path = QPainterPath(QPointF(2.5, 20.5))
|
||||
path.quadTo(QPointF(2.5, 14.5), QPointF(9.0, 14.5))
|
||||
path.quadTo(QPointF(15.5, 14.5), QPointF(15.5, 20.5))
|
||||
p.drawPath(path)
|
||||
_circle(p, 17.6, 8.0, 2.8)
|
||||
tail = QPainterPath(QPointF(17.0, 13.9))
|
||||
tail.quadTo(QPointF(21.5, 14.6), QPointF(21.5, 20.5))
|
||||
p.drawPath(tail)
|
||||
|
||||
|
||||
@_glyph("consultations", "consult", "message", "other")
|
||||
def _message(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(6.5, 3.5))
|
||||
path.lineTo(QPointF(17.5, 3.5))
|
||||
path.quadTo(QPointF(20.5, 3.5), QPointF(20.5, 6.5))
|
||||
path.lineTo(QPointF(20.5, 13.5))
|
||||
path.quadTo(QPointF(20.5, 16.5), QPointF(17.5, 16.5))
|
||||
path.lineTo(QPointF(11.5, 16.5))
|
||||
path.lineTo(QPointF(7.0, 20.5))
|
||||
path.lineTo(QPointF(7.0, 16.5))
|
||||
path.quadTo(QPointF(3.5, 16.5), QPointF(3.5, 13.5))
|
||||
path.lineTo(QPointF(3.5, 6.5))
|
||||
path.quadTo(QPointF(3.5, 3.5), QPointF(6.5, 3.5))
|
||||
p.drawPath(path)
|
||||
_line(p, 7.75, 8.25, 16.25, 8.25)
|
||||
_line(p, 7.75, 11.75, 13.0, 11.75)
|
||||
|
||||
|
||||
# --- Shell chrome ---------------------------------------------------------
|
||||
|
||||
|
||||
@_glyph("fold", "panel_close")
|
||||
def _fold(p: QPainter, w: float) -> None:
|
||||
_panel(p)
|
||||
_polyline(p, (17.0, 9.0), (14.0, 12.0), (17.0, 15.0))
|
||||
|
||||
|
||||
@_glyph("expand", "panel_open")
|
||||
def _expand(p: QPainter, w: float) -> None:
|
||||
_panel(p)
|
||||
_polyline(p, (14.0, 9.0), (17.0, 12.0), (14.0, 15.0))
|
||||
|
||||
|
||||
@_glyph("search")
|
||||
def _search(p: QPainter, w: float) -> None:
|
||||
_circle(p, 10.5, 10.5, 6.25)
|
||||
_line(p, 15.15, 15.15, 19.75, 19.75)
|
||||
|
||||
|
||||
@_glyph("refresh", "rotate")
|
||||
def _refresh(p: QPainter, w: float) -> None:
|
||||
# The arc terminates exactly on the arrow corner so the mark reads as one
|
||||
# continuous stroke. The painters replaced here left a detached triangle
|
||||
# (shell) or two stray lines that never formed a head at all (prescriptions).
|
||||
radius = math.hypot(8.5, 3.5)
|
||||
start = math.degrees(math.atan2(3.5, 8.5))
|
||||
p.drawArc(
|
||||
QRectF(12.0 - radius, 12.0 - radius, radius * 2, radius * 2),
|
||||
round(start * 16),
|
||||
round((360.0 - start) * 16),
|
||||
)
|
||||
_polyline(p, (20.5, 3.5), (20.5, 8.5), (15.5, 8.5))
|
||||
|
||||
|
||||
@_glyph("ai", "spark", "sparkle", "assistant")
|
||||
def _ai(p: QPainter, w: float) -> None:
|
||||
_sparkle(p, 10.2, 11.8, 7.2)
|
||||
_sparkle(p, 18.0, 6.0, 3.2)
|
||||
|
||||
|
||||
@_glyph("fullscreen", "expand-corners", "maximize")
|
||||
def _fullscreen(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (9.0, 3.5), (3.5, 3.5), (3.5, 9.0))
|
||||
_polyline(p, (15.0, 3.5), (20.5, 3.5), (20.5, 9.0))
|
||||
_polyline(p, (3.5, 15.0), (3.5, 20.5), (9.0, 20.5))
|
||||
_polyline(p, (20.5, 15.0), (20.5, 20.5), (15.0, 20.5))
|
||||
|
||||
|
||||
@_glyph("minimize")
|
||||
def _minimize(p: QPainter, w: float) -> None:
|
||||
_line(p, 5.0, 12.0, 19.0, 12.0)
|
||||
|
||||
|
||||
@_glyph("close", "cross")
|
||||
def _close(p: QPainter, w: float) -> None:
|
||||
_line(p, 5.75, 5.75, 18.25, 18.25)
|
||||
_line(p, 18.25, 5.75, 5.75, 18.25)
|
||||
|
||||
|
||||
@_glyph("down", "chevron_down")
|
||||
def _down(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (5.5, 9.0), (12.0, 15.5), (18.5, 9.0))
|
||||
|
||||
|
||||
@_glyph("up", "chevron_up")
|
||||
def _up(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (5.5, 15.0), (12.0, 8.5), (18.5, 15.0))
|
||||
|
||||
|
||||
@_glyph("left", "chevron_left")
|
||||
def _left(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (15.0, 5.5), (8.5, 12.0), (15.0, 18.5))
|
||||
|
||||
|
||||
@_glyph("right", "chevron_right")
|
||||
def _right(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (9.0, 5.5), (15.5, 12.0), (9.0, 18.5))
|
||||
|
||||
|
||||
@_glyph("notification", "bell")
|
||||
def _bell(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(6.75, 17.5))
|
||||
path.lineTo(QPointF(6.75, 10.75))
|
||||
path.arcTo(QRectF(6.75, 5.0, 10.5, 11.5), 180.0, -180.0)
|
||||
path.lineTo(QPointF(17.25, 17.5))
|
||||
p.drawPath(path)
|
||||
_line(p, 4.5, 17.5, 19.5, 17.5)
|
||||
p.drawArc(QRectF(10.0, 17.4, 4.0, 3.6), 180 * 16, 180 * 16)
|
||||
|
||||
|
||||
@_glyph("settings", "sliders")
|
||||
def _settings(p: QPainter, w: float) -> None:
|
||||
_line(p, 3.5, 8.5, 20.5, 8.5)
|
||||
_line(p, 3.5, 15.5, 20.5, 15.5)
|
||||
_circle(p, 9.0, 8.5, 2.4)
|
||||
_circle(p, 15.0, 15.5, 2.4)
|
||||
|
||||
|
||||
# --- Row and toolbar actions ---------------------------------------------
|
||||
|
||||
|
||||
@_glyph("eye", "view")
|
||||
def _eye(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(2.5, 12.0))
|
||||
path.quadTo(QPointF(12.0, 2.5), QPointF(21.5, 12.0))
|
||||
path.quadTo(QPointF(12.0, 21.5), QPointF(2.5, 12.0))
|
||||
p.drawPath(path)
|
||||
_circle(p, 12.0, 12.0, 3.0)
|
||||
|
||||
|
||||
@_glyph("pencil", "edit")
|
||||
def _pencil(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(16.25, 3.0))
|
||||
path.lineTo(QPointF(20.75, 7.5))
|
||||
path.lineTo(QPointF(8.5, 19.75))
|
||||
path.lineTo(QPointF(3.0, 21.0))
|
||||
path.lineTo(QPointF(4.25, 15.5))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
_line(p, 13.0, 6.25, 17.5, 10.75)
|
||||
|
||||
|
||||
@_glyph("trash", "delete")
|
||||
def _trash(p: QPainter, w: float) -> None:
|
||||
_line(p, 3.5, 6.25, 20.5, 6.25)
|
||||
_polyline(p, (9.0, 6.25), (9.0, 3.5), (15.0, 3.5), (15.0, 6.25))
|
||||
path = QPainterPath(QPointF(5.75, 6.25))
|
||||
path.lineTo(QPointF(6.6, 19.4))
|
||||
path.quadTo(QPointF(6.7, 20.5), QPointF(7.8, 20.5))
|
||||
path.lineTo(QPointF(16.2, 20.5))
|
||||
path.quadTo(QPointF(17.3, 20.5), QPointF(17.4, 19.4))
|
||||
path.lineTo(QPointF(18.25, 6.25))
|
||||
p.drawPath(path)
|
||||
_line(p, 10.0, 10.0, 10.0, 16.75)
|
||||
_line(p, 14.0, 10.0, 14.0, 16.75)
|
||||
|
||||
|
||||
@_glyph("plus", "add")
|
||||
def _plus(p: QPainter, w: float) -> None:
|
||||
_line(p, 12.0, 4.75, 12.0, 19.25)
|
||||
_line(p, 4.75, 12.0, 19.25, 12.0)
|
||||
|
||||
|
||||
@_glyph("check")
|
||||
def _check(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (4.75, 12.5), (9.75, 17.5), (19.25, 7.0))
|
||||
|
||||
|
||||
@_glyph("check_circle", "health")
|
||||
def _check_circle(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_polyline(p, (7.75, 12.25), (10.75, 15.25), (16.25, 9.0))
|
||||
|
||||
|
||||
@_glyph("checkbox")
|
||||
def _checkbox(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(3.75, 3.75, 16.5, 16.5), 3.5, 3.5)
|
||||
|
||||
|
||||
@_glyph("lock")
|
||||
def _lock(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(4.5, 10.5, 15.0, 10.0), 2.75, 2.75)
|
||||
p.drawArc(QRectF(8.0, 3.75, 8.0, 13.5), 0, 180 * 16)
|
||||
_dot(p, 12.0, 15.5, 1.35)
|
||||
|
||||
|
||||
@_glyph("document", "file")
|
||||
def _document(p: QPainter, w: float) -> None:
|
||||
_page(p)
|
||||
_line(p, 8.5, 12.75, 15.5, 12.75)
|
||||
_line(p, 8.5, 16.75, 13.25, 16.75)
|
||||
|
||||
|
||||
@_glyph("report")
|
||||
def _report(p: QPainter, w: float) -> None:
|
||||
_page(p)
|
||||
_line(p, 8.75, 17.75, 8.75, 14.25)
|
||||
_line(p, 12.0, 17.75, 12.0, 10.5)
|
||||
_line(p, 15.25, 17.75, 15.25, 12.75)
|
||||
|
||||
|
||||
@_glyph("list")
|
||||
def _list(p: QPainter, w: float) -> None:
|
||||
for y in (6.5, 12.0, 17.5):
|
||||
_dot(p, 4.5, y, 1.2)
|
||||
_line(p, 8.75, y, 19.5, y)
|
||||
|
||||
|
||||
@_glyph("user", "person")
|
||||
def _user(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 8.0, 4.0)
|
||||
path = QPainterPath(QPointF(4.25, 20.5))
|
||||
path.quadTo(QPointF(4.25, 14.5), QPointF(12.0, 14.5))
|
||||
path.quadTo(QPointF(19.75, 14.5), QPointF(19.75, 20.5))
|
||||
p.drawPath(path)
|
||||
|
||||
|
||||
@_glyph("remove", "minus_circle")
|
||||
def _remove(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_line(p, 8.0, 12.0, 16.0, 12.0)
|
||||
|
||||
|
||||
@_glyph("stop", "close_circle")
|
||||
def _stop(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_line(p, 9.0, 9.0, 15.0, 15.0)
|
||||
_line(p, 15.0, 9.0, 9.0, 15.0)
|
||||
|
||||
|
||||
@_glyph("info")
|
||||
def _info(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_line(p, 12.0, 11.25, 12.0, 16.5)
|
||||
_dot(p, 12.0, 7.75, 1.0)
|
||||
|
||||
|
||||
@_glyph("picture", "image")
|
||||
def _picture(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(3.0, 4.5, 18.0, 15.0), 3.0, 3.0)
|
||||
_circle(p, 7.75, 8.75, 1.6)
|
||||
_polyline(p, (3.5, 17.75), (9.75, 12.25), (13.25, 15.5), (15.75, 13.25), (20.5, 17.75))
|
||||
|
||||
|
||||
@_glyph("meds", "pill")
|
||||
def _meds(p: QPainter, w: float) -> None:
|
||||
p.save()
|
||||
p.translate(12.0, 12.0)
|
||||
p.rotate(-45.0)
|
||||
p.drawRoundedRect(QRectF(-9.25, -4.5, 18.5, 9.0), 4.5, 4.5)
|
||||
_line(p, 0.0, -4.5, 0.0, 4.5)
|
||||
p.restore()
|
||||
|
||||
|
||||
@_glyph("daily", "clipboard")
|
||||
def _clipboard(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(8.5, 4.5))
|
||||
path.lineTo(QPointF(6.75, 4.5))
|
||||
path.quadTo(QPointF(4.25, 4.5), QPointF(4.25, 7.0))
|
||||
path.lineTo(QPointF(4.25, 19.0))
|
||||
path.quadTo(QPointF(4.25, 21.5), QPointF(6.75, 21.5))
|
||||
path.lineTo(QPointF(17.25, 21.5))
|
||||
path.quadTo(QPointF(19.75, 21.5), QPointF(19.75, 19.0))
|
||||
path.lineTo(QPointF(19.75, 7.0))
|
||||
path.quadTo(QPointF(19.75, 4.5), QPointF(17.25, 4.5))
|
||||
path.lineTo(QPointF(15.5, 4.5))
|
||||
p.drawPath(path)
|
||||
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 4.0), 1.5, 1.5)
|
||||
_line(p, 8.0, 12.0, 16.0, 12.0)
|
||||
_line(p, 8.0, 16.25, 13.5, 16.25)
|
||||
|
||||
|
||||
@_glyph("followup", "calendar_clock")
|
||||
def _followup(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(13.0, 20.0))
|
||||
path.lineTo(QPointF(5.5, 20.0))
|
||||
path.quadTo(QPointF(3.0, 20.0), QPointF(3.0, 17.5))
|
||||
path.lineTo(QPointF(3.0, 7.5))
|
||||
path.quadTo(QPointF(3.0, 5.0), QPointF(5.5, 5.0))
|
||||
path.lineTo(QPointF(14.5, 5.0))
|
||||
path.quadTo(QPointF(17.0, 5.0), QPointF(17.0, 7.5))
|
||||
path.lineTo(QPointF(17.0, 9.0))
|
||||
p.drawPath(path)
|
||||
_line(p, 3.0, 9.5, 17.0, 9.5)
|
||||
_line(p, 7.0, 2.75, 7.0, 6.75)
|
||||
_line(p, 13.0, 2.75, 13.0, 6.75)
|
||||
_circle(p, 16.75, 16.75, 4.5)
|
||||
_polyline(p, (16.75, 14.25), (16.75, 16.75), (18.9, 16.75))
|
||||
|
||||
|
||||
@_glyph("brand", "logo")
|
||||
def _brand(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 12.0, 8.75)
|
||||
_polyline(p, (7.0, 12.0), (10.0, 12.0), (11.5, 8.5), (13.5, 15.5), (15.0, 12.0), (17.0, 12.0))
|
||||
|
||||
|
||||
# --- AI consultation ------------------------------------------------------
|
||||
|
||||
|
||||
@_glyph("chart", "analytics")
|
||||
def _analytics(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (3.5, 3.0), (3.5, 20.5), (21.0, 20.5))
|
||||
_polyline(p, (7.0, 16.5), (10.5, 8.5), (14.0, 13.0), (20.0, 5.5))
|
||||
|
||||
|
||||
@_glyph("trend")
|
||||
def _trend(p: QPainter, w: float) -> None:
|
||||
_polyline(p, (3.0, 18.0), (8.5, 9.5), (12.5, 13.5), (21.0, 5.5))
|
||||
_polyline(p, (15.5, 5.5), (21.0, 5.5), (21.0, 11.0))
|
||||
|
||||
|
||||
@_glyph("alert", "warning")
|
||||
def _alert(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(12.0, 3.0))
|
||||
path.lineTo(QPointF(21.5, 20.0))
|
||||
path.lineTo(QPointF(2.5, 20.0))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
_line(p, 12.0, 9.5, 12.0, 14.5)
|
||||
_dot(p, 12.0, 17.4, 1.05)
|
||||
|
||||
|
||||
@_glyph("mic", "microphone")
|
||||
def _mic(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 12.0), 3.5, 3.5)
|
||||
p.drawArc(QRectF(5.0, 6.0, 14.0, 14.0), 0, -180 * 16)
|
||||
_line(p, 12.0, 17.5, 12.0, 21.0)
|
||||
_line(p, 8.25, 21.0, 15.75, 21.0)
|
||||
|
||||
|
||||
@_glyph("send")
|
||||
def _send(p: QPainter, w: float) -> None:
|
||||
path = QPainterPath(QPointF(21.0, 3.0))
|
||||
path.lineTo(QPointF(2.5, 10.5))
|
||||
path.lineTo(QPointF(10.25, 13.75))
|
||||
path.lineTo(QPointF(13.5, 21.5))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
_line(p, 10.25, 13.75, 21.0, 3.0)
|
||||
|
||||
|
||||
@_glyph("qr", "qrcode")
|
||||
def _qr(p: QPainter, w: float) -> None:
|
||||
"""Three finder squares plus a few modules - the shape people scan for."""
|
||||
|
||||
for x, y in ((3.0, 3.0), (14.0, 3.0), (3.0, 14.0)):
|
||||
p.drawRoundedRect(QRectF(x, y, 7.0, 7.0), 1.5, 1.5)
|
||||
_dot(p, x + 3.5, y + 3.5, 1.15)
|
||||
_line(p, 14.5, 14.5, 14.5, 17.0)
|
||||
_line(p, 18.0, 14.5, 21.0, 14.5)
|
||||
_line(p, 17.5, 18.0, 17.5, 21.0)
|
||||
_dot(p, 20.75, 20.75, 1.15)
|
||||
|
||||
|
||||
@_glyph("video", "call")
|
||||
def _video(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(2.5, 6.0, 13.5, 12.0), 3.0, 3.0)
|
||||
path = QPainterPath(QPointF(16.0, 10.5))
|
||||
path.lineTo(QPointF(21.5, 7.25))
|
||||
path.lineTo(QPointF(21.5, 16.75))
|
||||
path.lineTo(QPointF(16.0, 13.5))
|
||||
path.closeSubpath()
|
||||
p.drawPath(path)
|
||||
|
||||
|
||||
# --- Clinical measures ----------------------------------------------------
|
||||
# The vital-sign tiles and the lifestyle row used to carry two more bespoke
|
||||
# painters (22 px / 1.35 px stroke and 16 px / 1.3 px stroke). Beyond the extra
|
||||
# weights, two of their glyphs were simply wrong: "weight" read as a padlock and
|
||||
# "BMI" as the Venus symbol.
|
||||
|
||||
|
||||
@_glyph("height")
|
||||
def _height(p: QPainter, w: float) -> None:
|
||||
_line(p, 6.0, 3.5, 18.0, 3.5)
|
||||
_line(p, 6.0, 20.5, 18.0, 20.5)
|
||||
_line(p, 12.0, 5.75, 12.0, 18.25)
|
||||
_polyline(p, (9.5, 8.25), (12.0, 5.75), (14.5, 8.25))
|
||||
_polyline(p, (9.5, 15.75), (12.0, 18.25), (14.5, 15.75))
|
||||
|
||||
|
||||
@_glyph("weight", "scale")
|
||||
def _weight(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 14.5), 3.5, 3.5)
|
||||
p.drawArc(QRectF(7.0, 10.0, 10.0, 10.0), 25 * 16, 130 * 16)
|
||||
_line(p, 12.0, 15.0, 9.9, 10.9)
|
||||
|
||||
|
||||
@_glyph("bmi", "body")
|
||||
def _bmi(p: QPainter, w: float) -> None:
|
||||
_circle(p, 12.0, 5.0, 2.75)
|
||||
_line(p, 12.0, 7.75, 12.0, 15.0)
|
||||
_line(p, 7.25, 11.0, 16.75, 11.0)
|
||||
_polyline(p, (8.5, 20.75), (12.0, 15.0), (15.5, 20.75))
|
||||
|
||||
|
||||
@_glyph("blood_pressure", "gauge", "bp")
|
||||
def _blood_pressure(p: QPainter, w: float) -> None:
|
||||
p.drawArc(QRectF(3.0, 5.5, 18.0, 18.0), 0, 180 * 16)
|
||||
_line(p, 3.0, 14.5, 21.0, 14.5)
|
||||
_line(p, 12.0, 14.5, 16.4, 9.4)
|
||||
_dot(p, 12.0, 14.5, 1.15)
|
||||
|
||||
|
||||
@_glyph("pulse", "heart")
|
||||
def _pulse(p: QPainter, w: float) -> None:
|
||||
heart = QPainterPath(QPointF(12.0, 20.25))
|
||||
heart.cubicTo(QPointF(3.2, 13.6), QPointF(2.2, 9.6), QPointF(4.7, 6.7))
|
||||
heart.cubicTo(QPointF(7.2, 4.0), QPointF(10.5, 4.8), QPointF(12.0, 7.7))
|
||||
heart.cubicTo(QPointF(13.5, 4.8), QPointF(16.8, 4.0), QPointF(19.3, 6.7))
|
||||
heart.cubicTo(QPointF(21.8, 9.6), QPointF(20.8, 13.6), QPointF(12.0, 20.25))
|
||||
heart.closeSubpath()
|
||||
p.drawPath(heart)
|
||||
_polyline(
|
||||
p, (5.6, 12.4), (9.0, 12.4), (10.6, 9.7), (13.2, 15.1), (14.7, 12.4), (18.4, 12.4)
|
||||
)
|
||||
|
||||
|
||||
@_glyph("smoke", "cigarette")
|
||||
def _smoke(p: QPainter, w: float) -> None:
|
||||
p.drawRoundedRect(QRectF(2.5, 14.0, 14.0, 5.0), 1.75, 1.75)
|
||||
_line(p, 13.0, 14.0, 13.0, 19.0)
|
||||
curl = QPainterPath(QPointF(19.0, 12.0))
|
||||
curl.quadTo(QPointF(21.5, 9.5), QPointF(19.0, 7.5))
|
||||
curl.quadTo(QPointF(16.5, 5.5), QPointF(19.0, 3.5))
|
||||
p.drawPath(curl)
|
||||
|
||||
|
||||
@_glyph("drink", "glass")
|
||||
def _drink(p: QPainter, w: float) -> None:
|
||||
bowl = QPainterPath(QPointF(6.5, 3.5))
|
||||
bowl.lineTo(QPointF(17.5, 3.5))
|
||||
bowl.lineTo(QPointF(13.75, 12.5))
|
||||
bowl.lineTo(QPointF(10.25, 12.5))
|
||||
bowl.closeSubpath()
|
||||
p.drawPath(bowl)
|
||||
_line(p, 7.75, 7.5, 16.25, 7.5)
|
||||
_line(p, 12.0, 12.5, 12.0, 20.0)
|
||||
_line(p, 8.0, 20.0, 16.0, 20.0)
|
||||
|
||||
|
||||
@_glyph("exercise", "run")
|
||||
def _exercise(p: QPainter, w: float) -> None:
|
||||
_circle(p, 15.75, 4.75, 2.5)
|
||||
_polyline(p, (14.5, 9.0), (9.75, 12.5), (6.0, 20.5))
|
||||
_polyline(p, (14.5, 9.0), (18.75, 12.75), (21.0, 10.75))
|
||||
_polyline(p, (11.75, 11.0), (15.25, 16.0), (13.25, 20.75))
|
||||
|
||||
|
||||
# --- Painting -------------------------------------------------------------
|
||||
|
||||
|
||||
def _device_ratio() -> float:
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return 1.0
|
||||
screen = app.primaryScreen()
|
||||
if screen is None:
|
||||
return 1.0
|
||||
return max(1.0, float(screen.devicePixelRatio()))
|
||||
|
||||
|
||||
def _render(kind: str, color: str, size: int) -> QPixmap:
|
||||
canvas = crisp_pixmap(size)
|
||||
draw = _GLYPHS.get(kind)
|
||||
if draw is None:
|
||||
return canvas
|
||||
painter = QPainter(canvas)
|
||||
try:
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
# Inset the grid by half a stroke on every edge. Without it a glyph that
|
||||
# legitimately reaches grid unit 24 loses the outer half of its line to
|
||||
# the pixmap boundary at the smaller sizes - which is exactly how the old
|
||||
# painters lost the shell star's companion dot and flattened the top of
|
||||
# the calendar. Insetting here means every glyph can use the full grid.
|
||||
weight = stroke_px(size)
|
||||
scale = (size - weight) / GRID
|
||||
painter.translate(weight / 2.0, weight / 2.0)
|
||||
painter.scale(scale, scale)
|
||||
# The pen width is expressed on the design grid, so the on-screen weight
|
||||
# stays the same fraction of the box at every size the shell asks for.
|
||||
width = weight / scale
|
||||
pen = QPen(QColor(color), width)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
draw(painter, width)
|
||||
finally:
|
||||
painter.end()
|
||||
return canvas
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _cached_pixmap(kind: str, color: str, size: int, ratio: float) -> QPixmap:
|
||||
del ratio # part of the cache key only; crisp_pixmap reads it back itself
|
||||
return _render(kind, color, size)
|
||||
|
||||
|
||||
def pixmap(kind: str, color: str = "default", size: int = 18) -> QPixmap:
|
||||
"""Return a cached, device-pixel-correct pixmap for ``kind``."""
|
||||
|
||||
return _cached_pixmap(kind, resolve_color(color), int(size), _device_ratio())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _cached_icon(kind: str, color: str, size: int, ratio: float) -> QIcon:
|
||||
result = QIcon(_cached_pixmap(kind, color, size, ratio))
|
||||
result.addPixmap(
|
||||
_cached_pixmap(kind, ROLES["disabled"], size, ratio),
|
||||
QIcon.Mode.Disabled,
|
||||
QIcon.State.Off,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def icon(kind: str, color: str = "default", size: int = 18) -> QIcon:
|
||||
"""Return a cached icon with a matching disabled variant already attached.
|
||||
|
||||
``color`` accepts a role name from :data:`ROLES` or a literal colour.
|
||||
"""
|
||||
|
||||
return _cached_icon(kind, resolve_color(color), int(size), _device_ratio())
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _cached_state_icon(
|
||||
kind: str,
|
||||
size: int,
|
||||
normal: str,
|
||||
active: str,
|
||||
checked: str,
|
||||
disabled: str,
|
||||
ratio: float,
|
||||
) -> QIcon:
|
||||
result = QIcon()
|
||||
result.addPixmap(_cached_pixmap(kind, normal, size, ratio), QIcon.Mode.Normal, QIcon.State.Off)
|
||||
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Normal, QIcon.State.On)
|
||||
result.addPixmap(_cached_pixmap(kind, active, size, ratio), QIcon.Mode.Active, QIcon.State.Off)
|
||||
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Active, QIcon.State.On)
|
||||
result.addPixmap(
|
||||
_cached_pixmap(kind, disabled, size, ratio), QIcon.Mode.Disabled, QIcon.State.Off
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def state_icon(
|
||||
kind: str,
|
||||
*,
|
||||
size: int = 18,
|
||||
normal: str = "muted",
|
||||
active: str = "strong",
|
||||
checked: str = "inverse",
|
||||
disabled: str = "disabled",
|
||||
) -> QIcon:
|
||||
"""Return an icon carrying its own hover / selected / disabled colours.
|
||||
|
||||
Qt only tints an icon when a widget asks it to, so a single-pixmap icon on a
|
||||
selected navigation row keeps its resting grey and reads as switched off.
|
||||
"""
|
||||
|
||||
return _cached_state_icon(
|
||||
kind,
|
||||
int(size),
|
||||
resolve_color(normal),
|
||||
resolve_color(active),
|
||||
resolve_color(checked),
|
||||
resolve_color(disabled),
|
||||
_device_ratio(),
|
||||
)
|
||||
|
||||
|
||||
def available_kinds() -> tuple[str, ...]:
|
||||
"""Every glyph name this module answers to, aliases included."""
|
||||
|
||||
return tuple(sorted(_GLYPHS))
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop cached pixmaps - used when the display scale factor changes."""
|
||||
|
||||
_cached_pixmap.cache_clear()
|
||||
_cached_icon.cache_clear()
|
||||
_cached_state_icon.cache_clear()
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Incremental server lists with a compact status footer and stable view state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QSignalBlocker, Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QCheckBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .widgets import first_value, get_value, page_items, page_total, run_async
|
||||
|
||||
|
||||
def record_key(row: Any) -> str:
|
||||
value = first_value(
|
||||
row, "id", "prescription_id", "appointment_id", "diagnosis_id", "order_id", "patient_id"
|
||||
)
|
||||
return str(value) if value is not None else repr(row)
|
||||
|
||||
|
||||
class ListSnapshot:
|
||||
"""Keep repository metadata available while replacing only the list payload."""
|
||||
|
||||
def __init__(self, rows: list[Any], total: int, source: Any) -> None:
|
||||
self.items = rows
|
||||
self.total = total
|
||||
self.source = source
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return get_value(self.source, name, None)
|
||||
|
||||
|
||||
class InfiniteList(QWidget):
|
||||
"""Bind to a scrolling view; fetch pages only as the visible list needs them.
|
||||
|
||||
Reloads use captured query arguments. Refreshing the same query rebuilds the
|
||||
loaded prefix atomically, so polling neither drops appended rows nor mixes
|
||||
an updated first page with an old tail. Failed appends retain the prior page
|
||||
and can be retried explicitly without an automatic request loop.
|
||||
"""
|
||||
|
||||
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("InfiniteList")
|
||||
self.setFixedHeight(24)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 0, 12, 0)
|
||||
self.summary_label = QLabel("", self)
|
||||
self.summary_label.setStyleSheet(
|
||||
"color: #5D6B80; font-size: 12px; background: transparent;"
|
||||
)
|
||||
layout.addWidget(self.summary_label)
|
||||
layout.addStretch(1)
|
||||
self.retry_button = QPushButton("加载失败,点击重试", self)
|
||||
self.retry_button.setFlat(True)
|
||||
self.retry_button.setStyleSheet(
|
||||
"color: #1769E8; font-size: 12px; padding: 0 4px; border: none; background: transparent; min-height: 20px; max-height: 20px; min-width: 0;"
|
||||
)
|
||||
self.retry_button.hide()
|
||||
self.retry_button.clicked.connect(self.retry)
|
||||
layout.addWidget(self.retry_button)
|
||||
self.page_size = page_size
|
||||
self.page = 0
|
||||
self.total = 0
|
||||
self.rows: list[Any] = []
|
||||
self.loading = False
|
||||
self.has_more = False
|
||||
self._generation = 0
|
||||
self._query_key: Any = object()
|
||||
self._view: QAbstractScrollArea | None = None
|
||||
self._views: list[QAbstractScrollArea] = []
|
||||
self._error = False
|
||||
self._configured = False
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._maybe_load_more)
|
||||
|
||||
def bind(self, view: QAbstractScrollArea) -> None:
|
||||
if view in self._views:
|
||||
return
|
||||
self._views.append(view)
|
||||
self._view = view
|
||||
view.verticalScrollBar().valueChanged.connect(self._schedule_check)
|
||||
view.verticalScrollBar().rangeChanged.connect(self._schedule_check)
|
||||
view.viewport().installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: Any, event: Any) -> bool:
|
||||
if event.type() in (QEvent.Type.Show, QEvent.Type.Resize):
|
||||
self._schedule_check()
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _schedule_check(self, *_: Any) -> None:
|
||||
self._timer.start(30)
|
||||
|
||||
def _maybe_load_more(self) -> None:
|
||||
view = next((v for v in self._views if v.isVisible()), None)
|
||||
if view is None or self.loading or self._error or not self.has_more:
|
||||
return
|
||||
bar = view.verticalScrollBar()
|
||||
# pageStep respects both per-item and per-pixel Qt scrolling modes.
|
||||
if bar.maximum() - bar.value() <= max(1, bar.pageStep() // 4):
|
||||
self.load_more()
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Disarm callbacks when a reusable dialog switches to another record."""
|
||||
self._generation += 1
|
||||
self._configured = False
|
||||
self.loading = self.has_more = self._error = False
|
||||
self.rows, self.page, self.total = [], 0, 0
|
||||
self._timer.stop()
|
||||
self.retry_button.hide()
|
||||
self._status()
|
||||
|
||||
reset = invalidate
|
||||
|
||||
def reload(
|
||||
self,
|
||||
fetch: Callable[[int], Any],
|
||||
apply: Callable[[Any], None],
|
||||
on_error: Callable[[Exception], None],
|
||||
*,
|
||||
runner: Callable[..., Any] = run_async,
|
||||
query_key: Any = None,
|
||||
on_finished: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
same_query = self._configured and query_key == self._query_key
|
||||
if same_query and self.loading:
|
||||
# Polling must not restart a slow prefix refresh indefinitely. The
|
||||
# caller may have advanced its own generation, so use its latest
|
||||
# render/error closures while the captured request finishes.
|
||||
self._apply, self._on_error = apply, on_error
|
||||
self._on_finished = on_finished
|
||||
return
|
||||
self._generation += 1
|
||||
self._query_key = query_key
|
||||
self._configured = True
|
||||
self._fetch, self._apply, self._on_error = fetch, apply, on_error
|
||||
self._runner, self._on_finished = runner, on_finished
|
||||
self._target = max(1, self.page) if same_query else 1
|
||||
self._reset_view = not same_query
|
||||
if not same_query:
|
||||
self.rows, self.page, self.total = [], 0, 0
|
||||
self.has_more = False
|
||||
self._render(ListSnapshot([], 0, None), preserve=False)
|
||||
self._begin(1, [], refresh=True)
|
||||
|
||||
def load_more(self) -> None:
|
||||
if not self._configured or self.loading or self._error or not self.has_more:
|
||||
return
|
||||
self._reset_view = False
|
||||
self._target = self.page + 1
|
||||
self._begin(self.page + 1, list(self.rows), refresh=False)
|
||||
|
||||
def retry(self) -> None:
|
||||
if self.loading or not self._error:
|
||||
return
|
||||
self._begin(self._failed_page, list(self._failed_rows), refresh=self._failed_refresh)
|
||||
|
||||
def _begin(self, page: int, rows: list[Any], *, refresh: bool) -> None:
|
||||
self.loading = True
|
||||
self._error = False
|
||||
self.retry_button.hide()
|
||||
self.summary_label.setText(
|
||||
f"已加载 {len(self.rows)} 条 · 正在加载…" if self.rows else "正在加载…"
|
||||
)
|
||||
generation = self._generation
|
||||
fetch = self._fetch
|
||||
self._runner(
|
||||
lambda: fetch(page),
|
||||
on_success=lambda result: self._received(result, generation, page, rows, refresh),
|
||||
on_error=lambda error: self._failed(error, generation, page, rows, refresh),
|
||||
on_finished=lambda: None,
|
||||
)
|
||||
|
||||
def _received(
|
||||
self, result: Any, generation: int, page: int, prior: list[Any], refresh: bool
|
||||
) -> None:
|
||||
if generation != self._generation:
|
||||
return
|
||||
incoming = page_items(result)
|
||||
merged = {record_key(row): row for row in prior}
|
||||
before = len(merged)
|
||||
for row in incoming:
|
||||
merged[record_key(row)] = row
|
||||
rows = list(merged.values())
|
||||
total = page_total(result, -1)
|
||||
more = (
|
||||
bool(incoming)
|
||||
and len(rows) > before
|
||||
and (len(rows) < total if total >= 0 else len(incoming) >= self.page_size)
|
||||
)
|
||||
# Retain first-page metadata (scope, counts, filter choices) on refresh.
|
||||
if page == 1:
|
||||
self._refresh_source = result
|
||||
source = self._refresh_source
|
||||
if refresh and page < self._target and more:
|
||||
self._begin(page + 1, rows, refresh=True)
|
||||
return
|
||||
self.rows, self.page = rows, page
|
||||
self.total = max(len(rows), total)
|
||||
self.has_more = more
|
||||
self._render(ListSnapshot(rows, self.total, source), preserve=not self._reset_view)
|
||||
self.loading = False
|
||||
self._status()
|
||||
if self._on_finished is not None:
|
||||
self._on_finished()
|
||||
self._schedule_check()
|
||||
|
||||
def _failed(
|
||||
self, error: Exception, generation: int, page: int, rows: list[Any], refresh: bool
|
||||
) -> None:
|
||||
if generation != self._generation:
|
||||
return
|
||||
self.loading = False
|
||||
self._error = True
|
||||
self._failed_page, self._failed_rows, self._failed_refresh = page, rows, refresh
|
||||
self.summary_label.setText(f"已加载 {len(self.rows)} 条" if self.rows else "暂未加载数据")
|
||||
self.retry_button.show()
|
||||
self._on_error(error)
|
||||
if self._on_finished is not None:
|
||||
self._on_finished()
|
||||
|
||||
def _status(self) -> None:
|
||||
if self.has_more:
|
||||
self.summary_label.setText(f"已加载 {len(self.rows)} / {self.total} 条 · 下拉加载更多")
|
||||
else:
|
||||
if self.total > len(self.rows):
|
||||
self.summary_label.setText(
|
||||
f"已加载 {len(self.rows)} / {self.total} 条 · 暂无更多数据"
|
||||
)
|
||||
else:
|
||||
self.summary_label.setText(
|
||||
f"共 {len(self.rows)} 条 · 已全部加载" if self.rows else "暂无数据"
|
||||
)
|
||||
|
||||
def update_state(self, page: int, total: int) -> None:
|
||||
"""Compatibility for existing render callbacks; requests own the state."""
|
||||
del page, total
|
||||
self._status()
|
||||
|
||||
def _render(self, snapshot: ListSnapshot, *, preserve: bool) -> None:
|
||||
view = next((v for v in self._views if v.isVisible()), self._view)
|
||||
if view is None:
|
||||
self._apply(snapshot)
|
||||
return
|
||||
bar = view.verticalScrollBar()
|
||||
scroll = bar.value()
|
||||
horizontal_scroll = view.horizontalScrollBar().value()
|
||||
selected: set[str] = set()
|
||||
checks: dict[tuple[str, int], Qt.CheckState] = {}
|
||||
widget_checks: dict[tuple[str, int, int], bool] = {}
|
||||
if preserve and isinstance(view, QTableWidget):
|
||||
for row in range(view.rowCount()):
|
||||
first = view.item(row, 0)
|
||||
if first is None:
|
||||
continue
|
||||
key = record_key(first.data(Qt.ItemDataRole.UserRole))
|
||||
if first.isSelected():
|
||||
selected.add(key)
|
||||
for column in range(view.columnCount()):
|
||||
item = view.item(row, column)
|
||||
if (
|
||||
item is not None
|
||||
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable
|
||||
and item.data(Qt.ItemDataRole.CheckStateRole) is not None
|
||||
):
|
||||
checks[key, column] = item.checkState()
|
||||
widget = view.cellWidget(row, column)
|
||||
if widget is not None:
|
||||
boxes = (
|
||||
[widget]
|
||||
if isinstance(widget, QCheckBox)
|
||||
else widget.findChildren(QCheckBox)
|
||||
)
|
||||
for index, box in enumerate(boxes):
|
||||
widget_checks[key, column, index] = box.isChecked()
|
||||
blocker = QSignalBlocker(view)
|
||||
try:
|
||||
self._apply(snapshot)
|
||||
if preserve and isinstance(view, QTableWidget):
|
||||
if selected:
|
||||
view.clearSelection()
|
||||
for row in range(view.rowCount()):
|
||||
first = view.item(row, 0)
|
||||
if first is None:
|
||||
continue
|
||||
key = record_key(first.data(Qt.ItemDataRole.UserRole))
|
||||
if key in selected:
|
||||
view.selectRow(row)
|
||||
for column in range(view.columnCount()):
|
||||
item = view.item(row, column)
|
||||
if item is not None and (key, column) in checks:
|
||||
item.setCheckState(checks[key, column])
|
||||
widget = view.cellWidget(row, column)
|
||||
if widget is not None:
|
||||
boxes = (
|
||||
[widget]
|
||||
if isinstance(widget, QCheckBox)
|
||||
else widget.findChildren(QCheckBox)
|
||||
)
|
||||
for index, box in enumerate(boxes):
|
||||
if (key, column, index) in widget_checks:
|
||||
box.setChecked(widget_checks[key, column, index])
|
||||
bar.setValue(min(scroll, bar.maximum()) if preserve else bar.minimum())
|
||||
view.horizontalScrollBar().setValue(horizontal_scroll)
|
||||
finally:
|
||||
blocker.unblock()
|
||||
if isinstance(view, QTableWidget):
|
||||
view.itemSelectionChanged.emit()
|
||||
|
||||
|
||||
__all__ = ["InfiniteList", "ListSnapshot"]
|
||||
@@ -21,6 +21,7 @@ from PySide6.QtGui import (
|
||||
QResizeEvent,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QFrame,
|
||||
QGraphicsDropShadowEffect,
|
||||
@@ -43,7 +44,7 @@ from PySide6.QtWidgets import (
|
||||
from doctor_workstation import __version__
|
||||
from doctor_workstation.resources import app_icon_path, brand_lockup_path
|
||||
|
||||
from .theme import crisp_pixmap
|
||||
from . import icons
|
||||
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
|
||||
|
||||
|
||||
@@ -71,7 +72,7 @@ class _VisibleCheckBox(QCheckBox):
|
||||
)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor("#FFFFFF" if self.isEnabled() else "#98A2B3")
|
||||
color = QColor("#FFFFFF" if self.isEnabled() else "#8E8F90")
|
||||
painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||||
painter.drawLine(
|
||||
QPoint(indicator.left() + 4, indicator.center().y()),
|
||||
@@ -90,7 +91,7 @@ class _AccountLineEdit(QLineEdit):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(_round_pen("#8292B6", 1.5))
|
||||
painter.setPen(_round_pen("#6A6B6D", 1.5))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawEllipse(QRectF(24, 14.5, 8, 8))
|
||||
painter.drawRoundedRect(QRectF(19, 27, 18, 9), 4.5, 4.5)
|
||||
@@ -104,7 +105,7 @@ class _DemoCheckBox(_VisibleCheckBox):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
center = QPointF(self.width() - 9, self.height() / 2)
|
||||
painter.setPen(_round_pen("#92A0BF", 1.4))
|
||||
painter.setPen(_round_pen("#8E8F90", 1.4))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawEllipse(center, 7, 7)
|
||||
painter.drawLine(center + QPointF(0, -1), center + QPointF(0, 4))
|
||||
@@ -112,10 +113,9 @@ class _DemoCheckBox(_VisibleCheckBox):
|
||||
|
||||
|
||||
def _font(pixel_size: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
|
||||
font = QFont("Microsoft YaHei UI")
|
||||
font = QFont(QApplication.font())
|
||||
font.setPixelSize(pixel_size)
|
||||
font.setWeight(weight)
|
||||
font.setHintingPreference(QFont.HintingPreference.PreferFullHinting)
|
||||
return font
|
||||
|
||||
|
||||
@@ -291,10 +291,10 @@ class _BrandPanel(QWidget):
|
||||
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
|
||||
background.setColorAt(0.0, QColor("#FFFFFF"))
|
||||
background.setColorAt(0.7, QColor("#FEFEFF"))
|
||||
background.setColorAt(1.0, QColor("#F9FBFF"))
|
||||
background.setColorAt(0.7, QColor("#FFFFFF"))
|
||||
background.setColorAt(1.0, QColor("#F7F7F7"))
|
||||
painter.setBrush(background)
|
||||
painter.setPen(QPen(QColor("#E4E9F4"), 1))
|
||||
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||||
painter.drawRoundedRect(bounds, 24, 24)
|
||||
|
||||
width, height = float(self.width()), float(self.height())
|
||||
@@ -315,15 +315,15 @@ class _BrandPanel(QWidget):
|
||||
|
||||
tag_rect = QRectF(left, 238, 119, 40)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#F0F2FF"))
|
||||
painter.setBrush(QColor("#EEF1FA"))
|
||||
painter.drawRoundedRect(tag_rect, 11, 11)
|
||||
painter.setFont(_font(17, QFont.Weight.DemiBold))
|
||||
painter.setPen(QColor("#5265F6"))
|
||||
painter.setFont(_font(17, QFont.Weight.Medium))
|
||||
painter.setPen(QColor("#4F63D9"))
|
||||
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
|
||||
|
||||
copy_left = left + 4
|
||||
painter.setFont(_font(51, QFont.Weight.Bold))
|
||||
painter.setPen(QColor("#14224A"))
|
||||
painter.setFont(_font(48, QFont.Weight.Medium))
|
||||
painter.setPen(QColor("#1A1C1F"))
|
||||
painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
|
||||
painter.drawText(QPointF(copy_left, 424), "留在一个")
|
||||
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
|
||||
@@ -333,8 +333,8 @@ class _BrandPanel(QWidget):
|
||||
copy_left + prefix_width + 264,
|
||||
0,
|
||||
)
|
||||
highlight.setColorAt(0.0, QColor("#4258EC"))
|
||||
highlight.setColorAt(1.0, QColor("#6975FF"))
|
||||
highlight.setColorAt(0.0, QColor("#4F63D9"))
|
||||
highlight.setColorAt(1.0, QColor("#4F63D9"))
|
||||
painter.setPen(QPen(QBrush(highlight), 1))
|
||||
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
|
||||
suffix_x = (
|
||||
@@ -342,19 +342,19 @@ class _BrandPanel(QWidget):
|
||||
+ prefix_width
|
||||
+ painter.fontMetrics().horizontalAdvance("安静的界面里")
|
||||
)
|
||||
painter.setPen(QColor("#14224A"))
|
||||
painter.setPen(QColor("#1A1C1F"))
|
||||
painter.drawText(QPointF(suffix_x, 424), "。")
|
||||
|
||||
body_left = left + 6
|
||||
painter.setFont(_font(20))
|
||||
painter.setPen(QColor("#7181A7"))
|
||||
painter.setPen(QColor("#606163"))
|
||||
painter.drawText(
|
||||
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
|
||||
)
|
||||
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
|
||||
|
||||
painter.setFont(_font(16))
|
||||
painter.setPen(QColor("#7484A9"))
|
||||
painter.setPen(QColor("#6A6B6D"))
|
||||
painter.drawText(
|
||||
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
|
||||
)
|
||||
@@ -366,7 +366,7 @@ class _RevealButton(QToolButton):
|
||||
del event
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor("#8796B8" if self.isEnabled() else "#B8C0D1")
|
||||
color = QColor("#6A6B6D" if self.isEnabled() else "#BDBDBE")
|
||||
painter.setPen(_round_pen(color, 2))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
eye = QPainterPath(QPointF(7, self.height() / 2))
|
||||
@@ -392,11 +392,11 @@ class _ServerButton(QPushButton):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
|
||||
painter.setBrush(QColor("#F8FAFF") if self.underMouse() else QColor("#FFFFFF"))
|
||||
painter.setPen(QPen(QColor("#D8DFEE"), 1.5))
|
||||
painter.setBrush(QColor("#FFFFFF") if self.underMouse() else QColor("#FFFFFF"))
|
||||
painter.setPen(QPen(QColor("#E4E4E5"), 1.5))
|
||||
painter.drawRoundedRect(rect, 12, 12)
|
||||
color = QColor("#17264B" if self.isEnabled() else "#A2ABC0")
|
||||
painter.setPen(_round_pen("#7C8DB2", 1.8))
|
||||
color = QColor("#1A1C1F" if self.isEnabled() else "#8E8F90")
|
||||
painter.setPen(_round_pen("#6A6B6D", 1.8))
|
||||
center = QPointF(29, self.height() / 2)
|
||||
painter.drawEllipse(center, 8, 8)
|
||||
painter.drawEllipse(center, 2.8, 2.8)
|
||||
@@ -420,7 +420,7 @@ class _ServerButton(QPushButton):
|
||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
|
||||
"服务器设置",
|
||||
)
|
||||
painter.setPen(_round_pen("#94A1BC", 2))
|
||||
painter.setPen(_round_pen("#8E8F90", 2))
|
||||
x, y = self.width() - 28, self.height() / 2
|
||||
if self.isChecked():
|
||||
painter.drawLine(QPointF(x - 5, y + 3), QPointF(x, y - 3))
|
||||
@@ -482,12 +482,8 @@ class LoginWindow(QMainWindow):
|
||||
canvas.setStyleSheet(
|
||||
"""
|
||||
QWidget#LoginCanvas {
|
||||
color: #17264B;
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #F8FAFF, stop:0.58 #FBFCFF, stop:1 #F1F5FF
|
||||
);
|
||||
font-family: "Microsoft YaHei UI";
|
||||
color: #1A1C1F;
|
||||
background-color: #F4F6FA;
|
||||
font-size: 16px;
|
||||
}
|
||||
QWidget#LoginBrandPanel {
|
||||
@@ -496,28 +492,28 @@ class LoginWindow(QMainWindow):
|
||||
}
|
||||
QFrame#LoginCard {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E1E6F0;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 20px;
|
||||
}
|
||||
QFrame#LoginCard QFrame#SubtleCard {
|
||||
background-color: #F8FAFF;
|
||||
border: 1px solid #DCE2EF;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#LoginCard QLabel { color: #17264B; background: transparent; }
|
||||
QFrame#LoginCard QLabel[role="muted"] { color: #7382A5; }
|
||||
QFrame#LoginCard QLabel { color: #1A1C1F; background: transparent; }
|
||||
QFrame#LoginCard QLabel[role="muted"] { color: #606163; }
|
||||
QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; }
|
||||
QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; }
|
||||
QFrame#LoginCard QLineEdit,
|
||||
QFrame#LoginCard QSpinBox {
|
||||
color: #17264B;
|
||||
color: #1A1C1F;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #D6DEED;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 12px;
|
||||
padding: 0 16px;
|
||||
font-size: 17px;
|
||||
selection-background-color: #E5E9FF;
|
||||
selection-color: #17264B;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F;
|
||||
}
|
||||
QFrame#LoginCard QLineEdit#AccountEdit {
|
||||
min-height: 52px;
|
||||
@@ -525,9 +521,9 @@ class LoginWindow(QMainWindow):
|
||||
padding-left: 52px;
|
||||
}
|
||||
QFrame#LoginCard QLineEdit:hover,
|
||||
QFrame#LoginCard QSpinBox:hover { border-color: #9AA8FF; }
|
||||
QFrame#LoginCard QSpinBox:hover { border-color: #8B9AD9; }
|
||||
QFrame#LoginCard QLineEdit:focus,
|
||||
QFrame#LoginCard QSpinBox:focus { border: 1.5px solid #7080F7; }
|
||||
QFrame#LoginCard QSpinBox:focus { border: 1.5px solid #8B9AD9; }
|
||||
QFrame#LoginCard QLineEdit#PasswordEdit {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -537,31 +533,31 @@ class LoginWindow(QMainWindow):
|
||||
QFrame#LoginCard QCheckBox#DemoModeCheck { spacing: 10px; }
|
||||
QFrame#PasswordField {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #D6DEED;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#PasswordField:focus-within { border-color: #7080F7; }
|
||||
QFrame#PasswordField:focus-within { border-color: #8B9AD9; }
|
||||
QFrame#LoginCard QCheckBox {
|
||||
color: #6F7FA3;
|
||||
color: #606163;
|
||||
spacing: 13px;
|
||||
font-size: 16px;
|
||||
}
|
||||
QFrame#LoginCard QCheckBox::indicator {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #CFD8EB;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 6px;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
QFrame#LoginCard QCheckBox::indicator:hover { border-color: #7A8AF8; }
|
||||
QFrame#LoginCard QCheckBox::indicator:hover { border-color: #8B9AD9; }
|
||||
QFrame#LoginCard QCheckBox::indicator:checked {
|
||||
border-color: #6475F5;
|
||||
background-color: #6475F5;
|
||||
border-color: #4F63D9;
|
||||
background-color: #4F63D9;
|
||||
}
|
||||
QFrame#LoginCard QToolButton#PasswordReveal {
|
||||
min-width: 87px;
|
||||
max-width: 87px;
|
||||
color: #8290B0;
|
||||
color: #6A6B6D;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
@@ -570,14 +566,11 @@ class LoginWindow(QMainWindow):
|
||||
min-height: 58px;
|
||||
max-height: 58px;
|
||||
color: #FFFFFF;
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #5B6BF1, stop:0.55 #6675FA, stop:1 #5865F2
|
||||
);
|
||||
background-color: #4F63D9;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
}
|
||||
QFrame#LoginCard QPushButton#ServerSettingsToggle {
|
||||
min-height: 56px;
|
||||
@@ -585,17 +578,17 @@ class LoginWindow(QMainWindow):
|
||||
padding: 0;
|
||||
}
|
||||
QFrame#LoginCard QPushButton[variant="primary"]:hover {
|
||||
background-color: #5262ED;
|
||||
background-color: #4156C4;
|
||||
}
|
||||
QFrame#LoginCard QPushButton[variant="secondary"] {
|
||||
color: #4353BD;
|
||||
background-color: #EDF0FF;
|
||||
border: 1px solid #D3DAFC;
|
||||
color: #1A1C1F;
|
||||
background-color: #F0F0F0;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QFrame#LoginCard QPushButton[variant="secondary"]:hover {
|
||||
background-color: #DCE3FF;
|
||||
border-color: #8B98F8;
|
||||
background-color: #E4E4E5;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QScrollArea#LoginAreaScroll,
|
||||
QScrollArea#LoginAreaScroll > QWidget > QWidget {
|
||||
@@ -629,6 +622,12 @@ class LoginWindow(QMainWindow):
|
||||
spacing = 20
|
||||
self.login_root.setContentsMargins(*margins)
|
||||
self.login_root.setSpacing(spacing)
|
||||
# Keep the form fully visible when the 60/40 desktop split would
|
||||
# otherwise crop its fixed-width card. Small windows focus on login.
|
||||
self.brand_panel.setVisible(width >= 1120)
|
||||
self.login_area_layout.setContentsMargins(
|
||||
0, min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
|
||||
)
|
||||
super().resizeEvent(event)
|
||||
|
||||
def _build_brand_panel(self) -> QWidget:
|
||||
@@ -647,20 +646,19 @@ class LoginWindow(QMainWindow):
|
||||
area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
area.setMinimumWidth(492)
|
||||
content = QWidget()
|
||||
content.setObjectName("LoginAreaContent")
|
||||
area.setWidget(content)
|
||||
self.login_scroll = area
|
||||
outer = QVBoxLayout(content)
|
||||
# The supplied 1536×1024 capture contains a 60 px native title bar.
|
||||
# Its card begins at y=198, i.e. y=138 in the 1536×964 client area.
|
||||
# The root starts at y=34, so the deterministic lead inset is 104 px.
|
||||
outer.setContentsMargins(0, 104, 0, 0)
|
||||
self.login_area_layout = outer
|
||||
outer.setContentsMargins(0, 40, 0, 12)
|
||||
|
||||
self.card = QFrame()
|
||||
self.card.setObjectName("LoginCard")
|
||||
self.card.setFixedWidth(480)
|
||||
self.card.setMinimumHeight(694)
|
||||
self.card.setMinimumHeight(620)
|
||||
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
card_shadow = QGraphicsDropShadowEffect(self.card)
|
||||
card_shadow.setBlurRadius(38)
|
||||
@@ -675,13 +673,13 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
title = QLabel("欢迎回来")
|
||||
title.setObjectName("LoginTitle")
|
||||
title.setStyleSheet("color:#14224A; font-size:33px; font-weight:700;")
|
||||
title.setStyleSheet("color:#1A1C1F; font-size:30px; font-weight:500;")
|
||||
title.setContentsMargins(1, -3, 0, 3)
|
||||
title.setFixedHeight(46)
|
||||
card_layout.addWidget(title)
|
||||
subtitle = QLabel("使用医生账号登录工作站")
|
||||
subtitle.setProperty("role", "muted")
|
||||
subtitle.setStyleSheet("color:#7382A5; font-size:18px;")
|
||||
subtitle.setStyleSheet("color:#606163; font-size:18px;")
|
||||
subtitle.setContentsMargins(1, 8, 0, 0)
|
||||
subtitle.setFixedHeight(27)
|
||||
card_layout.addWidget(subtitle)
|
||||
@@ -691,7 +689,7 @@ class LoginWindow(QMainWindow):
|
||||
card_layout.addWidget(self.error_banner)
|
||||
|
||||
account_label = QLabel("账号")
|
||||
account_label.setStyleSheet("color:#17264B; font-size:18px; font-weight:600;")
|
||||
account_label.setStyleSheet("color:#1A1C1F; font-size:16px; font-weight:500;")
|
||||
account_label.setContentsMargins(0, -2, 0, 2)
|
||||
account_label.setFixedHeight(24)
|
||||
card_layout.addWidget(account_label)
|
||||
@@ -707,7 +705,7 @@ class LoginWindow(QMainWindow):
|
||||
card_layout.addSpacing(21)
|
||||
|
||||
password_label = QLabel("密码")
|
||||
password_label.setStyleSheet("color:#17264B; font-size:18px; font-weight:600;")
|
||||
password_label.setStyleSheet("color:#1A1C1F; font-size:16px; font-weight:500;")
|
||||
password_label.setContentsMargins(0, -3, 0, 3)
|
||||
password_label.setFixedHeight(24)
|
||||
card_layout.addWidget(password_label)
|
||||
@@ -777,16 +775,16 @@ class LoginWindow(QMainWindow):
|
||||
divider.setSpacing(18)
|
||||
line_left = QFrame()
|
||||
line_left.setFrameShape(QFrame.Shape.HLine)
|
||||
line_left.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
||||
line_left.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
|
||||
divider.addWidget(line_left, 1)
|
||||
divider_text = QLabel("或")
|
||||
divider_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
divider_text.setStyleSheet("color:#7C89A8; font-size:16px;")
|
||||
divider_text.setStyleSheet("color:#6A6B6D; font-size:16px;")
|
||||
divider_text.setFixedSize(38, 22)
|
||||
divider.addWidget(divider_text)
|
||||
line_right = QFrame()
|
||||
line_right.setFrameShape(QFrame.Shape.HLine)
|
||||
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
||||
line_right.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
|
||||
divider.addWidget(line_right, 1)
|
||||
debug_settings_layout.addLayout(divider)
|
||||
debug_settings_layout.addSpacing(20)
|
||||
@@ -873,7 +871,7 @@ class LoginWindow(QMainWindow):
|
||||
footnote_row.addWidget(lock)
|
||||
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
|
||||
footnote.setProperty("role", "muted")
|
||||
footnote.setStyleSheet("color:#7A89AA; font-size:15px;")
|
||||
footnote.setStyleSheet("color:#6A6B6D; font-size:15px;")
|
||||
footnote.setContentsMargins(0, -4, 0, 4)
|
||||
footnote.setWordWrap(True)
|
||||
footnote.setFixedHeight(40)
|
||||
@@ -882,12 +880,12 @@ class LoginWindow(QMainWindow):
|
||||
self.version_label = QLabel(f"当前版本 {__version__}")
|
||||
self.version_label.setObjectName("LoginVersionLabel")
|
||||
self.version_label.setProperty("role", "muted")
|
||||
self.version_label.setStyleSheet("color:#8B98B5; font-size:13px;")
|
||||
self.version_label.setStyleSheet("color:#6A6B6D; font-size:13px;")
|
||||
self.version_label.setContentsMargins(0, 8, 0, 0)
|
||||
card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
outer.addWidget(
|
||||
self.card, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
||||
self.card, 0, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
outer.addStretch(1)
|
||||
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
|
||||
@@ -899,28 +897,11 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
@staticmethod
|
||||
def _account_icon() -> QIcon:
|
||||
pixmap = crisp_pixmap(24)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(_round_pen("#8292B6", 2))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawEllipse(QPointF(12, 7.5), 4.2, 4.2)
|
||||
painter.drawRoundedRect(QRectF(4.5, 14, 15, 7), 3.5, 3.5)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
return icons.icon("user", "muted", 24)
|
||||
|
||||
@staticmethod
|
||||
def _lock_icon() -> QIcon:
|
||||
pixmap = crisp_pixmap(20)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(_round_pen("#8FA0C4", 1.6))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRoundedRect(QRectF(5, 8, 10, 9), 2, 2)
|
||||
painter.drawArc(QRectF(7, 3, 6, 9), 0, 180 * 16)
|
||||
painter.drawLine(QPointF(10, 11), QPointF(10, 14))
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
return icons.icon("lock", "muted", 20)
|
||||
|
||||
def _restore_settings(self) -> None:
|
||||
configured_account = getattr(self.config, "remembered_account", "")
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Motion tokens and helpers.
|
||||
|
||||
The product had two `QGraphicsOpacityEffect` uses and no `QPropertyAnimation`
|
||||
at all, so every state change was an instant cut: pages replaced each other
|
||||
between one frame and the next, drawers appeared fully formed, toasts blinked
|
||||
in and out. Nothing was slow - it just gave the eye no continuity to follow,
|
||||
which is what reads as "not smooth" however fast the code underneath is.
|
||||
|
||||
Everything here is short. A workstation is used all day, so transitions are
|
||||
tuned to be felt rather than watched: 110-260 ms, ease-out on entry, and travel
|
||||
measured in single-digit pixels. Anything longer starts costing the user time.
|
||||
|
||||
Qt stylesheets have no `transition` property, so this is `QPropertyAnimation`
|
||||
throughout. Two rules keep that safe:
|
||||
|
||||
* an animation must be owned, or PySide garbage-collects it mid-flight and the
|
||||
widget freezes half-faded - :func:`_own` parks it on the target;
|
||||
* a `QGraphicsOpacityEffect` forces the whole widget subtree through an
|
||||
offscreen render path, which would make a table scroll badly for the rest of
|
||||
the session - every fade here removes its effect when it finishes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QAbstractAnimation,
|
||||
QEasingCurve,
|
||||
QEvent,
|
||||
QObject,
|
||||
QPoint,
|
||||
QPropertyAnimation,
|
||||
Qt,
|
||||
QTimer,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QGraphicsOpacityEffect,
|
||||
QStackedWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
#: Durations in milliseconds.
|
||||
FAST = 110 # hover-scale feedback, small fades
|
||||
BASE = 170 # the default: page and panel transitions
|
||||
SLOW = 260 # large travel, e.g. a drawer crossing the workspace
|
||||
|
||||
#: Entering elements decelerate; elements that move between two known places
|
||||
#: ease in and out; large travel gets a longer tail so it never looks linear.
|
||||
EASE_ENTER = QEasingCurve.Type.OutCubic
|
||||
EASE_MOVE = QEasingCurve.Type.InOutCubic
|
||||
EASE_TRAVEL = QEasingCurve.Type.OutQuint
|
||||
|
||||
#: How far an entering surface rises, in device-independent pixels. Kept small
|
||||
#: on purpose: a page that slides a long way reads as a slideshow, not an app.
|
||||
RISE = 8
|
||||
|
||||
|
||||
def reduced_motion() -> bool:
|
||||
"""Whether animation should be skipped entirely.
|
||||
|
||||
Off by default under the offscreen platform so widget grabs in tests and in
|
||||
the packaging smoke checks capture a settled frame rather than a frame from
|
||||
the middle of a fade. ``DOCTOR_MOTION=on`` / ``off`` overrides either way.
|
||||
"""
|
||||
|
||||
override = os.getenv("DOCTOR_MOTION", "").strip().lower()
|
||||
if override in {"off", "0", "false", "none", "reduce"}:
|
||||
return True
|
||||
if override in {"on", "1", "true", "full"}:
|
||||
return False
|
||||
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
|
||||
|
||||
|
||||
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
|
||||
"""Park an animation on its target so Python does not collect it early."""
|
||||
|
||||
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
|
||||
previous = running.get(key)
|
||||
if previous is not None:
|
||||
previous.stop()
|
||||
running[key] = animation
|
||||
target._doctor_motion = running
|
||||
return animation
|
||||
|
||||
|
||||
def animate(
|
||||
target: Any,
|
||||
prop: bytes,
|
||||
start: Any,
|
||||
end: Any,
|
||||
*,
|
||||
duration: int = BASE,
|
||||
easing: QEasingCurve.Type = EASE_ENTER,
|
||||
key: str | None = None,
|
||||
owner: QWidget | None = None,
|
||||
on_finished: Callable[[], None] | None = None,
|
||||
) -> QPropertyAnimation | None:
|
||||
"""Animate one Qt property, or apply the end value outright if motion is off.
|
||||
|
||||
``owner`` keeps the animation alive independently of ``target``. Fades
|
||||
animate a ``QGraphicsOpacityEffect`` that is deleted the moment the fade
|
||||
ends, so parenting the animation to the effect would destroy the animation
|
||||
from inside its own ``finished`` emission.
|
||||
"""
|
||||
|
||||
if reduced_motion():
|
||||
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return None
|
||||
animation = QPropertyAnimation(target, prop, owner if owner is not None else target)
|
||||
animation.setDuration(duration)
|
||||
animation.setEasingCurve(easing)
|
||||
animation.setStartValue(start)
|
||||
animation.setEndValue(end)
|
||||
if on_finished is not None:
|
||||
animation.finished.connect(on_finished)
|
||||
_own(owner if owner is not None else target, key or prop.decode(), animation)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
|
||||
return animation
|
||||
|
||||
|
||||
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
|
||||
effect = widget.graphicsEffect()
|
||||
if not isinstance(effect, QGraphicsOpacityEffect):
|
||||
effect = QGraphicsOpacityEffect(widget)
|
||||
widget.setGraphicsEffect(effect)
|
||||
effect.setEnabled(True)
|
||||
return effect
|
||||
|
||||
|
||||
def _drop_effect(widget: QWidget) -> None:
|
||||
"""Detach the opacity effect once a fade is done.
|
||||
|
||||
Leaving it attached keeps the widget on Qt's offscreen composite path, which
|
||||
is exactly the sort of quiet, permanent frame-rate tax this module exists to
|
||||
avoid introducing.
|
||||
|
||||
The detach is deferred by one event-loop turn on purpose. ``finished`` is
|
||||
emitted from inside the animation, and ``setGraphicsEffect(None)`` deletes
|
||||
the old effect immediately - tearing down the object graph underneath a
|
||||
signal that is still being delivered.
|
||||
"""
|
||||
|
||||
def detach() -> None:
|
||||
try:
|
||||
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
|
||||
widget.setGraphicsEffect(None)
|
||||
except RuntimeError: # the widget went away while the fade was running
|
||||
pass
|
||||
|
||||
QTimer.singleShot(0, detach)
|
||||
|
||||
|
||||
def fade_in(
|
||||
widget: QWidget,
|
||||
*,
|
||||
duration: int = BASE,
|
||||
start: float = 0.0,
|
||||
easing: QEasingCurve.Type = EASE_ENTER,
|
||||
) -> None:
|
||||
"""Fade a widget up to full opacity, showing it first if needed."""
|
||||
|
||||
if reduced_motion():
|
||||
widget.show()
|
||||
return
|
||||
effect = _opacity_effect(widget)
|
||||
effect.setOpacity(start)
|
||||
widget.show()
|
||||
animate(
|
||||
effect,
|
||||
b"opacity",
|
||||
start,
|
||||
1.0,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
key="fade",
|
||||
owner=widget,
|
||||
on_finished=lambda: _drop_effect(widget),
|
||||
)
|
||||
|
||||
|
||||
def fade_out(
|
||||
widget: QWidget,
|
||||
*,
|
||||
duration: int = FAST,
|
||||
hide: bool = True,
|
||||
on_finished: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Fade a widget down, optionally hiding it when the fade completes."""
|
||||
|
||||
if reduced_motion():
|
||||
if hide:
|
||||
widget.hide()
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return
|
||||
effect = _opacity_effect(widget)
|
||||
|
||||
def done() -> None:
|
||||
if hide:
|
||||
widget.hide()
|
||||
_drop_effect(widget)
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
animate(
|
||||
effect,
|
||||
b"opacity",
|
||||
float(effect.opacity()),
|
||||
0.0,
|
||||
duration=duration,
|
||||
easing=EASE_MOVE,
|
||||
key="fade",
|
||||
owner=widget,
|
||||
on_finished=done,
|
||||
)
|
||||
|
||||
|
||||
def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
|
||||
"""Fade a surface in while it settles upward by a few pixels.
|
||||
|
||||
The rise is what makes a swap read as one surface replacing another rather
|
||||
than as a repaint; keeping it under ten pixels stops it becoming a gesture
|
||||
the user has to wait out.
|
||||
"""
|
||||
|
||||
if reduced_motion():
|
||||
widget.show()
|
||||
return
|
||||
fade_in(widget, duration=duration)
|
||||
if rise:
|
||||
origin = widget.pos()
|
||||
widget.move(origin + QPoint(0, rise))
|
||||
animate(
|
||||
widget,
|
||||
b"pos",
|
||||
widget.pos(),
|
||||
origin,
|
||||
duration=duration,
|
||||
easing=EASE_ENTER,
|
||||
key="enter",
|
||||
)
|
||||
|
||||
|
||||
def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None:
|
||||
"""Change the current page of a stack with a short cross-fade.
|
||||
|
||||
``QStackedWidget`` swaps pages between two frames with nothing in between,
|
||||
which is the single most-seen transition in this product - it happens on
|
||||
every sidebar click and on every list that toggles to its empty state.
|
||||
"""
|
||||
|
||||
if index < 0 or index >= stack.count() or stack.currentIndex() == index:
|
||||
stack.setCurrentIndex(index)
|
||||
return
|
||||
stack.setCurrentIndex(index)
|
||||
page = stack.currentWidget()
|
||||
if page is None or reduced_motion():
|
||||
return
|
||||
enter(page, rise=rise)
|
||||
|
||||
|
||||
# --- Smooth scrolling -----------------------------------------------------
|
||||
|
||||
#: One wheel notch travels this far, and takes this long to get there. Qt's
|
||||
#: default is an instant jump of three lines per notch, which on a long clinical
|
||||
#: record is the single jerkiest thing in the interface.
|
||||
SCROLL_STEP = 120
|
||||
SCROLL_MS = 190
|
||||
|
||||
|
||||
class _SmoothScroller(QObject):
|
||||
"""Animate a scroll area's wheel movement instead of jumping to it."""
|
||||
|
||||
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
|
||||
super().__init__(area)
|
||||
self._bar = (
|
||||
area.verticalScrollBar()
|
||||
if orientation is Qt.Orientation.Vertical
|
||||
else area.horizontalScrollBar()
|
||||
)
|
||||
self._target = self._bar.value()
|
||||
self._animation = QPropertyAnimation(self._bar, b"value", self)
|
||||
self._animation.setEasingCurve(EASE_ENTER)
|
||||
self._animation.setDuration(SCROLL_MS)
|
||||
# Keyboard, programmatic and drag movements must not be fought over: when
|
||||
# nothing is animating, the wheel target follows wherever the bar went.
|
||||
self._bar.valueChanged.connect(self._sync_target)
|
||||
area.viewport().installEventFilter(self)
|
||||
|
||||
def _sync_target(self, value: int) -> None:
|
||||
if self._animation.state() != QAbstractAnimation.State.Running:
|
||||
self._target = value
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||||
del watched
|
||||
if event.type() is not QEvent.Type.Wheel or reduced_motion():
|
||||
return False
|
||||
delta = event.angleDelta().y() or event.angleDelta().x()
|
||||
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
return False
|
||||
lower, upper = self._bar.minimum(), self._bar.maximum()
|
||||
# Re-clamp first: the range can shrink underneath a running animation
|
||||
# when the content behind it reloads, which would otherwise leave the
|
||||
# pending target past the end of the new content.
|
||||
self._target = max(lower, min(upper, self._target))
|
||||
target = self._target - round(delta / 120.0 * SCROLL_STEP)
|
||||
target = max(lower, min(upper, target))
|
||||
# At either end, hand the wheel back so an enclosing scroll area still
|
||||
# gets it - swallowing it there is what makes nested panes feel stuck.
|
||||
if target == self._target:
|
||||
return False
|
||||
self._target = target
|
||||
self._animation.stop()
|
||||
self._animation.setStartValue(self._bar.value())
|
||||
self._animation.setEndValue(target)
|
||||
self._animation.start()
|
||||
return True
|
||||
|
||||
|
||||
def install_smooth_scroll(
|
||||
area: QAbstractScrollArea,
|
||||
*,
|
||||
orientation: Qt.Orientation = Qt.Orientation.Vertical,
|
||||
) -> None:
|
||||
"""Give a scroll area eased wheel scrolling."""
|
||||
|
||||
if getattr(area, "_doctor_smooth_scroll", None) is not None:
|
||||
return
|
||||
area._doctor_smooth_scroll = _SmoothScroller(area, orientation=orientation)
|
||||
|
||||
|
||||
def press_feedback(widget: QWidget) -> None:
|
||||
"""Mark a widget so the shared stylesheet can give it a pressed transform.
|
||||
|
||||
Qt has no CSS transitions, so the visual step itself lives in the palette's
|
||||
pressed state; this only tags the widget as one that should get it.
|
||||
"""
|
||||
|
||||
widget.setProperty("motionPress", True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BASE",
|
||||
"EASE_ENTER",
|
||||
"EASE_MOVE",
|
||||
"EASE_TRAVEL",
|
||||
"FAST",
|
||||
"RISE",
|
||||
"SLOW",
|
||||
"animate",
|
||||
"enter",
|
||||
"fade_in",
|
||||
"fade_out",
|
||||
"install_smooth_scroll",
|
||||
"press_feedback",
|
||||
"reduced_motion",
|
||||
"switch_stack",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
"""Scoped surfaces for the approved patient order-management workspace."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family
|
||||
|
||||
|
||||
def patient_orders_stylesheet() -> str:
|
||||
return Template(_ORDERS).substitute(body=body_family())
|
||||
|
||||
|
||||
_ORDERS = """
|
||||
#PatientOrdersWorkspace { background: transparent; color: #273244; }
|
||||
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
|
||||
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
|
||||
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
|
||||
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||
#PatientOrdersWorkspace QFrame#OrderFilterCard,
|
||||
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
|
||||
#PatientOrdersWorkspace QFrame#OrderTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
|
||||
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
|
||||
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
|
||||
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||
}
|
||||
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
|
||||
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
|
||||
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
|
||||
#PatientOrdersWorkspace QDateEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
|
||||
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QLineEdit QToolButton {
|
||||
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
|
||||
}
|
||||
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
|
||||
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
|
||||
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
|
||||
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
|
||||
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||
#PatientOrdersWorkspace QCalendarWidget {
|
||||
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||
#PatientOrdersWorkspace QCalendarWidget QToolButton {
|
||||
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
|
||||
font-family: "$body"; font-size: 13px; color: #273244;
|
||||
}
|
||||
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
|
||||
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
|
||||
}
|
||||
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
|
||||
#PatientOrdersWorkspace QCheckBox::indicator {
|
||||
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
|
||||
}
|
||||
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
|
||||
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
|
||||
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
|
||||
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
|
||||
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
|
||||
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
|
||||
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
|
||||
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable {
|
||||
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
|
||||
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
|
||||
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
|
||||
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
|
||||
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; padding: 5px;
|
||||
}
|
||||
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
|
||||
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
|
||||
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Local typography and surfaces for the approved consultation-progress page."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family
|
||||
|
||||
|
||||
def patient_progress_stylesheet() -> str:
|
||||
return Template(_PROGRESS).substitute(body=body_family())
|
||||
|
||||
|
||||
_PROGRESS = """
|
||||
#PatientProgressWorkspace { background: transparent; color: #273244; }
|
||||
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
|
||||
#PatientProgressWorkspace QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
|
||||
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||
}
|
||||
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
|
||||
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
|
||||
#PatientProgressWorkspace QFrame#ProgressQueueCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
|
||||
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
|
||||
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
|
||||
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
|
||||
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
|
||||
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
|
||||
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
|
||||
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
|
||||
#PatientProgressWorkspace QHeaderView::section {
|
||||
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def patients_chrome_stylesheet() -> str:
|
||||
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
def patient_list_stylesheet() -> str:
|
||||
return Template(_LIST).substitute(body=body_family())
|
||||
|
||||
|
||||
_CHROME = """
|
||||
#PatientsPage { background: #F3F7FD; }
|
||||
#PatientsPage QWidget#PageHeader QLabel {
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
|
||||
}
|
||||
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PatientsPage QPushButton#PatientRefreshButton {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
min-height: 38px; max-height: 38px; padding: 0 20px;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||
}
|
||||
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
|
||||
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
|
||||
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||
}
|
||||
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
|
||||
"""
|
||||
|
||||
_LIST = """
|
||||
#PatientListWorkspace { background: transparent; color: #273244; }
|
||||
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
|
||||
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
|
||||
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
|
||||
#PatientSearchToolbar QPushButton {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
|
||||
color: #5D6B80; font-size: 13px;
|
||||
}
|
||||
#PatientListWorkspace QFrame#PatientFilterCard,
|
||||
#PatientListWorkspace QFrame#PatientSummaryStrip,
|
||||
#PatientListWorkspace QFrame#PatientListCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||
}
|
||||
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
|
||||
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
|
||||
}
|
||||
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
|
||||
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton {
|
||||
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||
}
|
||||
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
|
||||
#PatientSearchToolbar QLineEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
|
||||
}
|
||||
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
|
||||
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
|
||||
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
|
||||
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
|
||||
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
|
||||
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
|
||||
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
|
||||
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||
selection-background-color: #DCEAFF;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||
#PatientListWorkspace QDateEdit:disabled {
|
||||
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
|
||||
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"] {
|
||||
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
|
||||
}
|
||||
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
|
||||
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
|
||||
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
|
||||
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
|
||||
#PatientListWorkspace QTableWidget#PatientTable {
|
||||
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
|
||||
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
|
||||
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"],
|
||||
#PatientListWorkspace QToolButton#RowActionsMore {
|
||||
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
|
||||
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
|
||||
}
|
||||
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
|
||||
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
|
||||
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
|
||||
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
|
||||
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
|
||||
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
|
||||
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
|
||||
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
|
||||
}
|
||||
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Local technology-blue rendering for the approved prescription library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QPushButton,
|
||||
QStyle,
|
||||
QStyledItemDelegate,
|
||||
QToolTip,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
from .widgets import SortableTable, first_value
|
||||
|
||||
|
||||
def library_stylesheet() -> str:
|
||||
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
|
||||
# Defer the page helper lookup so importing this module alone does not
|
||||
# recurse through pages.__init__ and the library page's own style import.
|
||||
from .pages.prescriptions import _painted_icon
|
||||
|
||||
result = QIcon()
|
||||
for mode, tint in (
|
||||
(QIcon.Mode.Normal, color),
|
||||
(QIcon.Mode.Active, color),
|
||||
(QIcon.Mode.Selected, color),
|
||||
(QIcon.Mode.Disabled, "#A4ADBA"),
|
||||
):
|
||||
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
|
||||
for state in (QIcon.State.Off, QIcon.State.On):
|
||||
result.addPixmap(pixmap, mode, state)
|
||||
return result
|
||||
|
||||
|
||||
class LibraryComboBox(QComboBox):
|
||||
"""Keep native combo interaction while painting the local caret."""
|
||||
|
||||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
try:
|
||||
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
|
||||
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
|
||||
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
|
||||
finally:
|
||||
painter.end()
|
||||
|
||||
|
||||
class LibraryTable(SortableTable):
|
||||
"""Repaint existing action widgets and restore selection by template ID."""
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
|
||||
super().set_rows(rows)
|
||||
# The shared table restores an unsorted row index after enabling sort.
|
||||
# Locate the same source object in the finished visual order instead.
|
||||
if selected_id is not None:
|
||||
for row_index in range(self.rowCount()):
|
||||
item = self.item(row_index, 0)
|
||||
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
row_id = first_value(row, "id", "template_id", default=None)
|
||||
if row_id is not None and str(row_id) == str(selected_id):
|
||||
self.selectRow(row_index)
|
||||
return
|
||||
self.clearSelection()
|
||||
self.setCurrentCell(-1, -1)
|
||||
|
||||
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
|
||||
super().setCellWidget(row, column, widget)
|
||||
if widget is None or column != 9:
|
||||
return
|
||||
widget.setObjectName("PrescriptionLibraryTableCellHost")
|
||||
widget.setAutoFillBackground(False)
|
||||
glyphs = {
|
||||
"查看处方模板": "eye",
|
||||
"AI 解释": "spark",
|
||||
"编辑处方模板": "pencil",
|
||||
"删除处方模板": "trash",
|
||||
}
|
||||
for button in widget.findChildren(QPushButton):
|
||||
if not button.property("rowAction"):
|
||||
continue
|
||||
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
|
||||
if glyph is not None:
|
||||
color = "#BE4657" if button.property("danger") else "#1769E8"
|
||||
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
|
||||
|
||||
|
||||
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
|
||||
font = QFont(heading_family() if medium else body_family())
|
||||
font.setPixelSize(size)
|
||||
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
|
||||
return font
|
||||
|
||||
|
||||
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
|
||||
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
|
||||
|
||||
|
||||
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||
"""Wrap between complete herb entries; elide only the displayed lines."""
|
||||
entries = text.split("、")
|
||||
first = entries[0]
|
||||
next_index = 1
|
||||
while next_index < min(2, len(entries)):
|
||||
candidate = first + "、" + entries[next_index]
|
||||
if metrics.horizontalAdvance(candidate) > width:
|
||||
break
|
||||
first = candidate
|
||||
next_index += 1
|
||||
lines = [_elide(first, metrics, width)]
|
||||
if next_index < len(entries):
|
||||
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
|
||||
return lines
|
||||
|
||||
|
||||
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||
if metrics.horizontalAdvance(text) <= width:
|
||||
return [text]
|
||||
split = 1
|
||||
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
|
||||
split += 1
|
||||
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
|
||||
|
||||
|
||||
class LibraryItemDelegate(QStyledItemDelegate):
|
||||
"""Paint the ten existing columns without changing display or source roles."""
|
||||
|
||||
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
|
||||
size = super().sizeHint(option, index)
|
||||
size.setHeight(78)
|
||||
return size
|
||||
|
||||
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
|
||||
if event is not None and index.isValid() and index.column() != 9:
|
||||
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
|
||||
if text:
|
||||
QToolTip.showText(event.globalPos(), str(text), view)
|
||||
return True
|
||||
return super().helpEvent(event, view, option, index)
|
||||
|
||||
@staticmethod
|
||||
def _paint_lines(
|
||||
painter: QPainter,
|
||||
rect: QRectF,
|
||||
lines: list[str],
|
||||
*,
|
||||
secondary_muted: bool = False,
|
||||
) -> None:
|
||||
line_height = 26
|
||||
top = rect.center().y() - len(lines) * line_height / 2
|
||||
for line_index, line in enumerate(lines):
|
||||
if secondary_muted and line_index:
|
||||
painter.setFont(_library_font(13))
|
||||
painter.setPen(QColor("#5D6B80"))
|
||||
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
|
||||
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
|
||||
|
||||
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
||||
painter.save()
|
||||
try:
|
||||
painter.setClipRect(option.rect)
|
||||
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
|
||||
painter.fillRect(option.rect, QColor(background))
|
||||
painter.setPen(QColor("#E6EDF6"))
|
||||
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||
if selected and index.column() == 0:
|
||||
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
|
||||
painter.fillRect(stripe, QColor("#1769E8"))
|
||||
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||
painter.setPen(QColor("#75A5F0"))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
||||
|
||||
column = index.column()
|
||||
if column == 9:
|
||||
return
|
||||
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
|
||||
if rect.width() <= 0:
|
||||
return
|
||||
value = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
text = "" if value is None else str(value)
|
||||
font = _library_font(medium=column == 1)
|
||||
painter.setFont(font)
|
||||
painter.setPen(QColor("#273244"))
|
||||
metrics = QFontMetrics(font)
|
||||
width = int(rect.width())
|
||||
|
||||
if column == 2:
|
||||
painter.setFont(_library_font(13))
|
||||
metrics = painter.fontMetrics()
|
||||
label = _elide(text, metrics, width - 12)
|
||||
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
|
||||
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EAF2FF"))
|
||||
painter.drawRoundedRect(pill, 4, 4)
|
||||
painter.setPen(QColor("#1769E8"))
|
||||
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
||||
elif column == 6:
|
||||
glyph = "users" if text == "所有人可见" else "lock"
|
||||
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
|
||||
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
|
||||
label_rect = rect.adjusted(22, 0, 0, 0)
|
||||
painter.drawText(
|
||||
label_rect,
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||
_elide(text, metrics, int(label_rect.width())),
|
||||
)
|
||||
elif column == 4:
|
||||
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
|
||||
elif column == 8:
|
||||
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
|
||||
lines = [_elide(parts[0], metrics, width)]
|
||||
if len(parts) > 1:
|
||||
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
|
||||
self._paint_lines(painter, rect, lines, secondary_muted=True)
|
||||
elif column == 1:
|
||||
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
|
||||
else:
|
||||
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
painter.drawText(rect, alignment, _elide(text, metrics, width))
|
||||
finally:
|
||||
painter.restore()
|
||||
|
||||
|
||||
_LIBRARY = """
|
||||
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
|
||||
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
|
||||
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
|
||||
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
|
||||
background: transparent; border: 0;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
|
||||
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
|
||||
#PrescriptionLibraryPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QFrame#MetricCard,
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
|
||||
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
|
||||
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
|
||||
border: 0; border-radius: 0; background: transparent;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
|
||||
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
|
||||
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
|
||||
background: #155BCC; border-color: #155BCC;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
|
||||
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
|
||||
background: #124EA9; border-color: #124EA9;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
|
||||
#PrescriptionLibraryPage QPushButton:disabled,
|
||||
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
|
||||
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
|
||||
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
|
||||
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
|
||||
#PrescriptionLibraryPage QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
|
||||
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
|
||||
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||
border-radius: 0; color: #273244; font-size: 14px;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
|
||||
color: #1769E8; border-bottom-color: #1769E8;
|
||||
}
|
||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
|
||||
color: #1555B6; background: #F2F7FF;
|
||||
}
|
||||
#PrescriptionLibraryPage QTableWidget {
|
||||
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
|
||||
#PrescriptionLibraryPage QHeaderView::section {
|
||||
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
|
||||
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||
}
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
|
||||
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
|
||||
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||
}
|
||||
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
|
||||
|
||||
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
|
||||
|
||||
from string import Template
|
||||
|
||||
from .reception_style import body_family, heading_family
|
||||
|
||||
|
||||
def prescriptions_stylesheet() -> str:
|
||||
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
|
||||
|
||||
|
||||
_PRESCRIPTIONS = """
|
||||
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
|
||||
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
|
||||
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
|
||||
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
|
||||
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="pageTitle"] {
|
||||
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="sectionTitle"] {
|
||||
font-family: "$heading"; font-size: 14px; font-weight: 600;
|
||||
}
|
||||
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
|
||||
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
|
||||
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||
}
|
||||
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
|
||||
#PrescriptionsPage QPushButton {
|
||||
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
|
||||
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
|
||||
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
|
||||
background: #155BCC; border-color: #155BCC;
|
||||
}
|
||||
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
|
||||
background: #124EA9; border-color: #124EA9;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
|
||||
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
|
||||
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||
}
|
||||
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
|
||||
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||
}
|
||||
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
|
||||
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
|
||||
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
|
||||
#PrescriptionsPage QComboBox QAbstractItemView {
|
||||
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||
}
|
||||
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
|
||||
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
|
||||
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
|
||||
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||
#PrescriptionsPage QTableWidget#PrescriptionTable {
|
||||
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||
}
|
||||
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
|
||||
#PrescriptionsPage QHeaderView::section {
|
||||
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||
}
|
||||
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"] {
|
||||
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
|
||||
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
|
||||
background: transparent; border-color: transparent; color: #A4ADBA;
|
||||
}
|
||||
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
|
||||
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
|
||||
"""
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Palette and real font families for the approved reception page only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.resources import resource_path
|
||||
|
||||
TECH_BLUE = {
|
||||
"accent": "#1769E8",
|
||||
"accent_hover": "#155BCC",
|
||||
"accent_pressed": "#124EA9",
|
||||
"selection": "#EAF2FF",
|
||||
"selected_text": "#1555B6",
|
||||
"canvas": "#F3F7FD",
|
||||
"sidebar": "#EDF4FF",
|
||||
"surface": "#FFFFFF",
|
||||
"raised": "#F7FAFE",
|
||||
"line": "#DBE5F2",
|
||||
"text": "#273244",
|
||||
"heading": "#202C3F",
|
||||
"muted": "#5D6B80",
|
||||
"focus": "#75A5F0",
|
||||
}
|
||||
|
||||
|
||||
def _families() -> dict[str, str]:
|
||||
"""Resolve after Qt starts; font registration does not change its theme."""
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
raise RuntimeError("Reception font families require a QApplication")
|
||||
cached = getattr(app, "_reception_font_families", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
heading = getattr(app, "_doctor_bundled_font_family", None)
|
||||
if not heading:
|
||||
font_path = resource_path("fonts", "NotoSansSC-VF.ttf")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
registered = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
|
||||
if registered:
|
||||
heading = registered[0]
|
||||
app._doctor_bundled_font_family = heading
|
||||
|
||||
available = set(QFontDatabase.families())
|
||||
# Qt's offscreen platform does not enumerate Windows fonts automatically.
|
||||
# Register only files already installed on this machine, never substitutes
|
||||
# downloaded or installed into the user's Windows font registry.
|
||||
if sys.platform == "win32":
|
||||
font_dir = (
|
||||
Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or "C:/Windows") / "Fonts"
|
||||
)
|
||||
for family, filenames in (
|
||||
("Microsoft YaHei UI", ("msyh.ttc",)),
|
||||
("Segoe UI", ("segoeui.ttf", "seguisb.ttf")),
|
||||
):
|
||||
if family in available:
|
||||
continue
|
||||
for filename in filenames:
|
||||
font_path = font_dir / filename
|
||||
if not font_path.is_file():
|
||||
continue
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
if font_id >= 0:
|
||||
available.update(QFontDatabase.applicationFontFamilies(font_id))
|
||||
|
||||
fallback = heading or next(
|
||||
(
|
||||
family
|
||||
for family in ("Noto Sans SC", "Noto Sans CJK SC", "PingFang SC")
|
||||
if family in available
|
||||
),
|
||||
app.font().family(),
|
||||
)
|
||||
resolved = {
|
||||
"body": "Microsoft YaHei UI" if "Microsoft YaHei UI" in available else fallback,
|
||||
"heading": heading or fallback,
|
||||
"number": "Segoe UI" if "Segoe UI" in available else fallback,
|
||||
}
|
||||
app._reception_font_families = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def body_family() -> str:
|
||||
"""Regular Chinese body copy; YaHei UI has no genuine Medium face."""
|
||||
|
||||
return _families()["body"]
|
||||
|
||||
|
||||
def heading_family() -> str:
|
||||
"""Bundled Noto Sans SC supplies genuine Medium and Semibold faces."""
|
||||
|
||||
return _families()["heading"]
|
||||
|
||||
|
||||
def number_family() -> str:
|
||||
"""Segoe UI supplies Regular and Semibold for numeric labels."""
|
||||
|
||||
return _families()["number"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,4 @@
|
||||
"""Application-wide visual system for the AI consultation workstation.
|
||||
|
||||
The palette and density follow the supplied product references: a quiet blue
|
||||
canvas, crisp white data surfaces, luminous indigo actions and compact tables.
|
||||
|
||||
Every size in the interface comes from the token tables below. Before they
|
||||
existed the UI had grown 19 distinct font sizes and 8 control heights, which is
|
||||
what made neighbouring controls look subtly mismatched; keep new work on the
|
||||
scale instead of introducing another one-off pixel value.
|
||||
"""
|
||||
"""Shared typography and neutral reading surfaces for the clinical workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,12 +8,15 @@ from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, Qt
|
||||
from PySide6.QtCore import QEvent, QObject, QPointF, Qt
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPalette,
|
||||
QPen,
|
||||
QPixmap,
|
||||
QTextBlockFormat,
|
||||
QTextCharFormat,
|
||||
@@ -35,50 +29,57 @@ from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QScrollArea,
|
||||
QTextBrowser,
|
||||
QTextEdit,
|
||||
)
|
||||
|
||||
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
|
||||
# callers while the stylesheet itself is generated from this mapping.
|
||||
from doctor_workstation.resources import resource_path
|
||||
|
||||
# ``motion`` deliberately imports nothing from this module, so this stays a
|
||||
# one-way dependency: tokens here, animation there.
|
||||
from . import motion
|
||||
|
||||
# Neutral reading colors follow the installed Codex light chrome defaults.
|
||||
# Brand accents are independent of reading ink; use opaque text for stable contrast.
|
||||
# The legacy teal/ink aliases remain for existing callers.
|
||||
COLORS = {
|
||||
# Sampled from the supplied 1710 x 920 product comps. The window edge is
|
||||
# the only blue-tinted surface; the application workspace itself is an
|
||||
# almost-white #FCFDFE field.
|
||||
"canvas": "#EEF3FD",
|
||||
"canvas_mid": "#F7F9FE",
|
||||
"canvas_glow": "#E5ECFD",
|
||||
"canvas": "#F4F6FA",
|
||||
"canvas_mid": "#FFFFFF",
|
||||
"canvas_glow": "#EEF1FA",
|
||||
"surface": "#FFFFFF",
|
||||
"surface_alt": "#FAFBFE",
|
||||
"raised": "#F5F7FC",
|
||||
"glass": "rgba(255, 255, 255, 252)",
|
||||
"glass_alt": "rgba(248, 250, 255, 252)",
|
||||
"line": "#E6EAF5",
|
||||
"line_soft": "rgba(82, 97, 246, 40)",
|
||||
"text": "#111F46",
|
||||
"text_soft": "#3F4E75",
|
||||
"muted": "#7886AA",
|
||||
"disabled_surface": "#F0F2F8",
|
||||
"disabled_text": "#A4ADC3",
|
||||
"indigo": "#5761F4",
|
||||
"indigo_hover": "#4C57E9",
|
||||
"indigo_pressed": "#4451E2",
|
||||
"indigo_pale": "#F0F2FF",
|
||||
"focus": "#8D9BFF",
|
||||
"selection": "#EDF0FF",
|
||||
"success": "#17A77D",
|
||||
"success_pale": "#EAF9F3",
|
||||
"warning": "#D38625",
|
||||
"warning_pale": "#FFF5E6",
|
||||
"danger": "#F15B67",
|
||||
"danger_pale": "#FFF1F3",
|
||||
"info": "#4D69ED",
|
||||
"info_pale": "#F0F4FF",
|
||||
"surface_alt": "#F7F7F7",
|
||||
"raised": "#F7F7F7",
|
||||
"glass": "#FFFFFF",
|
||||
"glass_alt": "#F7F7F7",
|
||||
"line": "#EDEDEE",
|
||||
"line_soft": "#E4E4E5",
|
||||
"text": "#1A1C1F",
|
||||
"text_soft": "#606163",
|
||||
"muted": "#6A6B6D",
|
||||
"disabled_surface": "#F2F2F2",
|
||||
"disabled_text": "#8E8F90",
|
||||
"indigo": "#4F63D9",
|
||||
"indigo_hover": "#4156C4",
|
||||
"indigo_pressed": "#354BB4",
|
||||
"indigo_pale": "#EEF1FA",
|
||||
"focus": "#8B9AD9",
|
||||
"selection": "#EEF1FA",
|
||||
"success": "#287B65",
|
||||
"success_pale": "#EEF7F3",
|
||||
"warning": "#A9691D",
|
||||
"warning_pale": "#FCF5E9",
|
||||
"danger": "#BE4B58",
|
||||
"danger_pale": "#FCF0F2",
|
||||
"info": "#4F63D9",
|
||||
"info_pale": "#EEF1FA",
|
||||
# Backward-compatible names used by older UI code and integrations.
|
||||
"ink": "#111F46",
|
||||
"ink_soft": "#3F4E75",
|
||||
"teal": "#5761F4",
|
||||
"teal_dark": "#4451E2",
|
||||
"teal_pale": "#F0F2FF",
|
||||
"ink": "#1A1C1F",
|
||||
"ink_soft": "#606163",
|
||||
"teal": "#4F63D9",
|
||||
"teal_dark": "#354BB4",
|
||||
"teal_pale": "#EEF1FA",
|
||||
}
|
||||
|
||||
|
||||
@@ -87,32 +88,47 @@ COLORS = {
|
||||
# more ink than Latin at the same pixel size, so the steps are spaced widely
|
||||
# enough that two adjacent levels are always distinguishable.
|
||||
TYPE = {
|
||||
"fs_caption": "12px", # table headers, hints, badges, timestamps
|
||||
"fs_body": "13px", # default UI text
|
||||
"fs_strong": "14px", # emphasised body, dialog prompts
|
||||
"fs_caption": "13px", # table headers, hints, badges, timestamps
|
||||
"fs_body": "14px", # default UI text
|
||||
"fs_strong": "15px", # emphasised body, dialog prompts
|
||||
"fs_section": "16px", # card and section titles
|
||||
"fs_title": "20px", # page titles, dialog titles
|
||||
"fs_display": "26px", # metric values, empty-state glyphs
|
||||
}
|
||||
|
||||
# The bundled variable font supplies real Regular, Medium and Semibold faces.
|
||||
# Keep ordinary controls at Medium and reserve Semibold for headings.
|
||||
WEIGHTS = {
|
||||
"fw_body": "400",
|
||||
"fw_control": "500",
|
||||
"fw_heading": "600",
|
||||
}
|
||||
|
||||
# --- Control metrics ------------------------------------------------------
|
||||
# Three interactive heights. ``h_default`` drops from the previous 36px: the
|
||||
# old value made every toolbar, filter row and inline action read as heavy,
|
||||
# which is the main reason the workspace felt clunky.
|
||||
# Controls leave room for the 14px reading size without increasing table density.
|
||||
METRICS = {
|
||||
"h_compact": "28px", # inline row actions, chips, links
|
||||
"h_default": "32px", # buttons, inputs, combos, tabs
|
||||
"h_default": "34px", # buttons, inputs, combos, tabs
|
||||
"h_cta": "38px", # primary dialog actions, sidebar navigation
|
||||
"h_bar": "52px", # dialog header / footer bars
|
||||
"r_sm": "6px",
|
||||
"r_md": "8px",
|
||||
"r_lg": "12px",
|
||||
"r_xl": "16px",
|
||||
# Corner radii. These existed before but the pages invented their own, so
|
||||
# the same role - a card - shipped at 10, 11, 12, 13, 14 and 16 px across
|
||||
# six pages. Nothing in a product is a "10px card"; it is either a card or
|
||||
# it is not, and it should round like every other card next to it.
|
||||
#
|
||||
# Nesting rule: an element sitting flush inside a rounded container takes
|
||||
# ``outer - gap``. Where the gap is bigger than the outer radius the inner
|
||||
# element is far enough from the corner that its own radius is free.
|
||||
"r_xs": "4px", # chips, badges, tiny status pills
|
||||
"r_sm": "6px", # inline row actions, tags
|
||||
"r_md": "8px", # buttons, inputs, combo boxes
|
||||
"r_lg": "12px", # cards, filter bars, panels
|
||||
"r_xl": "16px", # the shell surfaces the cards sit on
|
||||
"pad_control": "12px", # horizontal padding inside default controls
|
||||
"pad_compact": "9px",
|
||||
}
|
||||
|
||||
_QSS_TOKENS = {**COLORS, **TYPE, **METRICS}
|
||||
_QSS_TOKENS = {**COLORS, **TYPE, **WEIGHTS, **METRICS}
|
||||
|
||||
|
||||
def crisp_pixmap(width: int, height: int | None = None) -> QPixmap:
|
||||
@@ -144,8 +160,8 @@ GLOBAL_QSS = Template(
|
||||
QWidget {
|
||||
color: $text;
|
||||
background-color: transparent;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: $fs_body;
|
||||
font-weight: $fw_body;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog, QWidget#LoginCanvas {
|
||||
@@ -170,7 +186,7 @@ QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
|
||||
QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
|
||||
color: $text;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
|
||||
color: $muted;
|
||||
@@ -214,48 +230,43 @@ QInputDialog QLabel {
|
||||
font-size: $fs_strong;
|
||||
}
|
||||
QWidget#AppCanvas {
|
||||
background-color: qlineargradient(
|
||||
x1: 0, y1: 0, x2: 1, y2: 1,
|
||||
stop: 0 $canvas,
|
||||
stop: 0.58 $canvas_mid,
|
||||
stop: 1 $canvas_glow
|
||||
);
|
||||
background-color: $canvas;
|
||||
border-radius: 18px;
|
||||
}
|
||||
QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
|
||||
background-color: #FCFDFE;
|
||||
background-color: $canvas_mid;
|
||||
}
|
||||
|
||||
QLabel[role="muted"] { color: $muted; }
|
||||
QLabel[role="danger"] { color: $danger; }
|
||||
QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; }
|
||||
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: $fs_strong; }
|
||||
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: 600; }
|
||||
QLabel[role="breadcrumbSeparator"] { color: #8E8F90; font-size: $fs_strong; }
|
||||
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: $fw_control; }
|
||||
QLabel[role="eyebrow"] {
|
||||
color: $indigo_hover;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
QLabel[role="pageTitle"] {
|
||||
color: $text;
|
||||
font-size: $fs_title;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QLabel[role="sectionTitle"] {
|
||||
color: $text;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QLabel[role="display"] {
|
||||
color: $text;
|
||||
font-size: $fs_display;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QLabel[role="metric"] {
|
||||
color: $text;
|
||||
font-size: $fs_title;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
|
||||
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
|
||||
@@ -277,19 +288,19 @@ QFrame#MetricCard {
|
||||
}
|
||||
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
|
||||
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
|
||||
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: $fs_title; font-weight: 700; }
|
||||
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: $fs_title; font-weight: $fw_heading; }
|
||||
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; }
|
||||
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
|
||||
QFrame#ReceptionAiCard {
|
||||
min-height: 132px;
|
||||
background-color: #F8FAFF;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 13px;
|
||||
}
|
||||
QLabel#ReceptionAiTitle {
|
||||
color: $indigo_pressed;
|
||||
font-size: $fs_strong;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
|
||||
min-height: $h_compact;
|
||||
@@ -302,7 +313,7 @@ QLabel#MetricGlyph {
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 11px;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
|
||||
QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; }
|
||||
@@ -318,7 +329,7 @@ QGroupBox {
|
||||
border: 1px solid $line;
|
||||
border-radius: 12px;
|
||||
background-color: $glass;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
@@ -334,7 +345,7 @@ QPushButton {
|
||||
border-radius: $r_md;
|
||||
background-color: $surface;
|
||||
color: $text;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: $raised;
|
||||
@@ -345,7 +356,8 @@ QPushButton:pressed {
|
||||
border-color: $indigo_pressed;
|
||||
}
|
||||
QPushButton:focus {
|
||||
border: 2px solid $focus;
|
||||
border: 1px solid $indigo;
|
||||
background-color: $indigo_pale;
|
||||
}
|
||||
QPushButton:checked {
|
||||
color: #FFFFFF;
|
||||
@@ -361,10 +373,7 @@ QPushButton:disabled {
|
||||
|
||||
QPushButton[variant="primary"] {
|
||||
color: #FFFFFF;
|
||||
background-color: qlineargradient(
|
||||
x1: 0, y1: 0, x2: 1, y2: 0,
|
||||
stop: 0 $indigo, stop: 1 #7769F7
|
||||
);
|
||||
background-color: $indigo;
|
||||
border-color: $indigo;
|
||||
}
|
||||
QPushButton[variant="primary"]:hover,
|
||||
@@ -377,7 +386,10 @@ QPushButton[variant="primary"]:checked {
|
||||
background-color: $indigo_pressed;
|
||||
border-color: $indigo_pressed;
|
||||
}
|
||||
QPushButton[variant="primary"]:focus { border: 2px solid $focus; }
|
||||
QPushButton[variant="primary"]:focus {
|
||||
border: 1px solid $indigo_pressed;
|
||||
background-color: $indigo_hover;
|
||||
}
|
||||
QPushButton[variant="primary"]:disabled {
|
||||
color: $disabled_text;
|
||||
background-color: $surface_alt;
|
||||
@@ -410,7 +422,7 @@ QWidget#RowActions QPushButton[rowAction="true"] {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r_sm;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QWidget#RowActions QPushButton[rowAction="true"]:hover {
|
||||
color: $indigo_pressed;
|
||||
@@ -436,7 +448,7 @@ QToolButton#RowActionsMore {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r_sm;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QToolButton#RowActionsMore:hover {
|
||||
color: $text;
|
||||
@@ -457,7 +469,7 @@ QPushButton#NoteAttachmentPreview {
|
||||
background-color: $surface_alt;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QPushButton#NoteAttachmentPreview:hover {
|
||||
background-color: $indigo_pale;
|
||||
@@ -503,15 +515,15 @@ QPushButton[variant="success"] {
|
||||
background-color: $success;
|
||||
border-color: $success;
|
||||
}
|
||||
QPushButton[variant="success"]:hover { background-color: #65D4B7; border-color: #65D4B7; }
|
||||
QPushButton[variant="success"]:pressed { background-color: #319F84; border-color: #319F84; }
|
||||
QPushButton[variant="success"]:hover { background-color: #216A56; border-color: #216A56; }
|
||||
QPushButton[variant="success"]:pressed { background-color: #1B5746; border-color: #1B5746; }
|
||||
QPushButton[variant="warning"] {
|
||||
color: #FFFFFF;
|
||||
background-color: $warning;
|
||||
border-color: $warning;
|
||||
}
|
||||
QPushButton[variant="warning"]:hover { background-color: #F0C97C; border-color: #F0C97C; }
|
||||
QPushButton[variant="warning"]:pressed { background-color: #B99045; border-color: #B99045; }
|
||||
QPushButton[variant="warning"]:hover { background-color: #925B19; border-color: #925B19; }
|
||||
QPushButton[variant="warning"]:pressed { background-color: #794B14; border-color: #794B14; }
|
||||
|
||||
QPushButton[variant="ghost"] {
|
||||
color: $text_soft;
|
||||
@@ -536,7 +548,7 @@ QPushButton[variant="link"] {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
QPushButton[variant="link"]:hover { color: $focus; background-color: $indigo_pale; }
|
||||
QPushButton[variant="link"]:hover { color: $indigo_hover; background-color: $indigo_pale; }
|
||||
QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
|
||||
QPushButton[variant="chip"] {
|
||||
min-height: $h_compact;
|
||||
@@ -564,7 +576,7 @@ QPushButton[variant="nav"] {
|
||||
background-color: transparent;
|
||||
color: $muted;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; }
|
||||
QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; }
|
||||
@@ -580,7 +592,7 @@ QToolButton {
|
||||
}
|
||||
QToolButton:hover { color: $text; background-color: $surface_alt; border-color: $line; }
|
||||
QToolButton:pressed { background-color: $indigo_pale; border-color: $indigo_pressed; }
|
||||
QToolButton:focus { border: 2px solid $focus; }
|
||||
QToolButton:focus { border: 1px solid $indigo; background-color: $indigo_pale; }
|
||||
QToolButton:checked { color: #FFFFFF; background-color: $indigo_pressed; border-color: $indigo_hover; }
|
||||
QToolButton:disabled { color: $disabled_text; background-color: transparent; border-color: transparent; }
|
||||
QToolButton[diagnosisChip="true"] {
|
||||
@@ -616,8 +628,8 @@ QDoubleSpinBox:hover, QKeySequenceEdit:hover { border-color: $indigo_hover; }
|
||||
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus,
|
||||
QDateEdit:focus, QDateTimeEdit:focus, QTimeEdit:focus, QSpinBox:focus,
|
||||
QDoubleSpinBox:focus, QKeySequenceEdit:focus {
|
||||
border: 2px solid $focus;
|
||||
background-color: $surface_alt;
|
||||
border: 1px solid $indigo;
|
||||
background-color: $surface;
|
||||
}
|
||||
QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only {
|
||||
color: $muted;
|
||||
@@ -676,7 +688,7 @@ QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
|
||||
QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView {
|
||||
color: $text;
|
||||
background-color: $surface;
|
||||
alternate-background-color: #FBFCFF;
|
||||
alternate-background-color: $surface_alt;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
gridline-color: $line;
|
||||
@@ -684,7 +696,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
|
||||
selection-color: $text;
|
||||
outline: 0;
|
||||
}
|
||||
QAbstractItemView:focus { border: 1px solid $indigo_hover; }
|
||||
QAbstractItemView:focus { border: 0; }
|
||||
QTableWidget::item, QTableView::item {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid $line;
|
||||
@@ -695,14 +707,14 @@ QTableWidget::item:selected, QTableView::item:selected {
|
||||
background-color: $selection;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background-color: #F7F9FE;
|
||||
background-color: $raised;
|
||||
color: $muted;
|
||||
border: 0;
|
||||
border-right: 1px solid $line;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid $line;
|
||||
padding: 7px 8px;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QHeaderView::section:hover { color: $text; background-color: $raised; }
|
||||
QTableCornerButton::section { background-color: $surface_alt; border: 0; }
|
||||
@@ -736,7 +748,7 @@ QTabBar::tab {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
|
||||
QTabBar::tab:focus { border-color: $focus; }
|
||||
@@ -788,8 +800,8 @@ QMenu::item {
|
||||
border-radius: $r_sm;
|
||||
background-color: transparent;
|
||||
}
|
||||
QMenu::item:selected { color: #FFFFFF; background-color: $indigo_pressed; }
|
||||
QMenu::item:pressed { background-color: $indigo; }
|
||||
QMenu::item:selected { color: $indigo_pressed; background-color: $indigo_pale; }
|
||||
QMenu::item:pressed { color: #FFFFFF; background-color: $indigo; }
|
||||
QMenu::item:disabled { color: $disabled_text; background-color: transparent; }
|
||||
QMenu::item[danger="true"] { color: $danger; }
|
||||
QMenu::item[danger="true"]:selected { color: $danger; background-color: $danger_pale; }
|
||||
@@ -841,8 +853,8 @@ QScrollBar::handle:vertical {
|
||||
min-height: $h_compact;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover { background: $indigo_pressed; }
|
||||
QScrollBar::handle:vertical:pressed { background: $indigo; }
|
||||
QScrollBar::handle:vertical:hover { background: #A7B1C9; }
|
||||
QScrollBar::handle:vertical:pressed { background: #8894B2; }
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
|
||||
QScrollBar:horizontal {
|
||||
@@ -855,8 +867,8 @@ QScrollBar::handle:horizontal {
|
||||
min-width: 30px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:horizontal:hover { background: $indigo_pressed; }
|
||||
QScrollBar::handle:horizontal:pressed { background: $indigo; }
|
||||
QScrollBar::handle:horizontal:hover { background: #A7B1C9; }
|
||||
QScrollBar::handle:horizontal:pressed { background: #8894B2; }
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
|
||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
|
||||
|
||||
@@ -885,7 +897,7 @@ QLabel#StatusBadge {
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r_sm;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QLabel#StatusBadge[kind="neutral"] { color: $muted; background-color: $surface_alt; border-color: $line; }
|
||||
QLabel#StatusBadge[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 72); }
|
||||
@@ -902,7 +914,7 @@ QWidget#Pager QLabel#PagerActive {
|
||||
border: 1px solid $indigo;
|
||||
border-radius: 8px;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QWidget#Pager QPushButton {
|
||||
min-height: $h_compact;
|
||||
@@ -918,7 +930,7 @@ QLabel#EmptyStateGlyph {
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 22px;
|
||||
font-size: $fs_display;
|
||||
font-weight: 500;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
|
||||
QFrame#MessageBanner {
|
||||
@@ -927,7 +939,7 @@ QFrame#MessageBanner {
|
||||
color: $text_soft;
|
||||
}
|
||||
QFrame#MessageBanner QLabel { background-color: transparent; }
|
||||
QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: 700; }
|
||||
QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: $fw_heading; }
|
||||
QFrame#MessageBanner[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 82); }
|
||||
QFrame#MessageBanner[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 82); }
|
||||
QFrame#MessageBanner[kind="warning"] { color: $warning; background-color: $warning_pale; border-color: rgba(228, 185, 103, 82); }
|
||||
@@ -943,7 +955,7 @@ QLabel#Toast {
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 11px;
|
||||
padding: 11px 16px;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QLabel#Toast[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 96); }
|
||||
QLabel#Toast[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 96); }
|
||||
@@ -978,7 +990,7 @@ QLabel#UserAvatar {
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 18px;
|
||||
font-size: $fs_strong;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QWidget#LoginBrandPanel {
|
||||
background-color: qlineargradient(
|
||||
@@ -997,6 +1009,29 @@ QFrame#LoginCard {
|
||||
|
||||
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
|
||||
QSplitter::handle:hover { background-color: $indigo_pressed; }
|
||||
QPushButton[variant="secondary"]:disabled,
|
||||
QPushButton[variant="secondary"]:checked:disabled,
|
||||
QPushButton[variant="success"]:disabled,
|
||||
QPushButton[variant="success"]:checked:disabled,
|
||||
QPushButton[variant="warning"]:disabled,
|
||||
QPushButton[variant="warning"]:checked:disabled,
|
||||
QPushButton[variant="danger"]:disabled,
|
||||
QPushButton[variant="danger"]:checked:disabled,
|
||||
QPushButton[variant="dangerGhost"]:disabled,
|
||||
QPushButton[variant="dangerGhost"]:checked:disabled,
|
||||
QPushButton[variant="ghost"]:disabled,
|
||||
QPushButton[variant="ghost"]:checked:disabled,
|
||||
QPushButton[variant="link"]:disabled,
|
||||
QPushButton[variant="link"]:checked:disabled,
|
||||
QPushButton[variant="chip"]:disabled,
|
||||
QPushButton[variant="chip"]:checked:disabled,
|
||||
QPushButton[variant="nav"]:disabled,
|
||||
QPushButton[variant="nav"]:checked:disabled {
|
||||
color: $disabled_text;
|
||||
background-color: $disabled_surface;
|
||||
border-color: $line;
|
||||
}
|
||||
|
||||
QToolTip {
|
||||
color: $text;
|
||||
background-color: $raised;
|
||||
@@ -1024,6 +1059,166 @@ _BLOCK_RHYTHM = {
|
||||
}
|
||||
|
||||
|
||||
# --- Surfaces -------------------------------------------------------------
|
||||
# There is deliberately no drop-shadow system here. One was tried: a painted
|
||||
# layer behind each page's cards, on the theory that flat outlines were what
|
||||
# made the workspace look unfinished. It does not work in this palette. The
|
||||
# cards are #FFFFFF sitting on a #FFFFFF workspace - barely one percent apart -
|
||||
# so a shadow has no tonal room to read as depth and instead composites into a
|
||||
# neutral grey rim around every card, which against the blue-tinted ground looks
|
||||
# like a dirty second border rather than elevation.
|
||||
#
|
||||
# Depth in this product comes from the border and the fill, not from shadow.
|
||||
|
||||
# --- Control indicator glyphs --------------------------------------------
|
||||
# Styling ``QCheckBox::indicator`` with a background and border but no image
|
||||
# tells Qt to stop drawing its own tick, so every checked box in the product
|
||||
# rendered as a plain indigo square and the partially-checked state was an
|
||||
# unlabelled grey one. Selection columns on the prescription, patient and
|
||||
# diagnosis tables all depend on that tick, so the marks are painted here and
|
||||
# handed back to the stylesheet as cached PNGs.
|
||||
|
||||
_INDICATOR_BOX = 15
|
||||
_INDICATOR_REVISION = "1"
|
||||
|
||||
|
||||
#: The dropdown caret is the one glyph Fusion still drew itself - a solid
|
||||
#: triangle sitting beside an otherwise entirely stroked icon set. Rendering it
|
||||
#: here brings every QComboBox, date field and tool-button menu onto the same
|
||||
#: 24-unit grid as the rest of the product.
|
||||
_CARET_BOX = 12
|
||||
|
||||
|
||||
def _indicator_pixmap(kind: str, color: str, ratio: int, box_size: int | None = None) -> QPixmap:
|
||||
box = (box_size or _INDICATOR_BOX) * ratio
|
||||
pixmap = QPixmap(box, box)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
try:
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
scale = box / 24.0
|
||||
painter.scale(scale, scale)
|
||||
pen = QPen(QColor(color), 3.4)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
if kind == "check":
|
||||
path = QPainterPath(QPointF(5.0, 12.5))
|
||||
path.lineTo(QPointF(10.0, 17.5))
|
||||
path.lineTo(QPointF(19.0, 6.5))
|
||||
painter.drawPath(path)
|
||||
elif kind == "dash":
|
||||
painter.drawLine(QPointF(6.0, 12.0), QPointF(18.0, 12.0))
|
||||
elif kind == "dot":
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor(color))
|
||||
painter.drawEllipse(QPointF(12.0, 12.0), 4.6, 4.6)
|
||||
elif kind == "caret":
|
||||
pen.setWidthF(2.6)
|
||||
painter.setPen(pen)
|
||||
path = QPainterPath(QPointF(6.0, 9.5))
|
||||
path.lineTo(QPointF(12.0, 15.5))
|
||||
path.lineTo(QPointF(18.0, 9.5))
|
||||
painter.drawPath(path)
|
||||
finally:
|
||||
painter.end()
|
||||
return pixmap
|
||||
|
||||
|
||||
def _indicator_asset_dir() -> Path | None:
|
||||
try:
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
from ..config import APP_AUTHOR, APP_NAME
|
||||
|
||||
directory = Path(user_cache_dir(APP_NAME, APP_AUTHOR)) / "indicators"
|
||||
except Exception: # pragma: no cover - falls back to the user home
|
||||
directory = Path.home() / ".zhenyangdoctor" / "indicators"
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError: # pragma: no cover - read-only deployment
|
||||
return None
|
||||
return directory
|
||||
|
||||
|
||||
def _indicator_url(kind: str, color: str, box_size: int | None = None) -> str | None:
|
||||
"""Return a stylesheet ``url()`` body for one indicator mark, or None."""
|
||||
|
||||
directory = _indicator_asset_dir()
|
||||
if directory is None:
|
||||
return None
|
||||
stem = f"{kind}-{color.lstrip('#').lower()}-{box_size or _INDICATOR_BOX}-{_INDICATOR_REVISION}"
|
||||
base = directory / f"{stem}.png"
|
||||
# Qt resolves the ``@2x`` companion itself on scaled displays, which keeps
|
||||
# the mark crisp at the 125%/150% factors clinic workstations run at.
|
||||
retina = directory / f"{stem}@2x.png"
|
||||
try:
|
||||
if not base.exists() and not _indicator_pixmap(kind, color, 1, box_size).save(
|
||||
str(base), "PNG"
|
||||
):
|
||||
return None
|
||||
if not retina.exists():
|
||||
_indicator_pixmap(kind, color, 2, box_size).save(str(retina), "PNG")
|
||||
except OSError: # pragma: no cover - read-only deployment
|
||||
return None
|
||||
return base.as_posix()
|
||||
|
||||
|
||||
def _indicator_qss() -> str:
|
||||
"""Stylesheet fragment that restores the tick, dash and radio dot."""
|
||||
|
||||
marks = {
|
||||
"check": _indicator_url("check", "#FFFFFF"),
|
||||
"check_disabled": _indicator_url("check", COLORS["disabled_text"]),
|
||||
"dash": _indicator_url("dash", "#FFFFFF"),
|
||||
"dot": _indicator_url("dot", "#FFFFFF"),
|
||||
"dot_disabled": _indicator_url("dot", COLORS["disabled_text"]),
|
||||
"caret": _indicator_url("caret", COLORS["muted"], _CARET_BOX),
|
||||
"caret_disabled": _indicator_url("caret", COLORS["disabled_text"], _CARET_BOX),
|
||||
}
|
||||
if any(value is None for value in marks.values()):
|
||||
return ""
|
||||
return f"""
|
||||
QCheckBox::indicator:checked {{ image: url({marks["check"]}); }}
|
||||
QCheckBox::indicator:indeterminate {{
|
||||
background-color: {COLORS["indigo"]};
|
||||
border: 1px solid {COLORS["indigo"]};
|
||||
border-radius: 4px;
|
||||
image: url({marks["dash"]});
|
||||
}}
|
||||
QCheckBox::indicator:checked:disabled {{ image: url({marks["check_disabled"]}); }}
|
||||
QRadioButton::indicator:checked {{ image: url({marks["dot"]}); }}
|
||||
QRadioButton::indicator:checked:disabled {{ image: url({marks["dot_disabled"]}); }}
|
||||
QComboBox::down-arrow, QDateEdit::down-arrow, QDateTimeEdit::down-arrow,
|
||||
QTimeEdit::down-arrow {{
|
||||
width: {_CARET_BOX}px;
|
||||
height: {_CARET_BOX}px;
|
||||
image: url({marks["caret"]});
|
||||
}}
|
||||
/* A tool button's menu indicator defaults to the bottom-right corner, which was
|
||||
invisible while it was Fusion's 6 px triangle and became obvious once it was a
|
||||
12 px chevron - the caret dropped below the label instead of sitting beside
|
||||
it. Anchor it to the right edge; the buttons that show one already reserve
|
||||
right padding for it, so nothing here changes their metrics. */
|
||||
QToolButton::menu-indicator {{
|
||||
width: {_CARET_BOX}px;
|
||||
height: {_CARET_BOX}px;
|
||||
image: url({marks["caret"]});
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: right center;
|
||||
right: 6px;
|
||||
}}
|
||||
/* Re-assert the suppressions that the block above would otherwise override.
|
||||
These live here rather than in the main sheet because this fragment is
|
||||
appended after it, and a later rule wins in Qt when specificity ties. */
|
||||
QToolButton#RowActionsMore::menu-indicator {{ width: 0; height: 0; image: none; }}
|
||||
QComboBox::down-arrow:disabled, QDateEdit::down-arrow:disabled,
|
||||
QDateTimeEdit::down-arrow:disabled, QTimeEdit::down-arrow:disabled,
|
||||
QToolButton::menu-indicator:disabled {{ image: url({marks["caret_disabled"]}); }}
|
||||
"""
|
||||
|
||||
|
||||
def _block_is_all_bold(block: Any) -> bool:
|
||||
"""True when every visible run in the block is bold.
|
||||
|
||||
@@ -1100,7 +1295,7 @@ def apply_reading_rhythm(browser: QTextBrowser, *, role: str) -> None:
|
||||
char_format = QTextCharFormat()
|
||||
char_format.setFont(heading_font)
|
||||
if role != "doctor":
|
||||
char_format.setForeground(QColor("#111B3F"))
|
||||
char_format.setForeground(QColor("#1A1C1F"))
|
||||
cursor.setPosition(block.position())
|
||||
cursor.setPosition(
|
||||
block.position() + block.length() - 1,
|
||||
@@ -1123,14 +1318,21 @@ def _apply_group(
|
||||
|
||||
|
||||
def _register_preferred_cjk_fonts() -> str:
|
||||
"""Make the bundled/offscreen Windows runtime aware of its CJK fonts.
|
||||
"""Prefer the shipped outline font, with native CJK faces as a fallback."""
|
||||
|
||||
Qt's offscreen platform does not always enumerate the Windows font
|
||||
collection. Registering the already-installed YaHei collection only
|
||||
when it is missing prevents Chinese text from degrading to tofu boxes in
|
||||
packaged captures and headless visual checks. Other platforms continue
|
||||
to use their native PingFang/Noto fallback.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
registered = getattr(app, "_doctor_bundled_font_family", None)
|
||||
if registered:
|
||||
return registered
|
||||
bundled_font = resource_path("fonts", "NotoSansSC-VF.ttf")
|
||||
if bundled_font.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(bundled_font))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
|
||||
if families:
|
||||
family = families[0]
|
||||
if app is not None:
|
||||
app._doctor_bundled_font_family = family
|
||||
return family
|
||||
|
||||
platform_families = {
|
||||
"win32": ("Microsoft YaHei UI", "Microsoft YaHei"),
|
||||
@@ -1259,12 +1461,27 @@ class _BusinessDialogStyleFilter(QObject):
|
||||
or dialog.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
)
|
||||
|
||||
#: The filter is installed on the QApplication, so it is handed every event
|
||||
#: in the process - roughly 5,000 per list refresh. Only two event types
|
||||
#: matter, and bailing on the type before touching ``isinstance`` keeps the
|
||||
#: hot path to a single comparison.
|
||||
_WATCHED = frozenset({QEvent.Type.Polish, QEvent.Type.Show})
|
||||
|
||||
#: Reading surfaces that get eased wheel scrolling when they are polished.
|
||||
#: Deliberately not every ``QAbstractScrollArea``: animating the scrollbar of
|
||||
#: an item view that hosts a widget per row means repainting those widgets
|
||||
#: for the length of the animation, which would trade a jerky scroll for a
|
||||
#: slow one. The list pages opt their own tables in individually.
|
||||
_SMOOTH_SCROLL = (QScrollArea, QTextEdit, QTextBrowser, QPlainTextEdit)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
|
||||
event_type = event.type()
|
||||
if isinstance(watched, QDialogButtonBox) and event_type in {
|
||||
QEvent.Type.Polish,
|
||||
QEvent.Type.Show,
|
||||
}:
|
||||
if event_type not in self._WATCHED:
|
||||
return False
|
||||
if event_type is QEvent.Type.Polish and isinstance(watched, self._SMOOTH_SCROLL):
|
||||
motion.install_smooth_scroll(watched)
|
||||
return False
|
||||
if isinstance(watched, QDialogButtonBox):
|
||||
_polish_dialog_buttons(watched)
|
||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
|
||||
# Window flags can only be changed before the dialog is on screen,
|
||||
@@ -1305,11 +1522,17 @@ def apply_theme(app: QApplication) -> None:
|
||||
|
||||
app.setStyle("Fusion")
|
||||
cjk_family = _register_preferred_cjk_fonts()
|
||||
# Pin the Windows CJK face explicitly. A comma-separated QSS fallback
|
||||
# list can resolve to Qt's generic sans face in offscreen/native title-bar
|
||||
# captures, which changes glyph width and can even yield tofu boxes.
|
||||
application_font = QFont(app.font())
|
||||
application_font.setFamily(cjk_family)
|
||||
# Resolve once for both styled widgets and custom-painted table cells.
|
||||
application_font = QFont(cjk_family)
|
||||
application_font.setPixelSize(int(TYPE["fs_body"].removesuffix("px")))
|
||||
application_font.setWeight(QFont.Weight.Normal)
|
||||
application_font.setStyleStrategy(
|
||||
QFont.StyleStrategy.PreferAntialias
|
||||
| QFont.StyleStrategy.PreferOutline
|
||||
)
|
||||
# Keep the platform's pixel fitting and subpixel rendering available.
|
||||
# Forcing grayscale plus vertical-only hinting softens small Windows text.
|
||||
application_font.setHintingPreference(QFont.HintingPreference.PreferDefaultHinting)
|
||||
app.setFont(application_font)
|
||||
palette = QPalette()
|
||||
active = {
|
||||
@@ -1324,7 +1547,7 @@ def apply_theme(app: QApplication) -> None:
|
||||
QPalette.ColorRole.ButtonText: COLORS["text"],
|
||||
QPalette.ColorRole.Base: COLORS["surface"],
|
||||
QPalette.ColorRole.Window: COLORS["canvas"],
|
||||
QPalette.ColorRole.Shadow: "#B7C0D2",
|
||||
QPalette.ColorRole.Shadow: "#C2C2C2",
|
||||
QPalette.ColorRole.Highlight: COLORS["indigo"],
|
||||
QPalette.ColorRole.HighlightedText: "#FFFFFF",
|
||||
QPalette.ColorRole.Link: COLORS["info"],
|
||||
@@ -1347,7 +1570,7 @@ def apply_theme(app: QApplication) -> None:
|
||||
QPalette.ColorRole.ButtonText: COLORS["disabled_text"],
|
||||
QPalette.ColorRole.Base: COLORS["disabled_surface"],
|
||||
QPalette.ColorRole.Window: COLORS["canvas"],
|
||||
QPalette.ColorRole.Shadow: "#C8CFDC",
|
||||
QPalette.ColorRole.Shadow: "#D2D2D2",
|
||||
QPalette.ColorRole.Highlight: COLORS["line"],
|
||||
QPalette.ColorRole.HighlightedText: COLORS["disabled_text"],
|
||||
QPalette.ColorRole.Link: COLORS["disabled_text"],
|
||||
@@ -1362,7 +1585,7 @@ def apply_theme(app: QApplication) -> None:
|
||||
_apply_group(palette, QPalette.ColorGroup.Inactive, active)
|
||||
_apply_group(palette, QPalette.ColorGroup.Disabled, disabled)
|
||||
app.setPalette(palette)
|
||||
app.setStyleSheet(GLOBAL_QSS)
|
||||
app.setStyleSheet(GLOBAL_QSS + _indicator_qss())
|
||||
_install_business_dialog_styling(app)
|
||||
|
||||
|
||||
@@ -1373,6 +1596,7 @@ __all__ = [
|
||||
"GLOBAL_QSS",
|
||||
"METRICS",
|
||||
"TYPE",
|
||||
"WEIGHTS",
|
||||
"apply_theme",
|
||||
"crisp_pixmap",
|
||||
"mark_business_dialog",
|
||||
|
||||
@@ -10,7 +10,15 @@ from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
from PySide6.QtCore import (
|
||||
QObject,
|
||||
QRunnable,
|
||||
Qt,
|
||||
QThreadPool,
|
||||
QTimer,
|
||||
Signal,
|
||||
Slot,
|
||||
)
|
||||
from PySide6.QtGui import QColor, QPainter, QPaintEvent, QResizeEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
@@ -35,6 +43,8 @@ from doctor_workstation.core.errors import (
|
||||
AuthenticationExpiredError,
|
||||
)
|
||||
|
||||
from . import icons, motion
|
||||
|
||||
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
|
||||
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
|
||||
|
||||
@@ -470,6 +480,8 @@ class PageHeader(QWidget):
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("PageHeader")
|
||||
self._compact = False
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
@@ -514,7 +526,31 @@ class PageHeader(QWidget):
|
||||
|
||||
def set_subtitle(self, text: str) -> None:
|
||||
self.subtitle_label.setText(text)
|
||||
self.subtitle_label.setVisible(bool(text))
|
||||
self.subtitle_label.setVisible(bool(text) and not self._compact)
|
||||
self.title_label.setToolTip(text if self._compact else "")
|
||||
|
||||
def set_compact(self, compact: bool = True) -> None:
|
||||
"""Use a single title/action row on list pages with foldable search."""
|
||||
self._compact = compact
|
||||
layout = self.layout()
|
||||
breadcrumb = layout.itemAt(0).layout()
|
||||
for index in range(breadcrumb.count()):
|
||||
widget = breadcrumb.itemAt(index).widget()
|
||||
if widget is not None:
|
||||
widget.setVisible(not compact)
|
||||
self.subtitle_label.setVisible(bool(self.subtitle_label.text()) and not compact)
|
||||
self.title_label.setToolTip(self.subtitle_label.text() if compact else "")
|
||||
layout.setSpacing(0 if compact else 8)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
heading = layout.itemAt(1).layout()
|
||||
heading.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
heading.itemAt(0).layout().setSpacing(0 if compact else 3)
|
||||
self.actions.setSpacing(8)
|
||||
if compact:
|
||||
self.setFixedHeight(44)
|
||||
else:
|
||||
self.setMinimumHeight(0)
|
||||
self.setMaximumHeight(16777215)
|
||||
|
||||
|
||||
class MetricCard(QFrame):
|
||||
@@ -636,7 +672,9 @@ class MessageBanner(QFrame):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 9, 12, 9)
|
||||
layout.setSpacing(9)
|
||||
self.icon = QLabel("i", self)
|
||||
# The banner used to letter its own icons - a lowercase "i", a "!", and
|
||||
# a U+2713 whose shape depended on whichever font happened to cover it.
|
||||
self.icon = QLabel(self)
|
||||
self.icon.setObjectName("MessageBannerIcon")
|
||||
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.icon.setFixedSize(20, 20)
|
||||
@@ -647,18 +685,33 @@ class MessageBanner(QFrame):
|
||||
layout.addWidget(self.label, 1)
|
||||
self.setVisible(bool(text))
|
||||
|
||||
#: Banner kind -> shared glyph and colour role.
|
||||
_ICONS = {
|
||||
"info": ("info", "info"),
|
||||
"success": ("check_circle", "success"),
|
||||
"warning": ("alert", "warning"),
|
||||
"danger": ("alert", "danger"),
|
||||
}
|
||||
|
||||
def show_message(self, text: str, kind: str = "info") -> None:
|
||||
glyphs = {"info": "i", "success": "✓", "warning": "!", "danger": "!"}
|
||||
glyph, role = self._ICONS.get(kind, self._ICONS["info"])
|
||||
self.label.setText(text)
|
||||
self.icon.setText(glyphs.get(kind, "i"))
|
||||
self.icon.setPixmap(icons.pixmap(glyph, role, 16))
|
||||
self.setProperty("kind", kind)
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.setVisible(bool(text))
|
||||
if not text:
|
||||
self.setVisible(False)
|
||||
return
|
||||
if self.isVisible():
|
||||
return
|
||||
motion.fade_in(self, duration=motion.FAST)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.setVisible(False)
|
||||
self.label.clear()
|
||||
if self.isVisible():
|
||||
motion.fade_out(self, duration=motion.FAST, on_finished=self.label.clear)
|
||||
else:
|
||||
self.label.clear()
|
||||
|
||||
|
||||
class Toast(QLabel):
|
||||
@@ -670,7 +723,10 @@ class Toast(QLabel):
|
||||
self.hide()
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self.hide)
|
||||
self._timer.timeout.connect(self._dismiss)
|
||||
|
||||
def _dismiss(self) -> None:
|
||||
motion.fade_out(self, duration=motion.BASE)
|
||||
|
||||
def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
|
||||
self.setText(text)
|
||||
@@ -682,7 +738,7 @@ class Toast(QLabel):
|
||||
if parent is not None:
|
||||
self.move(max(16, parent.width() - self.width() - 24), 20)
|
||||
self.raise_()
|
||||
self.show()
|
||||
motion.fade_in(self, duration=motion.FAST)
|
||||
self._timer.start(duration)
|
||||
|
||||
|
||||
@@ -730,7 +786,7 @@ class _BusyTrack(QWidget):
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
rect = self.rect()
|
||||
painter.setBrush(QColor("#D8DEEA"))
|
||||
painter.setBrush(QColor("#E4E4E5"))
|
||||
painter.drawRoundedRect(rect, 3, 3)
|
||||
chunk_width = max(36, int(rect.width() * 0.32))
|
||||
span = rect.width() + chunk_width
|
||||
@@ -766,6 +822,16 @@ class BusyOverlay(QFrame):
|
||||
self.raise_()
|
||||
super().showEvent(event)
|
||||
|
||||
def reveal(self) -> None:
|
||||
"""Fade the guard in, so a fast response never flashes a grey slab."""
|
||||
|
||||
if not self.isVisible():
|
||||
motion.fade_in(self, duration=motion.FAST)
|
||||
|
||||
def dismiss(self) -> None:
|
||||
if self.isVisible():
|
||||
motion.fade_out(self, duration=motion.FAST)
|
||||
|
||||
|
||||
class OverlayHost(QWidget):
|
||||
"""Widget base that automatically sizes a BusyOverlay child."""
|
||||
@@ -795,11 +861,19 @@ class SortableTable(QTableWidget):
|
||||
self.setColumnCount(len(self.columns))
|
||||
self.setHorizontalHeaderLabels([column.title for column in self.columns])
|
||||
self.setAlternatingRowColors(True)
|
||||
# Row separation already comes from the ``::item`` bottom border, so the
|
||||
# grid only added a column rule that cut across every row - and it kept
|
||||
# drawing past the last populated column, leaving a stray vertical line
|
||||
# hanging in the empty part of the table.
|
||||
self.setShowGrid(False)
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
# Per-pixel mode only smooths dragging; the wheel still jumped three rows
|
||||
# at a time, which is how most of a list actually gets read.
|
||||
motion.install_smooth_scroll(self)
|
||||
self.setMinimumHeight(0)
|
||||
self.setSortingEnabled(True)
|
||||
self.verticalHeader().setVisible(False)
|
||||
|
||||
@@ -1634,7 +1634,11 @@ def test_report_bubbles_report_the_height_they_actually_paint(
|
||||
host, bubble = _fitted_bubble(reply)
|
||||
|
||||
assert bubble.height() > 0
|
||||
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2
|
||||
# Wrapping labels can legitimately have a different preferred height at
|
||||
# their preferred width. Compare against the actual reading-column width.
|
||||
fitted_height = bubble.heightForWidth(bubble.width())
|
||||
expected_height = fitted_height if fitted_height >= 0 else bubble.sizeHint().height()
|
||||
assert abs(expected_height - bubble.height()) <= 2
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
|
||||
@@ -1646,14 +1650,15 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
|
||||
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
|
||||
assert "#BE4B58" 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
|
||||
body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
|
||||
assert "color: #1a1c1f" in body.lower()
|
||||
assert "font-size: 14px" in body
|
||||
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
|
||||
assert "font-size: 13px" in block
|
||||
assert "font-size: 14px" in block
|
||||
assert "font-size: 11px" not in block
|
||||
|
||||
@@ -559,7 +559,7 @@ def test_keyboard_focus_has_a_visible_state(
|
||||
assert focus_target.hasFocus()
|
||||
assert application.focusWidget() is focus_target
|
||||
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
|
||||
assert "border-color: #8D9BFF;" in APPOINTMENT_DRAWER_QSS
|
||||
assert "border-color: #8B9AD9;" in APPOINTMENT_DRAWER_QSS
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Scrolling, query isolation and refresh contracts for the two clinic queues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QComboBox, QSpinBox, QWidget
|
||||
|
||||
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||
from doctor_workstation.ui.pages import appointments, consultations
|
||||
|
||||
|
||||
class _DiagnosisDialog(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.fail_page: int | None = None
|
||||
self.revision = 0
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(dict(query))
|
||||
page = query["page_no"]
|
||||
if page == self.fail_page:
|
||||
raise RuntimeError("暂时无法加载")
|
||||
second = bool(query.get("keyword") or query.get("patient_name"))
|
||||
offset = 1000 if second else 0
|
||||
total = 4 if second else 34
|
||||
start = (page - 1) * query["page_size"]
|
||||
rows = [
|
||||
{
|
||||
"id": offset + index,
|
||||
"diagnosis_id": offset + index,
|
||||
"source_patient_id": index + 2000,
|
||||
"patient_id": index + 2000,
|
||||
"patient_name": f"患者{offset + index} · {self.revision}",
|
||||
"status": 1,
|
||||
"status_desc": "待接诊",
|
||||
"appointment_id": offset + index,
|
||||
"appointment_status": 1,
|
||||
"appointment_date": date.today().isoformat(),
|
||||
"appointment_time": "09:00-09:30",
|
||||
"patient_phone": "13800001234",
|
||||
"doctor_name": "测试医生",
|
||||
"doctor_id": 30,
|
||||
"diagnosis_confirmed": True,
|
||||
"appointments": [],
|
||||
}
|
||||
for index in range(start + 1, min(total, start + query["page_size"]) + 1)
|
||||
]
|
||||
result = {"lists": rows, "count": total}
|
||||
if page == 1:
|
||||
result["extend"] = {
|
||||
"status_count": {"1": total, "3": 7},
|
||||
"date_counts": {"today": total, "tomorrow": 9},
|
||||
}
|
||||
return result
|
||||
|
||||
list_appointments = _list
|
||||
list_consultations = _list
|
||||
|
||||
|
||||
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
QTest.qWait(35)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(params=["appointments", "consultations"])
|
||||
def queue_page(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
module = appointments if request.param == "appointments" else consultations
|
||||
monkeypatch.setattr(module, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "DiagnosisDialog", _DiagnosisDialog)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||
repository = _Repository()
|
||||
page_class = appointments.AppointmentsPage if module is appointments else consultations.ConsultationsPage
|
||||
page = page_class(repository, permissions={"*"}, current_user={"role_id": 1})
|
||||
page.resize(1280, 800)
|
||||
page.show()
|
||||
page.poll_timer.stop()
|
||||
_settle(application)
|
||||
yield page, repository, module
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _scroll_bottom(page: Any, application: QApplication) -> None:
|
||||
scrollbar = page.table.verticalScrollBar()
|
||||
assert scrollbar.maximum() > 0
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_scroll_appends_and_preserves_selection(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
assert page.table.rowCount() == 15
|
||||
page.table.selectRow(6)
|
||||
if hasattr(page, "table_host"):
|
||||
model = page.table_host.model
|
||||
model.setData(model.index(6, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||
_scroll_bottom(page, application)
|
||||
|
||||
assert page.table.rowCount() == 30
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert page.table.current_data()["id"] == 7
|
||||
assert page.table.verticalScrollBar().value() > 0
|
||||
if hasattr(page, "table_host"):
|
||||
assert [row["id"] for row in page.table_host.selected_records()] == [7]
|
||||
else:
|
||||
assert page._status_counts[3] == 7
|
||||
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||
|
||||
_scroll_bottom(page, application)
|
||||
assert page.table.rowCount() == 34
|
||||
assert len({row["id"] for row in page.pager.rows}) == 34
|
||||
assert not page.pager.has_more
|
||||
assert "已全部加载" in page.pager.summary_label.text()
|
||||
_scroll_bottom(page, application)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_silent_refresh_keeps_the_loaded_prefix(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
_scroll_bottom(page, application)
|
||||
page.table.selectRow(19)
|
||||
old_scroll = page.table.verticalScrollBar().value()
|
||||
repository.calls.clear()
|
||||
repository.revision = 2
|
||||
page.refresh(silent=True)
|
||||
_settle(application)
|
||||
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert page.table.rowCount() == 30
|
||||
assert page.table.current_data()["id"] == 20
|
||||
assert page.table.current_data()["patient_name"].endswith(" · 2")
|
||||
assert page.table.verticalScrollBar().value() == old_scroll
|
||||
if isinstance(page, appointments.AppointmentsPage):
|
||||
assert page._status_counts[3] == 7
|
||||
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||
|
||||
|
||||
def test_filter_change_supersedes_pending_append(
|
||||
queue_page: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
page, _repository, module = queue_page
|
||||
jobs: list[tuple[Any, dict[str, Any]]] = []
|
||||
|
||||
def deferred(function: Any, **callbacks: Any) -> None:
|
||||
jobs.append((function, callbacks))
|
||||
|
||||
monkeypatch.setattr(module, "run_async", deferred)
|
||||
# Reconfigure the shared controller to use the deferred runner, then finish
|
||||
# that refresh before simulating a slow next page.
|
||||
page.refresh(silent=True)
|
||||
function, callbacks = jobs.pop()
|
||||
callbacks["on_success"](function())
|
||||
page.pager.load_more()
|
||||
append_function, append_callbacks = jobs.pop()
|
||||
stale_result = deepcopy(append_function())
|
||||
|
||||
search = page.patient_input if module is appointments else page.keyword_edit
|
||||
search.setText("第二组")
|
||||
page.refresh(silent=True)
|
||||
assert len(jobs) == 1
|
||||
function, callbacks = jobs.pop()
|
||||
assert function()["lists"][0]["id"] == 1001
|
||||
callbacks["on_success"](function())
|
||||
append_callbacks["on_success"](stale_result)
|
||||
_settle(application)
|
||||
|
||||
assert page.table.rowCount() == 4
|
||||
assert [row["id"] for row in page.pager.rows] == [1001, 1002, 1003, 1004]
|
||||
assert page.pager.page == 1
|
||||
assert not page.pager.loading
|
||||
assert page.table.verticalScrollBar().value() == 0
|
||||
|
||||
|
||||
def test_failed_append_retries_without_losing_rows(queue_page: Any, application: QApplication) -> None:
|
||||
page, repository, _module = queue_page
|
||||
repository.fail_page = 2
|
||||
_scroll_bottom(page, application)
|
||||
assert page.table.rowCount() == 15
|
||||
assert page.pager.page == 1
|
||||
assert page.pager.retry_button.isVisible()
|
||||
calls = len(repository.calls)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls
|
||||
|
||||
repository.fail_page = None
|
||||
page.pager.retry_button.click()
|
||||
_settle(application)
|
||||
assert page.table.rowCount() == 30
|
||||
assert page.pager.page == 2
|
||||
assert page.pager.retry_button.isHidden()
|
||||
|
||||
|
||||
def test_list_footer_is_compact_without_page_controls(queue_page: Any, application: QApplication) -> None:
|
||||
page, _repository, _module = queue_page
|
||||
for height in (768, 960):
|
||||
page.resize(1280, height)
|
||||
_settle(application)
|
||||
assert isinstance(page.pager, InfiniteList)
|
||||
assert page.pager.height() == 24
|
||||
assert not page.pager.findChildren(QComboBox)
|
||||
assert not page.pager.findChildren(QSpinBox)
|
||||
content = page.page_scroll.widget() if hasattr(page, "page_scroll") else page
|
||||
assert content.layout().contentsMargins().bottom() == 8
|
||||
@@ -10,6 +10,8 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QElapsedTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
@@ -398,12 +400,19 @@ def test_appointments_page_default_query_is_today_pending(
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
|
||||
completed = QElapsedTimer()
|
||||
completed.start()
|
||||
while page.table.rowCount() == 0 and completed.elapsed() < 2_000:
|
||||
QTest.qWait(10)
|
||||
|
||||
filters = page._query_filters()
|
||||
assert filters["status"] == 1
|
||||
assert filters["include_status_counts"] == 1
|
||||
assert filters["start_date"] == filters["end_date"]
|
||||
assert "diag_scope_relax" not in filters
|
||||
assert page.table.rowCount() >= 1
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_demo_appointment_status_counts_respect_date_scope() -> None:
|
||||
@@ -464,8 +473,7 @@ def test_appointment_multiline_cells_receive_enough_row_height(
|
||||
assert appointment_text.count("\n") == 2
|
||||
assert "2026-08-11 14:30" in appointment_text
|
||||
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
|
||||
assert 60 <= page.table.rowHeight(0) <= 66
|
||||
assert page.table.rowHeight(0) >= min(required, 66)
|
||||
assert page.table.rowHeight(0) >= required
|
||||
assert page.table.item(0, 4).toolTip() == appointment_text
|
||||
page.close()
|
||||
|
||||
@@ -854,9 +862,8 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
||||
permissions=PermissionSet(["*"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
# 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter,
|
||||
# and 62 px top bar leaves a 1161x680 page viewport.
|
||||
page.resize(1161, 680)
|
||||
# Approved shared chrome: 208 px rail, 76 px topbar, no outer gutter.
|
||||
page.resize(1158, 692)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
rows = [
|
||||
@@ -886,10 +893,12 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
||||
application.processEvents()
|
||||
|
||||
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
|
||||
# 与其余列表页一致的“面包屑 + 标题 + 副标题”页头。
|
||||
assert page.header.height() == 62
|
||||
assert page.filter_panel.height() <= 84
|
||||
assert all(60 <= height <= 66 for height in heights)
|
||||
# Compact title and folded filters leave more room for the patient queue.
|
||||
assert page.header.height() >= page.header.minimumSizeHint().height()
|
||||
assert page.header.height() <= 44
|
||||
assert page.filter_panel.isHidden()
|
||||
assert all(60 <= height <= 84 for height in heights)
|
||||
assert all(page.table.cellWidget(row, 4).height() >= page.table.cellWidget(row, 4).minimumSizeHint().height() for row in range(page.table.rowCount()))
|
||||
assert page.table.viewport().height() // max(heights) >= 4
|
||||
assert page.pager.isVisibleTo(page)
|
||||
assert page.content_layout.count() == 1
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Native Qt interaction and layout checks for the approved appointment surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from datetime import date
|
||||
from itertools import combinations
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTabBar,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.pages import appointments as appointments_module
|
||||
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.rows = [
|
||||
{
|
||||
"id": identifier,
|
||||
"patient_name": f"测试患者{identifier}",
|
||||
"patient_phone": "13800001234",
|
||||
"gender": 1,
|
||||
"age": 36,
|
||||
"status": 1,
|
||||
"status_desc": "待接诊",
|
||||
"appointment_date": date.today().isoformat(),
|
||||
"appointment_time": "09:00-09:30",
|
||||
"doctor_name": "测试医生",
|
||||
"doctor_id": 21,
|
||||
"diagnosis_id": identifier + 100,
|
||||
"source_patient_id": identifier + 200,
|
||||
"diagnosis_confirmed": True,
|
||||
"channel_name": "测试渠道",
|
||||
}
|
||||
for identifier in (401, 402, 403)
|
||||
]
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
|
||||
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||
self.queries.append(kwargs)
|
||||
return {
|
||||
"lists": deepcopy(self.rows),
|
||||
"count": len(self.rows),
|
||||
"extend": {"status_count": {"1": len(self.rows)}},
|
||||
}
|
||||
|
||||
def list_departments(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 10, "name": "测试部门", "children": []}]
|
||||
|
||||
|
||||
class _QuietPage(QWidget):
|
||||
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
QTest.qWait(5)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def run_inline(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args: Any, **_kwargs: Any):
|
||||
pytest.fail("The appointment visual tests must stay offline")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(appointments_module, "run_async", run_inline)
|
||||
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||
navigation = [
|
||||
NavigationItem("appointments", "挂号列表", "号", AppointmentsPage, ("doctor.appointment/lists",)),
|
||||
NavigationItem("reception", "接诊台", "◎", _QuietPage, ("doctor.appointment/lists",)),
|
||||
NavigationItem("patients", "我的患者", "患", _QuietPage, ("firstvisit.myPatient/lists",)),
|
||||
NavigationItem("prescriptions", "已开处方", "笺", _QuietPage, ("tcm.prescription/lists",)),
|
||||
NavigationItem("legacy_reference", "参考页", "参", _QuietPage, ()),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
shell_module, "_resolve_navigation",
|
||||
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
|
||||
)
|
||||
windows = []
|
||||
|
||||
def create(*, admin: bool = False, width: int = 1536, height: int = 960):
|
||||
repository = _Repository()
|
||||
shell = ShellWindow(
|
||||
repository,
|
||||
{"user": {"name": "测试医生", "role_id": 3 if admin else 1}, "demo_mode": True},
|
||||
permissions={"*"},
|
||||
)
|
||||
windows.append(shell)
|
||||
shell.resize(width, height)
|
||||
shell.show()
|
||||
_settle(application)
|
||||
page = shell.pages["appointments"]
|
||||
page.poll_timer.stop()
|
||||
return shell, page, repository
|
||||
|
||||
yield create
|
||||
for shell in windows:
|
||||
shell.close()
|
||||
shell.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _selector(page: AppointmentsPage, row: int) -> QCheckBox:
|
||||
host = page.table.cellWidget(row, 0)
|
||||
assert host is not None
|
||||
selector = host.findChild(QCheckBox)
|
||||
assert selector is not None
|
||||
return selector
|
||||
|
||||
|
||||
def _assert_selection_matches(page: AppointmentsPage) -> None:
|
||||
table = page.table
|
||||
selected = {index.row() for index in table.selectionModel().selectedRows()}
|
||||
assert selected == {table.currentRow()}
|
||||
assert sum(_selector(page, row).isChecked() for row in range(table.rowCount())) == 1
|
||||
for row in range(table.rowCount()):
|
||||
assert _selector(page, row).isChecked() == (row in selected)
|
||||
assert table.cellWidget(row, 0).property("selected") == (row in selected)
|
||||
|
||||
|
||||
def test_real_selector_tracks_initial_row_click_repeat_click_and_refresh(
|
||||
application: QApplication, window_factory
|
||||
) -> None:
|
||||
shell, page, repository = window_factory()
|
||||
assert page.table.rowCount() == 3
|
||||
_assert_selection_matches(page)
|
||||
checkbox = _selector(page, 1)
|
||||
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.table.currentRow() == 1
|
||||
_assert_selection_matches(page)
|
||||
|
||||
# Clicking the selected checkbox cannot leave the selected patient unchecked.
|
||||
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
_assert_selection_matches(page)
|
||||
assert page.table.currentRow() == 1
|
||||
|
||||
target = page.table.item(2, 2)
|
||||
QTest.mouseClick(
|
||||
page.table.viewport(), Qt.MouseButton.LeftButton,
|
||||
pos=page.table.visualItemRect(target).center(),
|
||||
)
|
||||
_settle(application)
|
||||
assert page.table.currentRow() == 2
|
||||
_assert_selection_matches(page)
|
||||
selected_id = page.table.current_data()["id"]
|
||||
|
||||
shell.refresh_button.click()
|
||||
_settle(application)
|
||||
assert page.table.current_data()["id"] == selected_id
|
||||
_assert_selection_matches(page)
|
||||
|
||||
# Changed server data rebuilds the cells, unlike unchanged polling.
|
||||
repository.rows[0]["channel_name"] = "更新后的测试渠道"
|
||||
shell.refresh_button.click()
|
||||
_settle(application)
|
||||
assert page.table.current_data()["id"] == selected_id
|
||||
_assert_selection_matches(page)
|
||||
|
||||
|
||||
def test_selector_selects_its_visible_patient_after_sorting(
|
||||
application: QApplication, window_factory
|
||||
) -> None:
|
||||
_shell, page, _repository = window_factory()
|
||||
# A user can change sorting after cell widgets have already been installed.
|
||||
first_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
header = page.table.horizontalHeader()
|
||||
header_position = QPoint(
|
||||
header.sectionViewportPosition(1) + header.sectionSize(1) // 2,
|
||||
header.height() // 2,
|
||||
)
|
||||
for _ in range(2):
|
||||
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=header_position)
|
||||
_settle(application)
|
||||
if page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id:
|
||||
break
|
||||
expected_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
assert expected_id != first_id
|
||||
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.table.current_data()["id"] == expected_id
|
||||
_assert_selection_matches(page)
|
||||
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.table.current_data()["id"] == expected_id
|
||||
_assert_selection_matches(page)
|
||||
|
||||
|
||||
def _rect(widget: QWidget, ancestor: QWidget) -> QRect:
|
||||
return QRect(widget.mapTo(ancestor, QPoint()), widget.size())
|
||||
|
||||
|
||||
def _assert_filter_layout(page: AppointmentsPage) -> None:
|
||||
types = (QPushButton, QComboBox, QLineEdit, QTabBar, QLabel)
|
||||
controls = [widget for widget in page.filter_panel.findChildren(QWidget)
|
||||
if isinstance(widget, types) and widget.isVisible()]
|
||||
panel = page.filter_panel.rect()
|
||||
for widget in controls:
|
||||
assert panel.contains(_rect(widget, page.filter_panel)), widget.objectName()
|
||||
for first, second in combinations(controls, 2):
|
||||
if first.isAncestorOf(second) or second.isAncestorOf(first):
|
||||
continue
|
||||
overlap = _rect(first, page).intersected(_rect(second, page))
|
||||
assert overlap.isEmpty(), (first.objectName(), second.objectName(), overlap)
|
||||
assert not _rect(page.filter_panel, page).intersects(_rect(page.table_card, page))
|
||||
assert page.table.viewport().height() > 80
|
||||
|
||||
|
||||
@pytest.mark.parametrize("admin", [False, True], ids=["doctor", "admin"])
|
||||
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 640)])
|
||||
def test_more_filters_fit_exact_window_and_preserve_all_columns(
|
||||
application: QApplication, window_factory, admin: bool, width: int, height: int
|
||||
) -> None:
|
||||
shell, page, _repository = window_factory(admin=admin, width=width, height=height)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
for expanded in (False, True, False):
|
||||
if page.more_filters_button.isChecked() != expanded:
|
||||
QTest.mouseClick(page.more_filters_button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert shell.size() == QSize(width, height)
|
||||
assert page.advanced_filters.isVisible() == expanded
|
||||
assert page.dept_filter.isVisible() == expanded
|
||||
assert page.doctor_input.isVisible() == (expanded and admin)
|
||||
assert page.custom_date_button.isVisible() == expanded
|
||||
assert page.reset_filter_button.isVisible() == expanded
|
||||
_assert_filter_layout(page)
|
||||
assert page.table.columnCount() == 11
|
||||
assert all(not page.table.isColumnHidden(column) for column in range(11))
|
||||
assert [page.table.horizontalHeaderItem(column).text() for column in range(11)] == [
|
||||
"", "ID", "患者", "性别 / 年龄", "挂号信息", "确认", "复诊", "助理", "开方", "未服务天数", "IM 问诊",
|
||||
]
|
||||
if width == 1024:
|
||||
assert page.table.horizontalScrollBar().maximum() > 0
|
||||
page.table.horizontalScrollBar().setValue(page.table.horizontalScrollBar().maximum())
|
||||
_settle(application)
|
||||
right = page.table.columnViewportPosition(10) + page.table.columnWidth(10)
|
||||
assert right <= page.table.viewport().width()
|
||||
|
||||
|
||||
def test_page_typography_remains_compact_and_chrome_restores(
|
||||
application: QApplication, window_factory
|
||||
) -> None:
|
||||
shell, page, _repository = window_factory()
|
||||
original_qss = application.styleSheet()
|
||||
assert page.header.title_label.font().pixelSize() == 20
|
||||
assert page.patient_input.font().pixelSize() == 14
|
||||
assert page.table.font().pixelSize() == 14
|
||||
assert page.table.item(0, 2).font().pixelSize() == 14
|
||||
assert page.header.subtitle_label.font().pixelSize() == 13
|
||||
assert page.more_filters_button.font().pixelSize() == 13
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert shell.navigate("reception")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert shell.navigate("patients")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert shell.navigate("prescriptions")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert shell.navigate("legacy_reference")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 190
|
||||
assert shell.topbar.height() == 62
|
||||
assert shell.workspace.pos() == QPoint(203, 13)
|
||||
assert shell.fold_button.isVisible()
|
||||
assert not shell.menu_sidebar_action.isVisible()
|
||||
for widget, stylesheet in shell._legacy_chrome_styles.items():
|
||||
assert widget.styleSheet() == stylesheet
|
||||
assert shell.navigate("appointments")
|
||||
_settle(application)
|
||||
assert shell.sidebar.width() == 208
|
||||
assert shell.topbar.height() == 76
|
||||
assert page.header.title_label.font().pixelSize() == 20
|
||||
assert application.styleSheet() == original_qss
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Native Qt coverage for presentation-only filter folding on both queues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QRect, Qt, Signal
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.pages import appointments, consultations
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Dialog(QWidget):
|
||||
saved = Signal()
|
||||
|
||||
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(query)
|
||||
rows = [{
|
||||
"id": index, "diagnosis_id": index, "patient_id": index + 2000,
|
||||
"source_patient_id": index + 2000, "patient_name": f"演示患者{index:02d}",
|
||||
"patient_phone": "13800001234", "gender": 2, "age": 38,
|
||||
"status": 1, "status_desc": "待接诊", "doctor_name": "演示医生",
|
||||
"doctor_id": 30, "assistant_name": "演示医助", "has_appointment": True,
|
||||
"appointment_id": index + 1000, "appointment_status": 1,
|
||||
"appointment_date": date.today().isoformat(), "appointment_time": "09:00-09:30",
|
||||
"diagnosis_confirmed": True, "has_prescription": False,
|
||||
"appointments": [{"id": index + 1000, "status": 1,
|
||||
"doctor_name": "演示医生", "time_text": "09:00-09:30"}],
|
||||
} for index in range(1, 16)]
|
||||
return {"lists": rows, "count": 15, "extend": {"status_count": {"1": 15}}}
|
||||
|
||||
list_appointments = _list
|
||||
list_consultations = _list
|
||||
|
||||
|
||||
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
QTest.qWait(10)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def list_window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(appointments, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "run_async", _inline)
|
||||
monkeypatch.setattr(consultations, "DiagnosisDialog", _Dialog)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||
navigation = [
|
||||
NavigationItem("appointments", "挂号列表", "号", appointments.AppointmentsPage,
|
||||
("doctor.appointment/lists",)),
|
||||
NavigationItem("consultations", "问诊列表", "询", consultations.ConsultationsPage,
|
||||
("tcm.diagnosis/lists",)),
|
||||
]
|
||||
monkeypatch.setattr(shell_module, "_resolve_navigation",
|
||||
lambda *_args, **_kwargs: [(item, item.title) for item in navigation])
|
||||
repository = _Repository()
|
||||
window = ShellWindow(repository, {"user": {"name": "演示医生", "role_id": 1},
|
||||
"demo_mode": True}, permissions={"*"})
|
||||
yield window, repository
|
||||
window.close()
|
||||
window.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["appointments", "consultations"])
|
||||
@pytest.mark.parametrize(("width", "height"), [(1366, 768), (1536, 960)])
|
||||
def test_folding_reclaims_rows_and_retains_query_refresh_and_navigation_state(
|
||||
application: QApplication, list_window: Any, kind: str, width: int, height: int,
|
||||
) -> None:
|
||||
window, repository = list_window
|
||||
window.resize(width, height)
|
||||
window.show()
|
||||
assert window.navigate(kind)
|
||||
_settle(application)
|
||||
page = window.pages[kind]
|
||||
page.poll_timer.stop()
|
||||
panel = page.filter_panel if kind == "appointments" else page.filters_card
|
||||
header = page.header if kind == "appointments" else page.page_header
|
||||
search = page.patient_input if kind == "appointments" else page.patient_name_edit
|
||||
nested = page.more_filters_button if kind == "appointments" else page.more_filter_button
|
||||
action = page.toolbar_edit_button if kind == "appointments" else page.add_button
|
||||
disclosure = page.filter_disclosure
|
||||
filters = page._query_filters if kind == "appointments" else page._filters
|
||||
assert not disclosure.expanded
|
||||
assert panel.isHidden() and not search.isVisible()
|
||||
assert header.height() <= 44
|
||||
assert not header.subtitle_label.isVisible()
|
||||
assert disclosure.button.isVisible() and action.isVisible()
|
||||
assert 30 <= disclosure.button.height() <= 34
|
||||
assert window.refresh_button.isVisible()
|
||||
assert any(button.isVisible() and button.text() == "刷新"
|
||||
for button in page.findChildren(QPushButton))
|
||||
collapsed_height = page.table.viewport().height()
|
||||
capture_dir = os.environ.get("COLLAPSIBLE_LIST_SCREENSHOTS")
|
||||
if capture_dir:
|
||||
Path(capture_dir).mkdir(parents=True, exist_ok=True)
|
||||
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-collapsed.png"))
|
||||
|
||||
calls_before = len(repository.calls)
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls_before
|
||||
assert panel.isVisible() and search.isVisible()
|
||||
assert collapsed_height >= page.table.viewport().height() + 100
|
||||
assert panel.rect().contains(QRect(search.mapTo(panel, QPoint()), search.size()))
|
||||
assert search.parentWidget().rect().contains(search.geometry())
|
||||
assert not page.advanced_filters.isVisible()
|
||||
if capture_dir:
|
||||
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-expanded.png"))
|
||||
(Path(capture_dir) / f"{kind}-{width}-metrics.json").write_text(
|
||||
json.dumps({"window": [width, height], "header_height": header.height(),
|
||||
"toggle_height": disclosure.button.height(),
|
||||
"collapsed_viewport_height": collapsed_height,
|
||||
"expanded_viewport_height": page.table.viewport().height(),
|
||||
"viewport_gain": collapsed_height - page.table.viewport().height()},
|
||||
indent=2), encoding="utf-8",
|
||||
)
|
||||
|
||||
search.setText("演示患者")
|
||||
query_button = (page.findChild(QPushButton, "AppointmentSearchButton")
|
||||
if kind == "appointments" else page.search_button)
|
||||
QTest.mouseClick(query_button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) > calls_before
|
||||
assert repository.calls[-1]["patient_name"] == "演示患者"
|
||||
QTest.mouseClick(nested, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.advanced_filters.isVisible()
|
||||
query = dict(filters())
|
||||
calls_before = len(repository.calls)
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert len(repository.calls) == calls_before
|
||||
assert filters() == query
|
||||
assert search.text() == "演示患者"
|
||||
assert panel.isHidden()
|
||||
assert page.table.viewport().height() == collapsed_height
|
||||
|
||||
window.resize(width - 50, height - 20)
|
||||
window.refresh_button.click()
|
||||
_settle(application)
|
||||
assert not disclosure.expanded and panel.isHidden()
|
||||
assert filters() == query
|
||||
other = "consultations" if kind == "appointments" else "appointments"
|
||||
assert window.navigate(other)
|
||||
assert window.navigate(kind)
|
||||
_settle(application)
|
||||
assert not disclosure.expanded and panel.isHidden()
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
assert page.advanced_filters.isVisible() and nested.isChecked()
|
||||
window.refresh_button.click()
|
||||
_settle(application)
|
||||
assert disclosure.expanded and panel.isVisible()
|
||||
assert filters() == query
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Page disclosures reclaim list space without changing queries or tab state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientsPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def _result(self, kind: str, query: dict[str, Any]) -> dict[str, Any]:
|
||||
self.calls.append((kind, deepcopy(query)))
|
||||
return {
|
||||
"lists": [{
|
||||
"id": 101 + index, "diagnosis_id": 501 + index,
|
||||
"patient_id": 301 + index, "patient_name": f"患者 {index + 1}",
|
||||
"order_no": f"TEST-20260907-{index + 1}", "queue_no": index + 1,
|
||||
"appointment_status": 1, "queue_status": "waiting",
|
||||
"queue_status_text": "等待中", "doctor_name": "测试医生",
|
||||
} for index in range(4)],
|
||||
"count": 4,
|
||||
"extend": {
|
||||
"scope": {"label": "本人患者"}, "schedule_mode": "ownership",
|
||||
"summary": {"waiting": 4, "today": 4, "orders": 4},
|
||||
},
|
||||
}
|
||||
|
||||
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("patients", query)
|
||||
|
||||
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("orders", query)
|
||||
|
||||
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||
return self._result("progress", query)
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application, monkeypatch):
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args, **_kwargs):
|
||||
pytest.fail("Disclosure tests must use local fixture data")
|
||||
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
opened = []
|
||||
|
||||
def create(width=1328, height=884):
|
||||
repository = Repository()
|
||||
page = PatientsPage(repository, PermissionSet(["*"]))
|
||||
opened.append(page)
|
||||
page.resize(width, height)
|
||||
page.show()
|
||||
settle(application)
|
||||
return page, repository
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def regions(page, index):
|
||||
return (
|
||||
(page.patient_workspace.search_toolbar, page.patient_workspace.filter_card,
|
||||
page.patient_workspace.summary_strip),
|
||||
(page.order_workspace.filter_card, page.order_workspace.summary_strip),
|
||||
(page.progress_workspace.overview_card, page.progress_workspace.schedule_card),
|
||||
)[index]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0, 1, 2], ids=["patients", "orders", "progress"])
|
||||
def test_tabs_default_collapsed_and_keyboard_expansion_gives_space_to_lists(
|
||||
application, page_factory, index
|
||||
):
|
||||
page, repository = page_factory()
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
table = workspace.queue_table if index == 2 else workspace.table
|
||||
disclosure = page.filter_disclosure
|
||||
assert not disclosure.expanded
|
||||
assert all(widget.isHidden() for widget in regions(page, index))
|
||||
assert sum(item.button.isVisible() for item in page.filter_disclosures) == 1
|
||||
assert disclosure.button.text() == disclosure.button.accessibleName() == "展开筛选"
|
||||
assert disclosure.button.height() == 32
|
||||
assert page.header.height() <= 48
|
||||
assert not page.header.subtitle_label.isVisible()
|
||||
assert page.refresh_button.isVisible() and page.tabs.tabBar().isVisible()
|
||||
collapsed_height = table.viewport().height()
|
||||
before = deepcopy(repository.calls)
|
||||
disclosure.button.setFocus()
|
||||
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||
settle(application)
|
||||
assert disclosure.expanded and disclosure.button.text() == "收起筛选"
|
||||
assert all(widget.isVisible() for widget in regions(page, index))
|
||||
assert collapsed_height >= table.viewport().height() + 100
|
||||
assert repository.calls == before
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert table.viewport().height() == collapsed_height
|
||||
assert table.isVisible() and workspace.pager.isVisible()
|
||||
assert repository.calls == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", [0, 1], ids=["patient-filters", "order-filters"])
|
||||
def test_search_values_survive_collapse_refresh_and_tab_switch(
|
||||
application, page_factory, index
|
||||
):
|
||||
page, repository = page_factory()
|
||||
page.tabs.setCurrentIndex(index)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
workspace.keyword_edit.setText(" 林青 ")
|
||||
if index == 0:
|
||||
QTest.mouseClick(workspace.custom_date_button, Qt.MouseButton.LeftButton)
|
||||
else:
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.rx_audit.setCurrentIndex(2)
|
||||
workspace.start_date.setDate(QDate(2026, 9, 1))
|
||||
workspace.end_date.setDate(QDate(2026, 9, 7))
|
||||
QTest.mouseClick(workspace.search_button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
query = deepcopy(repository.calls[-1])
|
||||
before = deepcopy(repository.calls)
|
||||
page.filter_disclosure.set_expanded(False)
|
||||
settle(application)
|
||||
assert repository.calls == before
|
||||
assert workspace.keyword_edit.text() == " 林青 "
|
||||
assert workspace.start_date.date() == QDate(2026, 9, 1)
|
||||
assert workspace.end_date.date() == QDate(2026, 9, 7)
|
||||
QTest.mouseClick(page.refresh_button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert repository.calls[-1] == query
|
||||
assert not page.filter_disclosure.expanded
|
||||
page.tabs.setCurrentIndex(2)
|
||||
assert not page.filter_disclosure.expanded
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert repository.calls[-1] == query
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
assert workspace.keyword_edit.isVisible() and workspace.start_date.isEnabled()
|
||||
assert (workspace.custom_date_button.isChecked() if index == 0 else
|
||||
workspace.rx_audit.currentIndex() == 2 and workspace.use_dates.isChecked())
|
||||
page.tabs.setCurrentIndex(2)
|
||||
assert page.filter_disclosure.expanded
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1328, 884), (1158, 692), (816, 564)])
|
||||
def test_resize_keeps_collapsed_regions_hidden_and_queue_reachable(
|
||||
application, page_factory, width, height
|
||||
):
|
||||
page, _repository = page_factory()
|
||||
page.resize(width, height)
|
||||
for index in range(3):
|
||||
page.tabs.setCurrentIndex(index)
|
||||
settle(application)
|
||||
workspace = page.tabs.currentWidget()
|
||||
assert all(widget.isHidden() for widget in regions(page, index))
|
||||
for widget in (page.filter_disclosure.button, page.refresh_button):
|
||||
assert page.rect().contains(QRect(widget.mapTo(page, QPoint()), widget.size()))
|
||||
assert workspace.scroll.verticalScrollBar().maximum() == 0
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
if index == 2:
|
||||
assert workspace.splitter.minimumHeight() == 222
|
||||
assert workspace.queue_card.isVisible()
|
||||
assert workspace.queue_card.height() == workspace.splitter.height()
|
||||
page.hide()
|
||||
page.show()
|
||||
settle(application)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert all(widget.isHidden() for widget in regions(page, 2))
|
||||
|
||||
|
||||
def test_collapsed_refresh_coalesces_identical_in_flight_queries(
|
||||
application, page_factory, monkeypatch
|
||||
):
|
||||
page, _repository = page_factory()
|
||||
pending = []
|
||||
monkeypatch.setattr(
|
||||
patients_module, "run_async",
|
||||
lambda function, **callbacks: pending.append((function, callbacks)),
|
||||
)
|
||||
page.refresh()
|
||||
page.refresh()
|
||||
assert len(pending) == 1
|
||||
function, callbacks = pending[0]
|
||||
callbacks["on_success"](function())
|
||||
settle(application)
|
||||
assert page.patient_workspace.table.rowCount() == 4
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert not page.patient_workspace.search_toolbar.isVisible()
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(application):
|
||||
for _ in range(8):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
widget.resize(1280, 800)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_default_fold_reclaims_queue_space_and_keeps_clinical_workspace(page, application):
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert page.filter_disclosure.button.text() == "展开筛选"
|
||||
assert page.refresh_button.isVisible()
|
||||
assert page.queue_filter_summary.isVisible()
|
||||
assert not page.queue_filter_panel.isVisible()
|
||||
assert not page.queue_date_button.isVisible()
|
||||
assert not page.search_edit.isVisible()
|
||||
assert all(not button.isVisible() for button in page._queue_filter_buttons.values())
|
||||
assert page.detail_tabs.isVisible()
|
||||
assert page.patient_name_label.isVisible()
|
||||
assert page.clinical_info_group.isVisible()
|
||||
clinical_geometry = page.detail_stack.geometry()
|
||||
collapsed_height = page.queue_stack.height()
|
||||
|
||||
QTest.mouseClick(page.filter_disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert page.filter_disclosure.expanded
|
||||
assert page.filter_disclosure.button.text() == "收起筛选"
|
||||
assert page.queue_date_button.isVisible()
|
||||
assert page.search_edit.isVisible()
|
||||
assert all(button.isVisible() for button in page._queue_filter_buttons.values())
|
||||
assert collapsed_height - page.queue_stack.height() >= 64
|
||||
assert page.detail_stack.geometry() == clinical_geometry
|
||||
|
||||
|
||||
def test_toggle_keeps_queries_patient_draft_loaded_pages_and_resize_state(page, application, monkeypatch):
|
||||
requests = []
|
||||
monkeypatch.setattr(page, "_request_queue_page", lambda query, **options: requests.append((query, options)))
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.queue_date_button._pick_date(QDate(2026, 8, 3))
|
||||
page._queue_filter_buttons[None].click()
|
||||
page.search_edit.setText(" 折叠测试患者 ")
|
||||
QTest.keyClick(page.search_edit, Qt.Key.Key_Return)
|
||||
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||
assert requests[-1][0]["status"] is None
|
||||
assert requests[-1][0]["start_date"] == requests[-1][0]["end_date"] == "2026-08-03"
|
||||
assert page.queue_filter_summary.text() == "2026-08-03 · 全部状态 · 姓名已筛选"
|
||||
assert "折叠测试患者" in page.queue_filter_summary.toolTip()
|
||||
|
||||
page._selected_appointment_id = 51
|
||||
page._queue_records = [{"id": index} for index in range(1, 31)]
|
||||
page._queue_page = 2
|
||||
page._queue_total = 40
|
||||
page.note_edit.setPlainText("尚未保存的接诊备注")
|
||||
page.detail_tabs.setCurrentIndex(4)
|
||||
query_snapshot = dict(page._queue_query)
|
||||
request_count = len(requests)
|
||||
loading = page._queue_loading
|
||||
|
||||
for expanded in (False, True, False):
|
||||
page.filter_disclosure.set_expanded(expanded)
|
||||
page.resize(816 if expanded else 1280, 800)
|
||||
settle(application)
|
||||
assert page.filter_disclosure.expanded is expanded
|
||||
assert page.queue_filter_panel.isVisible() is expanded
|
||||
assert len(requests) == request_count
|
||||
assert page._selected_appointment_id == 51
|
||||
assert page._queue_page == 2
|
||||
assert len(page._queue_records) == 30
|
||||
assert page._queue_loading is loading
|
||||
assert page._queue_query == query_snapshot
|
||||
assert page.search_edit.text() == " 折叠测试患者 "
|
||||
assert page.note_edit.toPlainText() == "尚未保存的接诊备注"
|
||||
assert page.detail_tabs.currentIndex() == 4
|
||||
|
||||
page.refresh(workspace_refresh=True)
|
||||
assert not page.filter_disclosure.expanded
|
||||
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||
assert requests[-1][0]["status"] is None
|
||||
assert requests[-1][0]["start_date"] == "2026-08-03"
|
||||
assert requests[-1][0]["page_size"] == 30
|
||||
assert page._selected_appointment_id == 51
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [816, 1280])
|
||||
def test_expanded_queue_controls_fit_at_supported_page_widths(page, application, width):
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
page.resize(width, 800)
|
||||
page._update_queue_filter_counts({"extend": {"status_count": {"1": 23, "2": 11, "3": 42}}}, [])
|
||||
settle(application)
|
||||
panel = page.queue_panel
|
||||
controls = [
|
||||
page.refresh_button,
|
||||
page.filter_disclosure.button,
|
||||
page.queue_date_button,
|
||||
page.search_edit,
|
||||
*page._queue_filter_buttons.values(),
|
||||
]
|
||||
for widget in controls:
|
||||
position = widget.mapTo(panel, QPoint())
|
||||
assert position.x() >= 0
|
||||
assert position.x() + widget.width() <= panel.width()
|
||||
assert widget.width() >= widget.minimumSizeHint().width()
|
||||
assert page.queue_date_button.geometry().right() < page.search_edit.geometry().left()
|
||||
assert page.filter_disclosure.button.height() == page.search_edit.height() == 32
|
||||
@@ -365,9 +365,11 @@ def test_refresh_generation_ignores_late_results(
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(consultations_module, "run_async", queue_async)
|
||||
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
|
||||
page.refresh(silent=True)
|
||||
page.refresh(silent=True)
|
||||
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
|
||||
page.refresh(silent=True)
|
||||
# Identical in-flight requests are deduplicated; a new query supersedes one.
|
||||
page.keyword_edit.setText("新患者")
|
||||
page.refresh(silent=True)
|
||||
|
||||
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
|
||||
callbacks[0]["on_success"]({"lists": [_row(id=901, diagnosis_id=901)], "count": 1})
|
||||
|
||||
@@ -986,14 +986,14 @@ def test_choice_chips_keep_visible_checked_style_when_readonly(
|
||||
application.processEvents()
|
||||
# Sample the pad (not glyph center) so white text does not hide the fill.
|
||||
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert enabled_color.name().lower() == "#f0f2ff"
|
||||
assert enabled_color.name().lower() == "#eef1fa"
|
||||
diet.setReadOnly(True)
|
||||
application.processEvents()
|
||||
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert selected.isChecked()
|
||||
assert selected.isEnabled()
|
||||
assert diet.isReadOnly()
|
||||
assert disabled_color.name().lower() == "#f0f2ff"
|
||||
assert disabled_color.name().lower() == "#eef1fa"
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -1013,7 +1013,7 @@ def test_view_only_drawer_shows_selected_choice_chips(
|
||||
selected = next(button for button in diet._buttons if button.isChecked())
|
||||
application.processEvents()
|
||||
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert color.name().lower() == "#f0f2ff"
|
||||
assert color.name().lower() == "#eef1fa"
|
||||
assert diet.isReadOnly()
|
||||
assert not dialog.save_button.isVisibleTo(dialog)
|
||||
dialog.close()
|
||||
|
||||
@@ -10,11 +10,14 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import pytest
|
||||
from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
@@ -161,27 +164,44 @@ def _page() -> ConsultationsPage:
|
||||
return ConsultationsPage(_CancellationRepository(), permissions=PermissionSet(["*"]))
|
||||
|
||||
|
||||
def _caret_matches(button: object, direction: str) -> bool:
|
||||
"""The disclosure caret is now a shared glyph, not Fusion's arrow type.
|
||||
|
||||
``setArrowType`` drew a solid triangle - the one filled mark in an otherwise
|
||||
all-stroke icon set - so the state is carried by the icon instead, and the
|
||||
direction has to be checked by comparing what was actually painted.
|
||||
"""
|
||||
|
||||
from doctor_workstation.ui import icons
|
||||
|
||||
painted = button.icon().pixmap(14, 14).toImage()
|
||||
expected = icons.pixmap(direction, "muted", 14).toImage()
|
||||
return painted == expected
|
||||
|
||||
|
||||
def test_visual_hierarchy_and_filter_contract(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
content_layout = page.page_scroll.widget().layout()
|
||||
margins = content_layout.contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (18, 10, 18, 10)
|
||||
assert content_layout.spacing() == 8
|
||||
# The approved blue shell has no outer gutter; the page owns this spacing.
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (27, 24, 26, 8)
|
||||
assert content_layout.spacing() == 10
|
||||
status_card = page.findChild(QFrame, "DiagnosisStatusCard")
|
||||
assert status_card is not None
|
||||
assert page.page_header.height() == 62
|
||||
assert status_card.height() == 50
|
||||
assert page.page_header.maximumHeight() >= page.page_header.minimumSizeHint().height()
|
||||
assert status_card.height() == 54
|
||||
assert page.findChild(QFrame, "DiagnosisFilterCard") is not None
|
||||
assert page.findChild(QFrame, "DiagnosisListCard") is not None
|
||||
assert page.filters_card.height() == 90
|
||||
assert page.keyword_edit.maximumWidth() == 380
|
||||
assert page.filters_card.isHidden()
|
||||
assert page.page_header.height() <= 44
|
||||
assert page.keyword_edit.maximumWidth() == 340
|
||||
assert list(page.status_buttons) == ["1", "", "4", "2", "3"]
|
||||
assert page.status_buttons["1"].isChecked()
|
||||
assert not page.advanced_filters.isVisible()
|
||||
assert page.more_filter_button.text() == "更多筛选"
|
||||
assert page.more_filter_button.arrowType() == Qt.ArrowType.DownArrow
|
||||
assert _caret_matches(page.more_filter_button, "down")
|
||||
assert [page._date_button_labels[key] for key in page.date_buttons] == [
|
||||
"昨天挂号",
|
||||
"前天挂号",
|
||||
@@ -228,7 +248,7 @@ def test_pending_assign_and_secondary_chip_semantics(
|
||||
page._toggle_advanced_filters(True)
|
||||
assert not page.advanced_filters.isHidden()
|
||||
assert page.more_filter_button.text() == "收起"
|
||||
assert page.more_filter_button.arrowType() == Qt.ArrowType.UpArrow
|
||||
assert _caret_matches(page.more_filter_button, "up")
|
||||
assert page.unserved_sort_combo.isHidden()
|
||||
date_ranges = page.advanced_filters.findChildren(QFrame, "DiagnosisDateRange")
|
||||
assert len(date_ranges) == 2
|
||||
@@ -237,15 +257,81 @@ def test_pending_assign_and_secondary_chip_semantics(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_toolbar_cancel_tracks_single_appointment_without_changing_checked_rows(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
single = _row(880)
|
||||
multiple = _row(881, appointments=[
|
||||
{"id": 8801, "status": 1}, {"id": 8802, "status": 3},
|
||||
])
|
||||
page.table_host.set_rows([single, multiple])
|
||||
page.table.selectRow(0)
|
||||
page._selection_changed()
|
||||
assert page.cancel_toolbar_button.isEnabled()
|
||||
assert page.case_toolbar_button.isEnabled()
|
||||
assert not page.call_toolbar_button.isEnabled()
|
||||
page.table.selectRow(1)
|
||||
page._selection_changed()
|
||||
assert not page.cancel_toolbar_button.isEnabled()
|
||||
assert page.table_host.selected_records() == []
|
||||
page.table_host.set_rows([])
|
||||
page._selection_changed()
|
||||
assert not page.case_toolbar_button.isEnabled()
|
||||
assert not page.prescription_toolbar_button.isEnabled()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(816, 564), (1328, 884)])
|
||||
def test_filter_rows_and_toolbar_stay_inside_their_panels_when_wrapping(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
size: tuple[int, int],
|
||||
) -> None:
|
||||
page = _page()
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
for expanded in (False, True):
|
||||
page._toggle_advanced_filters(expanded)
|
||||
if expanded:
|
||||
page._choose_pending_assign()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
controls = [*page.date_buttons.values(), page.custom_date_edit,
|
||||
page.confirmed_combo, page.department_combo, page.keyword_edit,
|
||||
page.more_filter_button]
|
||||
if expanded:
|
||||
controls.extend([page.pending_assign_month, page.pending_assign_keyword,
|
||||
page.channel_combo, page.latest_assign_end_date])
|
||||
rectangles = [QRect(control.mapTo(page.filters_card, QPoint()), control.size())
|
||||
for control in controls if control.isVisible()]
|
||||
assert all(page.filters_card.rect().contains(rect) for rect in rectangles)
|
||||
for index, rect in enumerate(rectangles):
|
||||
assert all(not rect.intersects(other) for other in rectangles[index + 1:])
|
||||
for button in (page.add_button, page.cancel_toolbar_button,
|
||||
page.complete_toolbar_button, page.refresh_button):
|
||||
if button.isVisible():
|
||||
assert page.list_toolbar.rect().contains(
|
||||
QRect(button.mapTo(page.list_toolbar, QPoint()), button.size())
|
||||
)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
page = _page()
|
||||
assert isinstance(page.table.model(), QAbstractTableModel)
|
||||
assert isinstance(page.table.model(), DiagnosisTableModel)
|
||||
assert page.table_host.LEFT_WIDTHS == (48, 70, 60, 100, 175, 88, 120, 100, 72, 110)
|
||||
assert page.table_host.FIXED_WIDTHS == (120, 410)
|
||||
assert page.table_host.fixed.width() == 532
|
||||
assert page.table_host.LEFT_WIDTHS == (48, 70, 82, 102, 244, 90, 84, 90, 88, 122)
|
||||
# Reference-aligned video/actions remain frozen in a compact 250px pane.
|
||||
assert page.table_host.FIXED_WIDTHS == (92, 158)
|
||||
assert page.table_host.fixed.width() == 250
|
||||
assert page.table.isColumnHidden(10)
|
||||
assert page.table_host.fixed.isColumnHidden(9)
|
||||
assert not page.table_host.fixed.isColumnHidden(10)
|
||||
@@ -267,6 +353,9 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
page.table_host.sort_unserved_requested.connect(requested.append)
|
||||
assert page.table_host.model.headerData(9, Qt.Orientation.Horizontal) == "未服务天数"
|
||||
assert page.table_host.model._sort_direction == ""
|
||||
# This model/action test keeps its fixture rows; a real server sort starts
|
||||
# a new query and intentionally resets the loaded list.
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page.table_host.main.horizontalHeader().sectionClicked.emit(9)
|
||||
assert requested == ["desc"]
|
||||
assert page.table_host.model._sort_direction == "desc"
|
||||
@@ -300,7 +389,7 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
def test_empty_loading_and_compact_footer_keep_the_table_shell(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
@@ -321,13 +410,13 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
page.loading_overlay.stop()
|
||||
|
||||
page.pager.update_state(3, 97)
|
||||
assert 40 <= page.pager.height() <= 44
|
||||
assert page.pager.height() == 24
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
assert pager_margins.top() >= 4
|
||||
assert pager_margins.bottom() >= 4
|
||||
assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40]
|
||||
assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5
|
||||
assert page.pager.jumper.maximum() == 7
|
||||
assert pager_margins.top() == 0
|
||||
assert pager_margins.bottom() == 0
|
||||
assert not page.pager.findChildren(QComboBox)
|
||||
assert not page.pager.findChildren(QSpinBox)
|
||||
assert not page.pager.findChildren(QToolButton)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -335,11 +424,11 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
@pytest.mark.parametrize(
|
||||
("record", "stripe", "channel"),
|
||||
[
|
||||
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#9a6813", "warning"),
|
||||
(_row(702, has_appointment=0, appointments=[]), "#2f6edb", "info"),
|
||||
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#a9691d", "warning"),
|
||||
(_row(702, has_appointment=0, appointments=[]), "#4f63d9", "info"),
|
||||
],
|
||||
)
|
||||
def test_semantic_hover_preserves_gradient_and_three_pixel_stripe(
|
||||
def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
|
||||
application: QApplication,
|
||||
record: dict[str, Any],
|
||||
stripe: str,
|
||||
@@ -363,8 +452,9 @@ def test_semantic_hover_preserves_gradient_and_three_pixel_stripe(
|
||||
assert image.pixelColor(0, 20).name() == stripe
|
||||
assert image.pixelColor(1, 20).name() == stripe
|
||||
assert image.pixelColor(2, 20).name() == stripe
|
||||
gradient = image.pixelColor(6, 20)
|
||||
assert gradient.name() != "#f8f8f8", f"{channel} hover collapsed to a neutral row"
|
||||
assert image.pixelColor(3, 20).name() == "#f7f7f7", f"{channel} stripe widened"
|
||||
assert image.pixelColor(6, 20).name() == "#f7f7f7"
|
||||
assert image.pixelColor(40, 20).name() == "#f7f7f7"
|
||||
|
||||
|
||||
def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
|
||||
@@ -378,9 +468,7 @@ def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
|
||||
assert shadow.isHidden()
|
||||
assert shadow.width() == 12
|
||||
assert shadow.geometry().right() == page.table_host.fixed.geometry().left() - 1
|
||||
assert 'font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei"' in (
|
||||
DIAGNOSIS_INDEX_QSS
|
||||
)
|
||||
assert "font-family:" not in DIAGNOSIS_INDEX_QSS
|
||||
assert "QTableView:focus" in DIAGNOSIS_INDEX_QSS
|
||||
assert "QToolButton[rowLink]:focus" in DIAGNOSIS_INDEX_QSS
|
||||
assert '#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"]' in (
|
||||
@@ -491,7 +579,7 @@ def test_full_more_menu_requires_each_real_repository_capability(
|
||||
min(danger_image.width(), danger_rect.right() + 1),
|
||||
):
|
||||
color = danger_image.pixelColor(x, y)
|
||||
if color.red() > 190 and color.green() < 150 and color.blue() < 150:
|
||||
if color.red() > color.green() + 60 and color.red() > color.blue() + 60:
|
||||
red_text_pixels += 1
|
||||
assert red_text_pixels > 8, "删除文案必须由 danger 色绘制,不能回退成原生黑色"
|
||||
more.menu().hide()
|
||||
@@ -642,7 +730,7 @@ def test_error_state_is_persistent_until_rows_replace_it(application: QApplicati
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 4), ((1710, 920), 7)],
|
||||
[((1366, 768), 3), ((1710, 920), 4)],
|
||||
)
|
||||
def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
|
||||
application: QApplication,
|
||||
@@ -686,9 +774,10 @@ def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable(
|
||||
def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
page = _page()
|
||||
rows = [
|
||||
@@ -704,15 +793,26 @@ def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
host_height = page.table_host.height()
|
||||
calls: list[int] = []
|
||||
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page._change_page_size(40)
|
||||
page.table_host.set_rows(rows)
|
||||
def list_consultations(**query: Any) -> dict[str, Any]:
|
||||
calls.append(query["page_no"])
|
||||
start = (query["page_no"] - 1) * query["page_size"]
|
||||
return {"lists": rows[start:start + query["page_size"]], "count": len(rows)}
|
||||
|
||||
monkeypatch.setattr(page.repository, "list_consultations", list_consultations, raising=False)
|
||||
page.refresh(silent=True)
|
||||
for _ in range(2):
|
||||
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||
QTest.qWait(50)
|
||||
application.processEvents()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
main_scroll = page.table.verticalScrollBar()
|
||||
fixed_scroll = page.table_host.fixed.verticalScrollBar()
|
||||
assert calls == [1, 2, 3]
|
||||
assert page.table.rowCount() == 40
|
||||
assert page.table_host.height() == host_height
|
||||
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||
DiagnosisTableHost,
|
||||
_blue_appointment_layout,
|
||||
_blue_appointment_text,
|
||||
_blue_font,
|
||||
)
|
||||
from doctor_workstation.ui.reception_style import body_family, heading_family
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(12):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _row(identifier: int = 501) -> dict:
|
||||
return {
|
||||
"id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000,
|
||||
"patient_name": "林晓岚", "gender": 2, "age": 46, "doctor_name": "陈医生(演示)",
|
||||
"appointment_id": identifier + 2000, "has_appointment": 1, "appointment_status": 1,
|
||||
"appointment_date": "2026-09-05", "appointment_time": "09:00-09:30",
|
||||
"appointments": [
|
||||
{"id": identifier + 2000, "status": 1, "appointment_date": "2026-09-05"},
|
||||
{"id": identifier + 1900, "status": 3, "appointment_date": "2026-08-06"},
|
||||
],
|
||||
"diagnosis_confirmed": 1, "assistant_name": "周医助", "has_prescription": 0,
|
||||
"unserved_days": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_display_fallback_requires_exact_current_appointment_id(application: QApplication) -> None:
|
||||
row = _row()
|
||||
before = copy.deepcopy(row)
|
||||
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
||||
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
||||
)
|
||||
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||
"—", "2026-08-06 时间 —",
|
||||
)
|
||||
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
||||
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
||||
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||
for missing_id in (0, "", None):
|
||||
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
||||
assert row == before
|
||||
legacy = DiagnosisTableHost()
|
||||
blue = DiagnosisTableHost(tech_blue=True)
|
||||
for host in (legacy, blue):
|
||||
host.set_rows([row])
|
||||
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||
assert blue.model.index(0, 4).data().splitlines() == [
|
||||
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
||||
]
|
||||
assert blue.model.index(0, 9).data() == "—"
|
||||
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||
blue.set_rows([{**row, "appointments": []}])
|
||||
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
||||
legacy.close()
|
||||
blue.close()
|
||||
|
||||
|
||||
def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QApplication) -> None:
|
||||
blue = DiagnosisTableHost(tech_blue=True)
|
||||
legacy = DiagnosisTableHost()
|
||||
row = _row()
|
||||
single = _row(502)
|
||||
single["appointments"] = single["appointments"][:1]
|
||||
long = copy.deepcopy(single)
|
||||
long["id"] = long["diagnosis_id"] = 503
|
||||
long["appointments"][0]["doctor_name"] = "超长完整医生姓名以及完整门诊部门需要换行" * 2
|
||||
long["latest_appointment_channel_text"] = "完整保留的挂号渠道说明" * 4
|
||||
long["has_prescription"] = 1
|
||||
long["followup_time_text"] = "2026-10-15 09:00-09:30"
|
||||
long["followup_doctor_name"] = "复诊医生姓名"
|
||||
long["followup_rx_voided"] = True
|
||||
blue.set_rows([row, single, long])
|
||||
legacy.set_rows([row])
|
||||
assert blue.model.columnCount() == 12
|
||||
assert [blue.main.columnWidth(i) for i in range(10)] == [48, 70, 82, 102, 244, 90, 84, 90, 88, 122]
|
||||
assert blue.main.horizontalHeader().height() == 41
|
||||
assert legacy.main.horizontalHeader().height() == 39
|
||||
assert blue.main.rowHeight(0) == 108
|
||||
assert blue.main.rowHeight(1) == 72
|
||||
assert blue.main.rowHeight(2) > 110
|
||||
assert legacy.main.rowHeight(0) == 96
|
||||
for index in range(3):
|
||||
assert blue.main.rowHeight(index) == blue.fixed.rowHeight(index)
|
||||
assert _blue_font(14).family() == body_family()
|
||||
assert _blue_font(14, medium=True).family() == heading_family()
|
||||
assert _blue_font(14, medium=True).weight() == QFont.Weight.Medium
|
||||
assert _blue_font(13).pixelSize() == 13
|
||||
blue.close()
|
||||
legacy.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
|
||||
application: QApplication, tech_blue: bool,
|
||||
) -> None:
|
||||
window = QWidget()
|
||||
layout = QVBoxLayout(window)
|
||||
layout.setContentsMargins(7, 7, 7, 7)
|
||||
host = DiagnosisTableHost(tech_blue=tech_blue)
|
||||
layout.addWidget(host, 1)
|
||||
footer = QLabel("始终可达的分页")
|
||||
footer.setFixedHeight(42)
|
||||
layout.addWidget(footer)
|
||||
host.set_rows([_row(i) for i in range(40)])
|
||||
window.resize(1280, 640)
|
||||
window.show()
|
||||
_settle(application)
|
||||
minimum = window.minimumSizeHint().height()
|
||||
for width, height in ((780, 480), (1440, 780), (800, 540), (1280, 640)) * 3:
|
||||
window.resize(width, height)
|
||||
_settle(application)
|
||||
# The previous implementation wrote the available height into the
|
||||
# fixed child's minimum and enlarged every ancestor on each relayout.
|
||||
assert window.size().height() == height
|
||||
assert window.minimumSizeHint().height() == minimum
|
||||
assert host.fixed.minimumHeight() == 0
|
||||
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||
assert host.main.viewport().mapTo(host, QPoint()).y() == host.fixed.viewport().mapTo(host, QPoint()).y()
|
||||
main_scroll, fixed_scroll = host.main.verticalScrollBar(), host.fixed.verticalScrollBar()
|
||||
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||
_settle(application)
|
||||
assert main_scroll.value() == fixed_scroll.value()
|
||||
fixed_scroll.setValue(fixed_scroll.maximum())
|
||||
_settle(application)
|
||||
assert main_scroll.value() == fixed_scroll.value()
|
||||
frozen_x = host.fixed.x()
|
||||
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||
_settle(application)
|
||||
assert host.fixed.x() == frozen_x
|
||||
assert host.fixed.geometry().right() <= host.rect().right()
|
||||
assert footer.mapTo(window, QPoint()).y() + footer.height() <= window.height()
|
||||
window.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_checkbox_toggle_and_select_all_do_not_change_current_patient(application: QApplication) -> None:
|
||||
host = DiagnosisTableHost(tech_blue=True)
|
||||
host.resize(1300, 420)
|
||||
host.set_rows([_row(501), _row(502)])
|
||||
host.show()
|
||||
_settle(application)
|
||||
host.main.selectRow(1)
|
||||
box = host.main.visualRect(host.model.index(1, 0)).center()
|
||||
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||
assert [row["id"] for row in host.selected_records()] == [502]
|
||||
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||
assert host.selected_records() == []
|
||||
assert host.main.currentRow() == 1
|
||||
header = host.main.horizontalHeader()
|
||||
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||
assert host.main.currentRow() == 1
|
||||
host.main.selectRow(0)
|
||||
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||
assert host.selected_records() == []
|
||||
host.close()
|
||||
|
||||
|
||||
def test_exact_appointment_cancel_hit_targets_and_video_gates(application: QApplication) -> None:
|
||||
host = DiagnosisTableHost(tech_blue=True, action_policy={
|
||||
"view": True, "edit": True, "appointment_cancel": True, "video_call": True,
|
||||
})
|
||||
row = _row()
|
||||
# Make the second entry cancellable and the first doctor wrap: hit regions
|
||||
# must follow measured content, not a legacy fixed 42-pixel multiplier.
|
||||
row["appointments"][0]["doctor_name"] = "医生与门诊信息完整保留" * 3
|
||||
row["appointments"][1]["status"] = 4
|
||||
host.set_rows([row])
|
||||
host.resize(1300, 500)
|
||||
host.show()
|
||||
_settle(application)
|
||||
received = []
|
||||
host.appointment_cancel_requested.connect(lambda record, identifier: received.append((record["id"], identifier)))
|
||||
overlay = host.main.indexWidget(host.model.index(0, 4))
|
||||
buttons = overlay.findChildren(QToolButton)
|
||||
assert len(buttons) == 2
|
||||
entries, total = _blue_appointment_layout(row, overlay.width())
|
||||
for position, button in enumerate(buttons):
|
||||
assert button.y() == max(9, (overlay.height() - total) // 2) + entries[position][0]
|
||||
assert button.geometry().bottom() < overlay.height()
|
||||
button.click()
|
||||
assert received == [(501, 2501), (501, 2401)]
|
||||
more = host.fixed.indexWidget(host.model.index(0, 11)).findChild(QToolButton, "DiagnosisRowMore")
|
||||
assert "取消挂号" not in [action.text() for action in more.menu().actions()]
|
||||
assert not host.fixed.indexWidget(host.model.index(0, 10)).findChildren(QToolButton)
|
||||
row["video_call_hint"] = {"state": "live"}
|
||||
host.set_rows([row])
|
||||
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||
assert video is not None and video.isEnabled()
|
||||
row["patient_id"] = 0
|
||||
host.set_rows([row])
|
||||
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||
assert video is not None and not video.isEnabled()
|
||||
host.action_policy["appointment_cancel"] = False
|
||||
host.set_rows([row])
|
||||
assert host.main.indexWidget(host.model.index(0, 4)) is None
|
||||
host.close()
|
||||
|
||||
|
||||
def test_real_shell_navigation_has_no_fixed_height_stack_overflow(tmp_path: Path) -> None:
|
||||
script = tmp_path / "shell_navigation.py"
|
||||
script.write_text('''
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
from PySide6.QtCore import Qt, QThreadPool
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||
app = QApplication([])
|
||||
apply_theme(app)
|
||||
def settle():
|
||||
for _ in range(12):
|
||||
QThreadPool.globalInstance().waitForDone(1000)
|
||||
app.processEvents()
|
||||
with patch.object(socket.socket, "connect", side_effect=RuntimeError("offline test")):
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
shell = ShellWindow(repo, {"session": session, "demo_mode": True}, permissions=session.permissions)
|
||||
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||
shell.resize(1536, 960)
|
||||
shell.show()
|
||||
settle()
|
||||
for route in ("consultations", "appointments", "consultations"):
|
||||
shell.navigate(route)
|
||||
settle()
|
||||
host = shell.pages["consultations"].table_host
|
||||
for width, height in ((1024, 640), (1536, 960), (1366, 768)):
|
||||
shell.resize(width, height)
|
||||
settle()
|
||||
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||
assert host.fixed.minimumHeight() == 0
|
||||
assert shell.height() == height
|
||||
shell.close()
|
||||
settle()
|
||||
print("native navigation geometry stable")
|
||||
''', encoding="utf-8")
|
||||
env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "DOCTOR_SMOKE_TEST": "1",
|
||||
"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
|
||||
result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", env=env, timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "native navigation geometry stable" in result.stdout
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, _SlotCard
|
||||
from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS, DiagnosisTabWidget
|
||||
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
|
||||
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
for _ in range(10):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [600, 760, 860])
|
||||
def test_prescription_form_keeps_date_gender_and_controls_readable(
|
||||
application: QApplication, width: int
|
||||
) -> None:
|
||||
host = QWidget()
|
||||
host.resize(width, 640)
|
||||
host.show()
|
||||
editor = PrescriptionEditorDialog(
|
||||
SimpleNamespace(list_medicines=lambda **kwargs: {"lists": [], "count": 0}),
|
||||
{"diagnosis_id": 501, "patient_name": "林晓岚", "prescription_type": "浓缩水丸"},
|
||||
parent=host,
|
||||
)
|
||||
editor.show()
|
||||
settle(application)
|
||||
try:
|
||||
display = editor.date_edit._display
|
||||
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||
for button in (editor.gender_male, editor.gender_female):
|
||||
assert button.width() >= button.sizeHint().width()
|
||||
controls = [editor.patient_name, editor.age, editor.date_edit, editor.prescription_type, editor.times_per_day]
|
||||
assert len({control.height() for control in controls}) == 1
|
||||
assert editor.body_content.width() == editor.body_scroll.viewport().width()
|
||||
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
|
||||
footer_position = editor.footer.mapToGlobal(QPoint())
|
||||
editor.body_scroll.verticalScrollBar().setValue(editor.body_scroll.verticalScrollBar().maximum())
|
||||
settle(application)
|
||||
assert editor.footer.mapToGlobal(QPoint()) == footer_position
|
||||
assert editor.save_button.visibleRegion().boundingRect() == editor.save_button.rect()
|
||||
# Changing prescription type keeps its conditional fields reachable after reflow.
|
||||
editor.prescription_type.setCurrentText("饮片")
|
||||
settle(application)
|
||||
assert not editor._main_decoction_field.isHidden()
|
||||
assert editor._main_bag_field.isHidden()
|
||||
if width == 860:
|
||||
for resized_width in (600, 860):
|
||||
editor.resize(resized_width, 640)
|
||||
settle(application)
|
||||
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||
assert editor._main_decoction_field.width() >= editor.need_decoction.minimumSizeHint().width() + 104
|
||||
finally:
|
||||
editor.close()
|
||||
host.close()
|
||||
|
||||
|
||||
def test_diagnosis_overflow_rail_leaves_selected_tab_underline_visible(application: QApplication) -> None:
|
||||
tabs = DiagnosisTabWidget()
|
||||
tabs.setObjectName("DiagnosisDrawerTabs")
|
||||
tabs.setStyleSheet(DIAGNOSIS_QSS)
|
||||
for title in ("病历", "医生备注", "日常记录", "处方", "业务订单", "视频录制回放", "聊天", "指派医助记录", "挂号记录"):
|
||||
tabs.addTab(QWidget(), title)
|
||||
tabs.resize(614, 480)
|
||||
tabs.show()
|
||||
settle(application)
|
||||
try:
|
||||
selected = tabs.tabBar().tabRect(tabs.currentIndex())
|
||||
selected.moveTopLeft(tabs.tabBar().pos() + selected.topLeft())
|
||||
assert tabs.tab_scrollbar.isVisible()
|
||||
assert not selected.intersects(tabs.tab_scrollbar.geometry())
|
||||
tabs.tab_scrollbar.setValue(tabs.count() - 1)
|
||||
assert tabs.currentIndex() == tabs.count() - 1
|
||||
finally:
|
||||
tabs.close()
|
||||
|
||||
|
||||
def test_appointment_slot_labels_follow_checked_and_disabled_states(application: QApplication) -> None:
|
||||
dialog = QDialog()
|
||||
dialog.setObjectName("AppointmentDrawerOverlay")
|
||||
dialog.setStyleSheet(APPOINTMENT_DRAWER_QSS)
|
||||
layout = QVBoxLayout(dialog)
|
||||
card = _SlotCard("09:00-09:30", "可约")
|
||||
card.setProperty("appointmentSlot", True)
|
||||
card.setProperty("availability", "available")
|
||||
card.setCheckable(True)
|
||||
layout.addWidget(card)
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
for checked, expected in ((True, "#ffffff"), (False, "#1a1c1f")):
|
||||
card.setChecked(checked)
|
||||
settle(application)
|
||||
assert card.time_label.palette().color(QPalette.ColorRole.WindowText).name() == expected
|
||||
card.setChecked(True)
|
||||
card.setEnabled(False)
|
||||
settle(application)
|
||||
assert card.time_label.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText).name() == "#8e8f90"
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_report_columns_keep_headings_at_the_same_text_baseline(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def immediate(function, **kwargs):
|
||||
kwargs["on_success"](function())
|
||||
if kwargs.get("on_finished"):
|
||||
kwargs["on_finished"]()
|
||||
|
||||
monkeypatch.setattr(ai_module, "run_async", immediate)
|
||||
report = {"summary": "复核参考", "possible_symptoms": ["乏力", "纳差", "睡眠不安"], "main_indications": "脾气不足", "efficacy": ["健脾", "益气", "养阴"], "suitable_people": ["需由医师辨证确认"]}
|
||||
repository = SimpleNamespace(list_prescription_template_ai_reports=lambda template_id: {"prescription_id": template_id, "reports": [{"report_id": 1, "model_key": "qwen", "report": report}]})
|
||||
dialog = PrescriptionAiReportDialog(repository, PermissionSet(["*"]))
|
||||
dialog.open_for({"id": 1, "prescription_name": "测试方", "herbs": []})
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
labels = {label.text(): label for label in dialog.findChildren(QLabel) if label.objectName() == "PrescriptionAiSectionTitle"}
|
||||
for left, right in (("可能症状与证候", "主治方向"), ("主要功效", "可能适用人群")):
|
||||
# QLabel vertically centers text: compare the centers, not just widget origins.
|
||||
centers = [labels[text].mapTo(dialog, labels[text].rect().center()).y() for text in (left, right)]
|
||||
assert abs(centers[0] - centers[1]) <= 1
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.dialogs import ai_consult_picker, diagnosis, prescription
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run(function: Any, *, on_success: Any, on_error: Any, on_finished: Any = None) -> None:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
on_error(error)
|
||||
else:
|
||||
on_success(result)
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
for module in (ai_consult_picker, diagnosis, prescription):
|
||||
monkeypatch.setattr(module, "run_async", run)
|
||||
|
||||
|
||||
class ListRepository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.fail_page = 0
|
||||
|
||||
def _list(self, **filters: Any) -> dict[str, Any]:
|
||||
self.calls.append(filters)
|
||||
page, size = filters["page_no"], filters["page_size"]
|
||||
if page == self.fail_page:
|
||||
raise RuntimeError("暂时无法加载")
|
||||
start = (page - 1) * size + 1
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": number,
|
||||
"diagnosis_id": number,
|
||||
"patient_id": 1000 + number,
|
||||
"patient_name": f"患者{number}",
|
||||
"prescription_id": 77,
|
||||
"prescription_name": f"处方{number}",
|
||||
"herbs": [{"name": "白术", "dosage": 10}],
|
||||
"order_no": f"ORDER{number}",
|
||||
}
|
||||
for number in range(start, min(start + size, 46))
|
||||
],
|
||||
"count": 45,
|
||||
}
|
||||
|
||||
list_prescription_templates = _list
|
||||
list_prescription_orders = _list
|
||||
list_ai_patient_options = _list
|
||||
|
||||
|
||||
def scroll_to_bottom(table: Any) -> None:
|
||||
scrollbar = table.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
QTest.qWait(70)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["template", "order", "ai"])
|
||||
def test_scroll_appends_and_retains_selected_business_identity(
|
||||
application: QApplication, immediate_async: None, kind: str
|
||||
) -> None:
|
||||
repository = ListRepository()
|
||||
if kind == "template":
|
||||
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||
controller = dialog.infinite_list
|
||||
elif kind == "order":
|
||||
dialog = prescription.PrescriptionOrderListDialog(repository, prescription_id=77)
|
||||
controller = dialog.infinite_list
|
||||
else:
|
||||
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||
controller = dialog.pager
|
||||
dialog.resize(980, 520)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
page_size = controller.page_size
|
||||
assert dialog.table.rowCount() == page_size
|
||||
assert not any(
|
||||
button.text() in {"上一页", "下一页"} for button in dialog.findChildren(QPushButton)
|
||||
)
|
||||
dialog.table.selectRow(2)
|
||||
scroll_to_bottom(dialog.table)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert dialog.table.rowCount() == page_size * 2
|
||||
assert dialog.table.currentRow() == 2
|
||||
assert all(
|
||||
dialog.table.item(row, column).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||
for row in range(dialog.table.rowCount())
|
||||
for column in range(dialog.table.columnCount())
|
||||
)
|
||||
repository.fail_page = 3
|
||||
scroll_to_bottom(dialog.table)
|
||||
assert controller.retry_button.isVisible()
|
||||
assert dialog.table.rowCount() == page_size * 2
|
||||
repository.fail_page = 0
|
||||
controller.retry_button.click()
|
||||
assert dialog.table.rowCount() == 45
|
||||
assert not controller.has_more
|
||||
assert dialog.table.currentRow() == 2
|
||||
if kind == "template":
|
||||
dialog.accept()
|
||||
assert dialog.selected_template()["id"] == 3
|
||||
elif kind == "ai":
|
||||
dialog.accept()
|
||||
assert dialog.selected_target().diagnosis_id == 3
|
||||
assert dialog.selected_target().patient_id == 1003
|
||||
else:
|
||||
assert dialog.table.item(2, 0).data(Qt.ItemDataRole.UserRole)["id"] == 3
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_template_query_supersedes_pending_load_and_keeps_creator_scope(
|
||||
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
pending: list[tuple[Any, dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
prescription, "run_async", lambda function, **callbacks: pending.append((function, callbacks))
|
||||
)
|
||||
repository = ListRepository()
|
||||
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
dialog.name_edit.setText("白术")
|
||||
dialog.formula_combo.setCurrentIndex(1)
|
||||
dialog.search()
|
||||
assert len(pending) == 2
|
||||
latest_function, latest_callbacks = pending[1]
|
||||
latest_callbacks["on_success"](latest_function())
|
||||
old_function, old_callbacks = pending[0]
|
||||
old_callbacks["on_success"](old_function())
|
||||
old_callbacks["on_error"](RuntimeError("过期错误"))
|
||||
assert dialog.table.rowCount() == 15
|
||||
assert not dialog.banner.isVisible()
|
||||
assert repository.calls[0] == {
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
"prescription_name": "白术",
|
||||
"formula_type": "主方",
|
||||
"prescribing_creator_id": 42,
|
||||
}
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_picker_continues_when_first_page_has_no_valid_diagnosis_targets(
|
||||
application: QApplication, immediate_async: None
|
||||
) -> None:
|
||||
class Repository(ListRepository):
|
||||
def list_ai_patient_options(self, **filters: Any) -> dict[str, Any]:
|
||||
result = self._list(**filters)
|
||||
if filters["page_no"] == 1:
|
||||
for row in result["lists"]:
|
||||
row["id"] = -row["id"]
|
||||
row["diagnosis_id"] = row["id"]
|
||||
return result
|
||||
|
||||
repository = Repository()
|
||||
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
QTest.qWait(100)
|
||||
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||
assert dialog.table.rowCount() == 20
|
||||
assert dialog.table.item(0, 3).text() == "21"
|
||||
assert dialog.table.isVisible()
|
||||
assert not dialog.empty_state.isVisible()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_diagnosis_readonly_orders_scroll_and_switching_patient_resets_scope(
|
||||
application: QApplication, immediate_async: None
|
||||
) -> None:
|
||||
repository = ListRepository()
|
||||
dialog = diagnosis.DiagnosisDialog(
|
||||
repository, permissions=PermissionSet(["tcm.diagnosis/patientOrders"])
|
||||
)
|
||||
dialog.open_for(
|
||||
501, authoritative_detail={"id": 501, "patient_id": 301, "patient_name": "患者一"}
|
||||
)
|
||||
application.processEvents()
|
||||
try:
|
||||
table = dialog._table_registry["orders"][0]
|
||||
dialog.readonly_scroll.ensureWidgetVisible(table)
|
||||
assert table.rowCount() == 10
|
||||
assert dialog.orders_list.parentWidget() is dialog._readonly_sections["orders"]
|
||||
table.selectRow(2)
|
||||
scroll_to_bottom(table)
|
||||
assert table.rowCount() == 20
|
||||
assert table.currentRow() == 2
|
||||
assert table.item(2, 0).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||
assert repository.calls[-1] == {
|
||||
"page_no": 2,
|
||||
"page_size": 10,
|
||||
"patient_id": 301,
|
||||
"context_diagnosis_id": 501,
|
||||
"scene": "diagnosis_edit",
|
||||
}
|
||||
dialog.open_for(
|
||||
502, authoritative_detail={"id": 502, "patient_id": 302, "patient_name": "患者二"}
|
||||
)
|
||||
assert table.rowCount() == 10
|
||||
assert repository.calls[-1]["page_no"] == 1
|
||||
assert repository.calls[-1]["patient_id"] == 302
|
||||
assert repository.calls[-1]["context_diagnosis_id"] == 502
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Search disclosure preserves queries, permissions, focus and usable list space."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.filter_disclosure import FilterDisclosure
|
||||
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
QTest.qWait(35)
|
||||
|
||||
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
value = function()
|
||||
if on_success:
|
||||
on_success(value)
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
|
||||
def test_keyboard_fold_preserves_hidden_permission_controls_and_focus(application):
|
||||
host = QWidget()
|
||||
layout = QVBoxLayout(host)
|
||||
panel = QWidget(host)
|
||||
form = QVBoxLayout(panel)
|
||||
field = QLineEdit("保留查询", panel)
|
||||
forbidden = QPushButton("无权限操作", panel)
|
||||
forbidden.hide()
|
||||
form.addWidget(field)
|
||||
form.addWidget(forbidden)
|
||||
disclosure = FilterDisclosure(host, [panel])
|
||||
layout.addWidget(disclosure.button)
|
||||
layout.addWidget(panel)
|
||||
host.show()
|
||||
settle(application)
|
||||
assert not panel.isVisible()
|
||||
assert disclosure.button.height() == 32
|
||||
disclosure.button.setFocus()
|
||||
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||
settle(application)
|
||||
assert panel.isVisible() and not forbidden.isVisible()
|
||||
field.setFocus()
|
||||
disclosure.set_expanded(False)
|
||||
assert QApplication.focusWidget() is disclosure.button
|
||||
disclosure.set_expanded(True)
|
||||
assert field.text() == "保留查询" and forbidden.isHidden()
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize("size", [(1328, 860), (1158, 690), (816, 620)])
|
||||
def test_compact_default_expands_for_query_and_retains_values(application, monkeypatch, kind, size):
|
||||
module = prescriptions if kind == "issued" else prescription_library
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page_type = module.PrescriptionsPage if kind == "issued" else module.PrescriptionLibraryPage
|
||||
page = page_type(repository, {"*"}, session.user)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
settle(application)
|
||||
disclosure = page.filter_disclosure
|
||||
assert not disclosure.expanded and page.filter_card.isHidden()
|
||||
assert page.header.height() == 44
|
||||
assert page.header.breadcrumb_label.isHidden() and page.header.subtitle_label.isHidden()
|
||||
assert disclosure.button.isVisible()
|
||||
assert disclosure.button.height() == 32
|
||||
assert page.header.rect().contains(disclosure.button.mapTo(page.header, QPoint(0, 0)))
|
||||
compact_height = page.table.height()
|
||||
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||
settle(application)
|
||||
assert disclosure.expanded and page.filter_card.isVisible()
|
||||
assert compact_height - page.table.height() >= 100
|
||||
field = page.sn_filter if kind == "issued" else page.name_filter
|
||||
assert field.isVisible() and page.query_button.isVisible() and page.reset_button.isVisible()
|
||||
field.setText("不存在的查询")
|
||||
# Folding must not issue a request or clear an unfinished query.
|
||||
generation = page._generation
|
||||
disclosure.set_expanded(False)
|
||||
settle(application)
|
||||
assert page._generation == generation
|
||||
assert field.text() == "不存在的查询"
|
||||
disclosure.set_expanded(True)
|
||||
QTest.keyClick(field, Qt.Key.Key_Return)
|
||||
settle(application)
|
||||
assert page._generation > generation
|
||||
assert disclosure.expanded
|
||||
disclosure.set_expanded(False)
|
||||
page.refresh()
|
||||
page.resize(size[0] + 10, size[1])
|
||||
settle(application)
|
||||
assert not disclosure.expanded and not page.filter_card.isVisible()
|
||||
disclosure.set_expanded(True)
|
||||
page.reset_button.click()
|
||||
settle(application)
|
||||
assert field.text() == "" and page.table.rowCount() == 2
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Contract for the shared icon system.
|
||||
|
||||
Before ``ui/icons.py`` existed the product drew its glyphs from ten independent
|
||||
painters that disagreed on stroke weight, design grid and palette. These tests
|
||||
lock in the properties that keep the set reading as one family, so a new glyph
|
||||
cannot quietly reintroduce a one-off weight or a clipped mark.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import QColor, QImage
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui import icons
|
||||
from doctor_workstation.ui.theme import COLORS
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _rendered(kind: str, size: int) -> QImage:
|
||||
return icons.pixmap(kind, "strong", size).toImage()
|
||||
|
||||
|
||||
def _ink_bounds(image: QImage) -> tuple[int, int, int, int, int]:
|
||||
"""Return ``(left, top, right, bottom, count)`` of visibly painted pixels."""
|
||||
|
||||
left, top = image.width(), image.height()
|
||||
right = bottom = -1
|
||||
count = 0
|
||||
for y in range(image.height()):
|
||||
for x in range(image.width()):
|
||||
if image.pixelColor(x, y).alpha() > 24:
|
||||
count += 1
|
||||
left, top = min(left, x), min(top, y)
|
||||
right, bottom = max(right, x), max(bottom, y)
|
||||
return left, top, right, bottom, count
|
||||
|
||||
|
||||
def test_every_glyph_paints_inside_its_box_at_every_shipped_size(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""No glyph may touch the edge of its pixmap.
|
||||
|
||||
Clipping is what the old painters did whenever a call site asked for a size
|
||||
other than the one the geometry was authored against - the shell's ``ai``
|
||||
star lost its companion dot, and the prescription icons were drawn with
|
||||
16 px geometry inside 14 px and 15 px boxes.
|
||||
"""
|
||||
|
||||
for kind in icons.available_kinds():
|
||||
for size in (14, 16, 18, 20, 24):
|
||||
image = _rendered(kind, size)
|
||||
device = image.width() # honours the device pixel ratio
|
||||
left, top, right, bottom, count = _ink_bounds(image)
|
||||
|
||||
assert count > 0, f"{kind}@{size} painted nothing"
|
||||
assert left > 0 and top > 0, f"{kind}@{size} is clipped at the top/left"
|
||||
assert right < device - 1 and bottom < device - 1, (
|
||||
f"{kind}@{size} is clipped at the bottom/right"
|
||||
)
|
||||
|
||||
|
||||
def test_glyphs_fill_a_consistent_share_of_the_optical_box(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Every mark lives in the same safe area, so none reads over- or undersized.
|
||||
|
||||
The set this replaces mixed glyphs that spanned the full 18 px box with a
|
||||
``close`` cross that spanned only 7 px, which is why the title bar controls
|
||||
never looked like siblings.
|
||||
"""
|
||||
|
||||
for kind in icons.available_kinds():
|
||||
image = _rendered(kind, 24)
|
||||
device = image.width()
|
||||
left, top, right, bottom, _count = _ink_bounds(image)
|
||||
extent = max(right - left, bottom - top) / device
|
||||
|
||||
assert 0.5 <= extent <= 0.95, f"{kind} fills {extent:.2f} of its box"
|
||||
|
||||
|
||||
def test_stroke_weight_is_one_formula_across_the_shipped_size_range() -> None:
|
||||
assert icons.stroke_px(24) == pytest.approx(2.0)
|
||||
assert icons.stroke_px(18) == pytest.approx(1.5)
|
||||
assert icons.stroke_px(16) == pytest.approx(4.0 / 3.0)
|
||||
# Clamped so a small icon stays visible and a large one does not turn slab.
|
||||
assert icons.stroke_px(8) == pytest.approx(1.25)
|
||||
assert icons.stroke_px(64) == pytest.approx(2.25)
|
||||
|
||||
|
||||
def test_icons_are_cached_so_list_rows_do_not_repaint_them(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A list page builds one icon per action button per row on every refresh.
|
||||
|
||||
Without the cache that is a fresh ``QPainter`` run per button; the pages in
|
||||
this product ask for the same handful of (kind, colour, size) triples over
|
||||
and over, so the cache turns a per-row cost into a per-process one.
|
||||
"""
|
||||
|
||||
icons.clear_cache()
|
||||
for _ in range(50):
|
||||
icons.icon("eye", "accent", 15)
|
||||
icons.icon("trash", "danger", 15)
|
||||
|
||||
# Two glyphs, each painted once in its requested colour and once disabled.
|
||||
assert icons._cached_pixmap.cache_info().misses == 4
|
||||
# The remaining 98 calls are answered from the icon cache without painting.
|
||||
assert icons._cached_icon.cache_info().hits == 98
|
||||
|
||||
|
||||
def test_colour_roles_resolve_to_the_palette_not_to_per_call_site_hexes() -> None:
|
||||
assert icons.resolve_color("accent") == COLORS["indigo"]
|
||||
assert icons.resolve_color("danger") == COLORS["danger"]
|
||||
assert icons.resolve_color("muted") == COLORS["muted"]
|
||||
# A literal colour still passes through for the few bespoke tints that remain.
|
||||
assert icons.resolve_color("#8268E8") == "#8268E8"
|
||||
|
||||
|
||||
def test_state_icon_carries_its_own_selected_and_disabled_pixmaps(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Navigation rows invert on selection, so the icon has to invert with them."""
|
||||
|
||||
icon = icons.state_icon("patients", size=18, normal="muted", checked="inverse")
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
off = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.Off).toImage()
|
||||
on = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On).toImage()
|
||||
|
||||
assert off != on
|
||||
assert _dominant_ink(on) == QColor("#FFFFFF")
|
||||
|
||||
|
||||
def _dominant_ink(image: QImage) -> QColor:
|
||||
for y in range(image.height()):
|
||||
for x in range(image.width()):
|
||||
colour = image.pixelColor(x, y)
|
||||
if colour.alpha() > 240:
|
||||
colour.setAlpha(255)
|
||||
return colour
|
||||
raise AssertionError("no opaque pixel found")
|
||||
|
||||
|
||||
def test_navigation_glyphs_are_visually_distinct(application: QApplication) -> None:
|
||||
"""Six sidebar entries need six silhouettes.
|
||||
|
||||
The reception detail tabs previously shared three near-identical
|
||||
"document with lines" marks, which made them unreadable at 16 px.
|
||||
"""
|
||||
|
||||
renders = {
|
||||
kind: _rendered(kind, 24).constBits().tobytes()
|
||||
for kind in (
|
||||
"reception",
|
||||
"appointments",
|
||||
"prescription_library",
|
||||
"prescriptions",
|
||||
"patients",
|
||||
"consultations",
|
||||
"report",
|
||||
"meds",
|
||||
"daily",
|
||||
"followup",
|
||||
)
|
||||
}
|
||||
|
||||
assert len(set(renders.values())) == len(renders)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Incremental requests retain business rows and survive query changes/errors."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||
from doctor_workstation.ui.widgets import get_value, page_items
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
class Requests:
|
||||
def __init__(self):
|
||||
self.pending = []
|
||||
|
||||
def __call__(self, function, **callbacks):
|
||||
self.pending.append((function, callbacks))
|
||||
|
||||
def finish(self, index=0, error=None):
|
||||
fn, cb = self.pending.pop(index)
|
||||
if error:
|
||||
cb["on_error"](error)
|
||||
else:
|
||||
cb["on_success"](fn())
|
||||
cb["on_finished"]()
|
||||
|
||||
|
||||
def setup(app, size=2):
|
||||
host = QWidget()
|
||||
layout = QVBoxLayout(host)
|
||||
table = QTableWidget(0, 2)
|
||||
footer = InfiniteList(size)
|
||||
footer.bind(table)
|
||||
layout.addWidget(table)
|
||||
layout.addWidget(footer)
|
||||
host.resize(420, 220)
|
||||
seen = []
|
||||
errors = []
|
||||
jobs = Requests()
|
||||
|
||||
def apply(result):
|
||||
rows = page_items(result)
|
||||
seen.append(result)
|
||||
table.setRowCount(len(rows))
|
||||
for i, row in enumerate(rows):
|
||||
for col in range(2):
|
||||
item = QTableWidgetItem(str(row["id"]))
|
||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||
if col == 1:
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.CheckState.Unchecked)
|
||||
table.setItem(i, col, item)
|
||||
|
||||
return host, table, footer, seen, errors, jobs, apply
|
||||
|
||||
|
||||
def test_append_deduplicates_and_preserves_selection_checks_metadata(app):
|
||||
host, table, f, seen, errors, jobs, apply = setup(app)
|
||||
pages = {
|
||||
1: {"items": [{"id": 1}, {"id": 2}], "total": 4, "extend": {"scope": "mine"}},
|
||||
2: {"items": [{"id": 2}, {"id": 3}], "total": 4},
|
||||
3: {"items": [{"id": 4}], "total": 4},
|
||||
}
|
||||
f.reload(lambda p: pages[p], apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
table.selectRow(1)
|
||||
table.item(1, 1).setCheckState(Qt.CheckState.Checked)
|
||||
f.load_more()
|
||||
f.load_more()
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert [r["id"] for r in f.rows] == [1, 2, 3]
|
||||
assert table.currentRow() == 1 and table.item(1, 1).checkState() == Qt.CheckState.Checked
|
||||
assert get_value(seen[-1], "extend.scope") == "mine"
|
||||
f.load_more()
|
||||
jobs.finish()
|
||||
assert not f.has_more
|
||||
assert table.rowCount() == 4 and not errors
|
||||
host.close()
|
||||
|
||||
|
||||
def test_changed_query_discards_old_success_and_error(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
f.reload(
|
||||
lambda p: {"items": [{"id": 1}], "total": 1},
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="a",
|
||||
)
|
||||
f.reload(
|
||||
lambda p: {"items": [{"id": 2}], "total": 1},
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="b",
|
||||
)
|
||||
jobs.finish(1)
|
||||
jobs.finish(0, error=RuntimeError("stale"))
|
||||
assert f.rows == [{"id": 2}] and not errors and not f.loading
|
||||
f.invalidate()
|
||||
assert not f.has_more
|
||||
host.close()
|
||||
|
||||
|
||||
def test_append_failure_retry_and_empty_end_are_bounded(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
calls = []
|
||||
|
||||
def fetch(p):
|
||||
calls.append(p)
|
||||
return {"items": [{"id": 1}, {"id": 2}] if p == 1 else [], "total": 8}
|
||||
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
f.load_more()
|
||||
jobs.finish(error=RuntimeError("offline"))
|
||||
assert t.rowCount() == 2 and f.page == 1 and not f.retry_button.isHidden()
|
||||
f.load_more()
|
||||
assert not jobs.pending
|
||||
f.retry()
|
||||
jobs.finish()
|
||||
assert f.page == 2 and not f.has_more
|
||||
assert calls == [1, 2] and len(errors) == 1
|
||||
host.close()
|
||||
|
||||
|
||||
def test_refresh_retains_loaded_prefix_until_complete(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||
calls = []
|
||||
|
||||
def fetch(p):
|
||||
calls.append(p)
|
||||
return {"items": [{"id": p * 2 - 1}, {"id": p * 2}], "total": 6}
|
||||
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
f.load_more()
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4 and f.loading
|
||||
jobs.finish()
|
||||
assert t.rowCount() == 4 and f.page == 2
|
||||
assert calls == [1, 2, 1, 2]
|
||||
host.close()
|
||||
|
||||
|
||||
def test_scroll_autofill_then_load_at_bottom(app):
|
||||
host, t, f, seen, errors, jobs, apply = setup(app, size=12)
|
||||
|
||||
def fetch(p):
|
||||
return {"items": [{"id": i} for i in range((p - 1) * 12, p * 12)], "total": 36}
|
||||
|
||||
host.show()
|
||||
app.processEvents()
|
||||
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish()
|
||||
app.processEvents()
|
||||
QTest.qWait(60)
|
||||
assert not jobs.pending
|
||||
t.verticalScrollBar().setValue(t.verticalScrollBar().maximum())
|
||||
QTest.qWait(60)
|
||||
assert len(jobs.pending) == 1
|
||||
old = t.verticalScrollBar().value()
|
||||
jobs.finish()
|
||||
assert t.verticalScrollBar().value() == old and t.rowCount() == 24
|
||||
host.close()
|
||||
|
||||
|
||||
def test_poll_during_slow_request_keeps_work_and_updates_consumer_callbacks(app):
|
||||
host, table, footer, seen, errors, jobs, apply = setup(app)
|
||||
completed = []
|
||||
|
||||
def fetch(page):
|
||||
return {"items": [{"id": page * 2 - 1}, {"id": page * 2}], "total": 6}
|
||||
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
footer.reload(
|
||||
fetch,
|
||||
apply,
|
||||
errors.append,
|
||||
runner=jobs,
|
||||
query_key="a",
|
||||
on_finished=lambda: completed.append("latest"),
|
||||
)
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert completed == ["latest"] and table.rowCount() == 2
|
||||
footer.load_more()
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert table.rowCount() == 4 and footer.page == 2
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
jobs.finish() # Prefix page 2 now pending.
|
||||
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||
assert len(jobs.pending) == 1
|
||||
jobs.finish()
|
||||
assert footer.page == 2 and not footer.loading and table.rowCount() == 4
|
||||
host.close()
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QTimer
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.pages import (
|
||||
appointments,
|
||||
consultations,
|
||||
patients,
|
||||
prescription_library,
|
||||
prescriptions,
|
||||
)
|
||||
from doctor_workstation.ui.widgets import PageHeader
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function: Any, *args: Any, on_success=None, on_error=None,
|
||||
on_finished=None, **kwargs: Any) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
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()
|
||||
|
||||
modules = (appointments, consultations, patients, prescription_library, prescriptions)
|
||||
for module in modules:
|
||||
monkeypatch.setattr(module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(kind: str, width: int = 1040):
|
||||
repo = DemoDoctorRepository()
|
||||
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||
classes = {
|
||||
"appointments": appointments.AppointmentsPage,
|
||||
"patients": patients.PatientsPage,
|
||||
"consultations": consultations.ConsultationsPage,
|
||||
"prescriptions": prescriptions.PrescriptionsPage,
|
||||
"prescription_library": prescription_library.PrescriptionLibraryPage,
|
||||
}
|
||||
page = classes[kind](repo, permissions=session.permissions, current_user=session.user)
|
||||
opened.append(page)
|
||||
page.resize(width, 700)
|
||||
page.show()
|
||||
page.refresh()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
return page
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["appointments", "patients", "consultations", "prescriptions", "prescription_library"])
|
||||
def test_list_header_fits_the_real_bundled_font(page_factory, kind: str) -> None:
|
||||
page = page_factory(kind)
|
||||
header = page.findChild(PageHeader)
|
||||
assert header is not None
|
||||
for label in header.findChildren(QLabel):
|
||||
if label.isVisible() and label.text() and label.property("role"):
|
||||
assert label.height() >= label.fontMetrics().height(), (
|
||||
kind, label.text(), label.height(), label.fontMetrics().height()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["patients", "prescription_library"])
|
||||
@pytest.mark.parametrize("width", [1040, 1200])
|
||||
def test_row_actions_are_fully_reachable_at_each_width(page_factory, kind, width):
|
||||
page = page_factory(kind, width)
|
||||
table = page.patient_workspace.table if kind == "patients" else page.table
|
||||
assert table.rowCount() > 0
|
||||
assert table.horizontalHeader().visualIndex(9) == 9
|
||||
actions = table.cellWidget(0, 9)
|
||||
assert actions is not None
|
||||
assert table.horizontalScrollBar().value() == 0
|
||||
# Both approved layouts put actions after the nine data columns. Preserve
|
||||
# full-size controls and the existing reachability assertion after scrolling.
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
QApplication.processEvents()
|
||||
assert actions.geometry().left() >= 0
|
||||
assert actions.geometry().right() < table.viewport().width()
|
||||
assert actions.height() >= actions.minimumSizeHint().height()
|
||||
for child in actions.findChildren(QPushButton):
|
||||
assert actions.rect().contains(child.mapTo(actions, QPoint()))
|
||||
assert actions.rect().contains(child.mapTo(actions, child.rect().bottomRight()))
|
||||
|
||||
|
||||
def test_appointment_detail_lines_and_cancel_fit_without_overlapping(page_factory) -> None:
|
||||
page = page_factory("appointments")
|
||||
table = page.table
|
||||
assert table.rowCount() > 0
|
||||
host = table.cellWidget(0, 4)
|
||||
assert host.width() >= host.minimumSizeHint().width()
|
||||
assert host.height() >= host.minimumSizeHint().height()
|
||||
for label in host.findChildren(QLabel):
|
||||
assert label.height() >= label.fontMetrics().height()
|
||||
assert host.rect().contains(label.geometry())
|
||||
cancel = next(button for button in host.findChildren(QPushButton) if button.text() == "取消")
|
||||
for label in host.findChildren(QLabel):
|
||||
assert not label.geometry().intersects(cancel.geometry())
|
||||
# Underlying text is still available to sorting while a delegate paints only
|
||||
# the selection surface beneath the actual controls.
|
||||
assert table.item(0, 4).text()
|
||||
assert isinstance(table.itemDelegateForColumn(4), appointments._AppointmentInfoDelegate)
|
||||
assert table.horizontalScrollBar().maximum() == 0
|
||||
search = page.findChild(QPushButton, "AppointmentSearchButton")
|
||||
assert search.height() == page.patient_input.height()
|
||||
assert all(button.height() == search.height() for button in page.date_buttons.values() if button.isVisible())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["patients", "prescriptions", "prescription_library", "consultations"])
|
||||
def test_filter_actions_align_with_their_inputs(page_factory, kind: str) -> None:
|
||||
page = page_factory(kind)
|
||||
if kind == "patients":
|
||||
controls = page.patient_workspace
|
||||
widgets = (controls.keyword_edit, controls.search_button, controls.reset_button)
|
||||
statuses = tuple(controls.status_buttons.values())
|
||||
assert len({widget.height() for widget in statuses}) == 1
|
||||
assert len({widget.mapTo(controls.status_host, QPoint()).y() for widget in statuses}) == 1
|
||||
for widget in statuses:
|
||||
assert controls.status_host.rect().contains(widget.geometry())
|
||||
assert widget.height() >= widget.fontMetrics().height()
|
||||
elif kind == "prescriptions":
|
||||
widgets = (page.sn_filter, page.query_button, page.reset_button, page.doctor_filter.button)
|
||||
elif kind == "prescription_library":
|
||||
widgets = (page.name_filter, page.query_button, page.reset_button)
|
||||
else:
|
||||
widgets = (page.search_button, page.reset_button, page.custom_date_edit)
|
||||
assert len({widget.height() for widget in widgets}) == 1
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Contract for the shared motion system.
|
||||
|
||||
The product previously had no `QPropertyAnimation` at all, so these tests exist
|
||||
to keep the two things that make added motion a liability from creeping back:
|
||||
an animation that outlives or destroys the object it is animating, and a
|
||||
graphics effect left attached after a fade, which would quietly move a whole
|
||||
subtree onto Qt's offscreen composite path for the rest of the session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
# The module disables itself under the offscreen platform so widget grabs in the
|
||||
# other suites capture settled frames; these tests are about the animation, so
|
||||
# they opt back in.
|
||||
os.environ["DOCTOR_MOTION"] = "on"
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from PySide6.QtGui import QWheelEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui import motion
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _stack(application: QApplication) -> tuple[QWidget, QStackedWidget, QLabel]:
|
||||
host = QWidget()
|
||||
host.resize(320, 120)
|
||||
layout = QVBoxLayout(host)
|
||||
stack = QStackedWidget()
|
||||
first, second = QLabel("A"), QLabel("B")
|
||||
stack.addWidget(first)
|
||||
stack.addWidget(second)
|
||||
layout.addWidget(stack)
|
||||
host.show()
|
||||
application.processEvents()
|
||||
return host, stack, second
|
||||
|
||||
|
||||
def test_page_transition_fades_and_settles_upward(application: QApplication) -> None:
|
||||
host, stack, incoming = _stack(application)
|
||||
origin = incoming.pos()
|
||||
|
||||
motion.switch_stack(stack, 1)
|
||||
|
||||
animations = incoming._doctor_motion
|
||||
rise, fade = animations["enter"], animations["fade"]
|
||||
assert rise.duration() == fade.duration() == motion.BASE
|
||||
|
||||
samples = []
|
||||
for at in (0, motion.BASE // 2, motion.BASE - 1):
|
||||
rise.setCurrentTime(at)
|
||||
fade.setCurrentTime(at)
|
||||
samples.append((incoming.pos().y() - origin.y(), incoming.graphicsEffect().opacity()))
|
||||
|
||||
offsets = [offset for offset, _ in samples]
|
||||
opacities = [opacity for _, opacity in samples]
|
||||
assert offsets == sorted(offsets, reverse=True), "the page must settle downward-to-up"
|
||||
assert opacities == sorted(opacities), "opacity must rise monotonically"
|
||||
assert offsets[0] == motion.RISE and opacities[0] == pytest.approx(0.0)
|
||||
host.close()
|
||||
|
||||
|
||||
def test_fade_detaches_its_graphics_effect_when_it_finishes(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A left-behind opacity effect is a permanent frame-rate tax, not a leak."""
|
||||
|
||||
host, stack, incoming = _stack(application)
|
||||
motion.switch_stack(stack, 1)
|
||||
assert incoming.graphicsEffect() is not None
|
||||
|
||||
fade = incoming._doctor_motion["fade"]
|
||||
fade.setCurrentTime(fade.duration())
|
||||
# The detach is deferred by one event-loop turn on purpose, so that the
|
||||
# effect is not destroyed from inside the signal it is emitting.
|
||||
application.processEvents()
|
||||
application.processEvents()
|
||||
|
||||
assert incoming.graphicsEffect() is None
|
||||
host.close()
|
||||
|
||||
|
||||
def test_motion_can_be_turned_off_without_leaving_widgets_mid_state(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("DOCTOR_MOTION", "off")
|
||||
assert motion.reduced_motion()
|
||||
|
||||
host, stack, incoming = _stack(application)
|
||||
origin = incoming.pos()
|
||||
motion.switch_stack(stack, 1)
|
||||
|
||||
assert stack.currentIndex() == 1
|
||||
assert incoming.pos() == origin
|
||||
assert incoming.graphicsEffect() is None
|
||||
host.close()
|
||||
|
||||
|
||||
def _scroll_area(application: QApplication) -> QScrollArea:
|
||||
area = QScrollArea()
|
||||
area.setWidgetResizable(True)
|
||||
area.setWidget(QLabel("\n".join(f"line {index}" for index in range(300))))
|
||||
area.resize(300, 200)
|
||||
area.show()
|
||||
application.processEvents()
|
||||
return area
|
||||
|
||||
|
||||
def _wheel(delta: int) -> QWheelEvent:
|
||||
return QWheelEvent(
|
||||
QPointF(50, 50),
|
||||
QPointF(50, 50),
|
||||
QPoint(0, 0),
|
||||
QPoint(0, delta),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
Qt.ScrollPhase.NoScrollPhase,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_wheel_scrolling_is_eased_rather_than_jumped(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
animation = scroller._animation
|
||||
assert animation.endValue() == motion.SCROLL_STEP
|
||||
|
||||
values = []
|
||||
for at in (0, 60, 120, animation.duration() - 1):
|
||||
animation.setCurrentTime(at)
|
||||
values.append(bar.value())
|
||||
|
||||
assert values[0] == 0
|
||||
assert values == sorted(values)
|
||||
assert values[-1] < motion.SCROLL_STEP, "an eased curve never reaches its end early"
|
||||
animation.stop()
|
||||
area.close()
|
||||
|
||||
|
||||
def test_wheel_at_either_end_is_handed_back_to_the_enclosing_area(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Swallowing the wheel at the extremes is what makes nested panes feel stuck."""
|
||||
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
bar = area.verticalScrollBar()
|
||||
|
||||
bar.setValue(bar.minimum())
|
||||
application.processEvents()
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(120))
|
||||
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
|
||||
scroller._animation.stop()
|
||||
bar.setValue(bar.maximum())
|
||||
application.processEvents()
|
||||
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||
area.close()
|
||||
|
||||
|
||||
def test_zoom_gestures_are_left_alone(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
scroller = area._doctor_smooth_scroll
|
||||
|
||||
ctrl_wheel = QWheelEvent(
|
||||
QPointF(50, 50),
|
||||
QPointF(50, 50),
|
||||
QPoint(0, 0),
|
||||
QPoint(0, -120),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.ControlModifier,
|
||||
Qt.ScrollPhase.NoScrollPhase,
|
||||
False,
|
||||
)
|
||||
|
||||
assert not scroller.eventFilter(area.viewport(), ctrl_wheel)
|
||||
area.close()
|
||||
|
||||
|
||||
def test_install_is_idempotent(application: QApplication) -> None:
|
||||
area = _scroll_area(application)
|
||||
motion.install_smooth_scroll(area)
|
||||
first = area._doctor_smooth_scroll
|
||||
motion.install_smooth_scroll(area)
|
||||
|
||||
assert area._doctor_smooth_scroll is first
|
||||
area.close()
|
||||
|
||||
|
||||
def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""A QScrollArea should not have to opt in page by page."""
|
||||
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
apply_theme(application)
|
||||
area = QScrollArea()
|
||||
area.setWidget(QLabel("content"))
|
||||
area.show()
|
||||
area.ensurePolished()
|
||||
application.sendEvent(area, QEvent(QEvent.Type.Polish))
|
||||
application.processEvents()
|
||||
|
||||
assert getattr(area, "_doctor_smooth_scroll", None) is not None
|
||||
area.close()
|
||||
@@ -0,0 +1,641 @@
|
||||
"""Order contracts at risk when filters, summary and table receive the blue layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientOrdersWorkspace, PatientsPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.rows = [
|
||||
{
|
||||
"id": 901 + index,
|
||||
"order_no": f"PO2026081000{index + 1}",
|
||||
"patient_name": name,
|
||||
"patient_phone_masked": f"186****482{index}",
|
||||
"recipient_phone": f"1860000482{index}",
|
||||
"prescription_id": 801 + index,
|
||||
"diagnosis_id": 501 + index,
|
||||
"amount": 368 + index,
|
||||
"effective_amount": 368 + index,
|
||||
"prescription_audit_status": 1,
|
||||
"payment_slip_audit_status": 1,
|
||||
"fulfillment_status": 2,
|
||||
"assistant_name": "周医助",
|
||||
"doctor_name": "陈医生(演示)",
|
||||
"creator_name": "周医助",
|
||||
# Source-only values must not fill display fields that are absent.
|
||||
"create_time": "2026-09-05 10:15:00",
|
||||
"pay_orders": [{"id": 2001 + index, "pay_amount": 368 + index}],
|
||||
}
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||
]
|
||||
self.total = 47
|
||||
self.summary = {
|
||||
"orders": 47,
|
||||
"amount": 12368.5,
|
||||
"pending": 8,
|
||||
"completed": 9,
|
||||
"rejected": 2,
|
||||
"rejection_rate": 4.3,
|
||||
}
|
||||
|
||||
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(query)
|
||||
return {
|
||||
"lists": deepcopy(self.rows),
|
||||
"count": self.total,
|
||||
"extend": {
|
||||
"scope": {"label": "测试部门订单范围"},
|
||||
"summary": deepcopy(self.summary),
|
||||
},
|
||||
}
|
||||
|
||||
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [{"id": 501, "diagnosis_id": 501, "patient_name": "阿青"}],
|
||||
"count": 1,
|
||||
"extend": {"scope": {"label": "测试患者范围"}},
|
||||
}
|
||||
|
||||
def patient_progress(self, **_query: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"lists": [],
|
||||
"count": 0,
|
||||
"extend": {"scope": {"label": "测试面诊范围"}},
|
||||
}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _click(widget, application: QApplication) -> None:
|
||||
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
def _banner_text(workspace: PatientOrdersWorkspace) -> str:
|
||||
return " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args: Any, **_kwargs: Any):
|
||||
pytest.fail("Order visual tests must use only local fixture data")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(*, permissions=("*",), width=1270, height=680, page=False):
|
||||
repository = _Repository()
|
||||
widget = (
|
||||
PatientsPage(repository, permissions=PermissionSet(list(permissions)))
|
||||
if page
|
||||
else PatientOrdersWorkspace(repository, PermissionSet(list(permissions)))
|
||||
)
|
||||
opened.append(widget)
|
||||
widget.resize(width, height)
|
||||
widget.show()
|
||||
if not page:
|
||||
widget.refresh()
|
||||
_settle(application)
|
||||
return widget, repository
|
||||
|
||||
yield create
|
||||
for widget in opened:
|
||||
for timer in widget.findChildren(QTimer):
|
||||
timer.stop()
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_filter_defaults_and_reset_keep_edited_dates_but_remove_query_limit(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
default_query = {
|
||||
"keyword": "",
|
||||
"prescription_audit_status": None,
|
||||
"payment_slip_audit_status": None,
|
||||
"fulfillment_status": None,
|
||||
"start_date": "",
|
||||
"end_date": "",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
}
|
||||
assert repository.queries[0] == default_query
|
||||
assert all(query == {**default_query, "page_no": index + 1}
|
||||
for index, query in enumerate(repository.queries))
|
||||
assert not workspace.use_dates.isChecked()
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == QDate.currentDate().addDays(-30)
|
||||
assert workspace.end_date.date() == QDate.currentDate()
|
||||
assert all(
|
||||
combo.currentData() is None
|
||||
for combo in (workspace.rx_audit, workspace.pay_audit, workspace.fulfillment)
|
||||
)
|
||||
|
||||
before_filters = len(repository.queries)
|
||||
workspace.keyword_edit.setText(" 林医生 ")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||
assert len(repository.queries) == before_filters # Changing status does not auto-submit.
|
||||
_click(workspace.use_dates, application)
|
||||
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||
start, end = QDate(2026, 7, 3), QDate(2026, 8, 8)
|
||||
workspace.start_date.setDate(start)
|
||||
workspace.end_date.setDate(end)
|
||||
_click(workspace.search_button, application)
|
||||
assert repository.queries[before_filters] == {
|
||||
**default_query,
|
||||
"keyword": "林医生",
|
||||
"prescription_audit_status": 2,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 9,
|
||||
"start_date": "2026-07-03",
|
||||
"end_date": "2026-08-08",
|
||||
}
|
||||
|
||||
workspace.pager.load_more()
|
||||
assert repository.queries[-1]["page_no"] == 2
|
||||
assert repository.queries[-1]["page_size"] == 15
|
||||
workspace.keyword_edit.setText("新检索")
|
||||
QTest.keyClick(workspace.keyword_edit, Qt.Key.Key_Return)
|
||||
_settle(application)
|
||||
assert next(query for query in repository.queries if query["keyword"] == "新检索")["page_no"] == 1
|
||||
workspace.start_date.setDate(end.addDays(1))
|
||||
before = len(repository.queries)
|
||||
_click(workspace.search_button, application)
|
||||
assert len(repository.queries) == before
|
||||
assert workspace.banner.isVisible()
|
||||
assert "开始日期不能晚于结束日期" in _banner_text(workspace)
|
||||
|
||||
_click(workspace.reset_button, application)
|
||||
assert repository.queries[-1] == {**default_query, "page_no": repository.queries[-1]["page_no"]}
|
||||
assert workspace.keyword_edit.text() == ""
|
||||
assert not workspace.use_dates.isChecked()
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == end.addDays(1)
|
||||
assert workspace.end_date.date() == end
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
def test_twelve_columns_keep_source_text_distinct_ids_and_missing_display_fields(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
row = repository.rows[0]
|
||||
row["order_no"] = "PO-非常长的订单号-20260905-ABCDEFGHIJ"
|
||||
row["patient_name"] = "用于检查省略与提示的较长患者姓名"
|
||||
row["doctor_name"] = "用于检查分行显示与完整提示信息的开方医生(演示)"
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
table = workspace.table
|
||||
assert [table.horizontalHeaderItem(column).text() for column in range(table.columnCount())] == [
|
||||
"订单",
|
||||
"患者",
|
||||
"处方 / 诊单",
|
||||
"有效金额",
|
||||
"处方审核",
|
||||
"支付审核",
|
||||
"履约",
|
||||
"支付单",
|
||||
"归属助理",
|
||||
"开方人",
|
||||
"创建人",
|
||||
"创建时间",
|
||||
]
|
||||
index = next(
|
||||
index
|
||||
for index in range(table.rowCount())
|
||||
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == 901
|
||||
)
|
||||
assert table.item(index, 0).text() == f"{row['order_no']} · #901"
|
||||
assert table.item(index, 1).text() == f"{row['patient_name']} · 186****4820"
|
||||
assert table.item(index, 2).text() == "#801 / #501"
|
||||
assert table.item(index, 9).text() == row["doctor_name"]
|
||||
assert table.item(index, 0).toolTip() == table.item(index, 0).text()
|
||||
assert table.item(index, 1).toolTip() == table.item(index, 1).text()
|
||||
assert table.item(index, 9).toolTip() == row["doctor_name"]
|
||||
assert table.item(index, 7).text() == "—"
|
||||
assert table.item(index, 11).text() == "—"
|
||||
for column in range(12):
|
||||
assert not table.isColumnHidden(column)
|
||||
assert table.item(index, column).data(Qt.ItemDataRole.UserRole) == row
|
||||
assert row["recipient_phone"] not in table.item(index, column).text()
|
||||
|
||||
|
||||
def test_sorted_selection_buttons_and_menu_emit_current_order_resource_ids(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory()
|
||||
table = workspace.table
|
||||
emitted = []
|
||||
workspace.diagnosis_requested.connect(lambda row: emitted.append(("diagnosis", row)))
|
||||
workspace.detail_requested.connect(lambda row: emitted.append(("detail", row)))
|
||||
workspace.action_requested.connect(lambda key, row: emitted.append((key, row)))
|
||||
table.sortItems(0, Qt.SortOrder.AscendingOrder)
|
||||
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
_settle(application)
|
||||
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id
|
||||
for index in (0, 2, 1):
|
||||
table.selectRow(index)
|
||||
_settle(application)
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
workspace.diagnosis_button.click()
|
||||
workspace.detail_button.click()
|
||||
next(
|
||||
action for action in workspace.action_menu.actions() if action.text() == "确认发货"
|
||||
).trigger()
|
||||
assert [kind for kind, _row in emitted[-3:]] == ["diagnosis", "detail", "ship"]
|
||||
for _kind, row in emitted[-3:]:
|
||||
assert (row["id"], row["prescription_id"], row["diagnosis_id"]) == (
|
||||
expected["id"],
|
||||
expected["prescription_id"],
|
||||
expected["diagnosis_id"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permissions", "menu", "detail"),
|
||||
[
|
||||
(("*",), ["撤回支付审核", "修改快递单号", "确认发货", "上传药房"], True),
|
||||
(("tcm.prescriptionOrder/detail",), [], True),
|
||||
(("tcm.prescriptionOrder/ship",), ["确认发货"], False),
|
||||
(("tcm.prescriptionOrder.detail", "tcm.prescriptionOrder.ship"), [], False),
|
||||
((), [], False),
|
||||
],
|
||||
)
|
||||
def test_selected_order_menu_requires_canonical_permissions(
|
||||
application: QApplication, workspace_factory, permissions, menu, detail
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(permissions=permissions)
|
||||
assert [action.text() for action in workspace.action_menu.actions()] == menu
|
||||
assert workspace.action_button.isVisible() is bool(menu)
|
||||
assert workspace.detail_button.isVisible() is detail
|
||||
assert workspace.diagnosis_button.isEnabled()
|
||||
emitted = []
|
||||
workspace.detail_requested.connect(lambda row: emitted.append(row["id"]))
|
||||
workspace._request_detail() # The double-click path also checks permission.
|
||||
assert bool(emitted) is detail
|
||||
workspace.permissions = PermissionSet([])
|
||||
workspace._selection_changed()
|
||||
_settle(application)
|
||||
assert workspace.action_menu.actions() == []
|
||||
assert not workspace.action_button.isVisible()
|
||||
assert not workspace.detail_button.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rx", "pay", "fulfillment", "rx_text", "pay_text", "state", "exclusion"),
|
||||
[
|
||||
(0, 1, 4, "待审核", "已通过", "已取消", "已取消不计入"),
|
||||
(1, 2, 9, "已通过", "已驳回", "拒收", "拒收不计入"),
|
||||
(2, 0, 10, "已驳回", "待审核", "退款", "退款不计入"),
|
||||
(1, 1, 6, "已通过", "已通过", "已签收", ""),
|
||||
],
|
||||
)
|
||||
def test_amount_exclusion_reasons_and_independent_status_text_survive_rendering(
|
||||
application: QApplication,
|
||||
workspace_factory,
|
||||
rx,
|
||||
pay,
|
||||
fulfillment,
|
||||
rx_text,
|
||||
pay_text,
|
||||
state,
|
||||
exclusion,
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.rows = [
|
||||
{
|
||||
**repository.rows[0],
|
||||
"prescription_audit_status": rx,
|
||||
"payment_slip_audit_status": pay,
|
||||
"fulfillment_status": fulfillment,
|
||||
"amount_included": not bool(exclusion),
|
||||
"amount_exclusion_text": exclusion,
|
||||
"effective_amount": 12368.5,
|
||||
}
|
||||
]
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert workspace.table.item(0, 3).text() == (exclusion or "¥12,368.50")
|
||||
assert [workspace.table.item(0, column).text() for column in (4, 5, 6)] == [
|
||||
rx_text,
|
||||
pay_text,
|
||||
state,
|
||||
]
|
||||
if fulfillment == 6:
|
||||
refund = next(
|
||||
action for action in workspace.action_menu.actions() if action.text() == "退款"
|
||||
)
|
||||
assert refund.property("danger") is True
|
||||
|
||||
|
||||
def test_summary_uses_response_scope_and_legacy_aliases_without_recomputing_rows(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||
"orders": "47",
|
||||
"amount": "¥12,368.50",
|
||||
"pending": "8",
|
||||
"completed": "9",
|
||||
"rejected": "2",
|
||||
"rejection_rate": "4.3%",
|
||||
}
|
||||
assert workspace.scope_label.text() == "测试部门订单范围"
|
||||
assert workspace.pager.total == 47
|
||||
assert workspace.pager.page_size == 15
|
||||
assert workspace.pager.height() == 24
|
||||
# Preserve the existing ratio compatibility, including its known ambiguity.
|
||||
repository.summary = {
|
||||
"order_count": 20,
|
||||
"effective_amount": 700.25,
|
||||
"pending_audit": 3,
|
||||
"completed": 4,
|
||||
"rejected": 1,
|
||||
"rejection_rate": 0.05,
|
||||
}
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||
"orders": "20",
|
||||
"amount": "¥700.25",
|
||||
"pending": "3",
|
||||
"completed": "4",
|
||||
"rejected": "1",
|
||||
"rejection_rate": "5.0%",
|
||||
}
|
||||
before = len(repository.queries)
|
||||
for metric in workspace.metrics.values():
|
||||
_click(metric, application)
|
||||
assert len(repository.queries) == before # The six metrics are read-only.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", [False, True])
|
||||
def test_loading_freezes_queries_resets_rows_and_ignores_stale_success_and_failure(
|
||||
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
table = workspace.table
|
||||
queued = []
|
||||
monkeypatch.setattr(
|
||||
patients_module,
|
||||
"run_async",
|
||||
lambda function, **callbacks: queued.append((function, callbacks)),
|
||||
)
|
||||
workspace.keyword_edit.setText("旧请求")
|
||||
workspace.refresh(silent=silent)
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.start_date.setDate(QDate(2026, 7, 1))
|
||||
workspace.end_date.setDate(QDate(2026, 8, 1))
|
||||
workspace.refresh(silent=silent)
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert workspace.pager.isVisible()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("尚未提交")
|
||||
workspace.rx_audit.setCurrentIndex(0)
|
||||
workspace.use_dates.setChecked(False)
|
||||
newer = queued[1][0]()
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "最新请求",
|
||||
"prescription_audit_status": 2,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 9,
|
||||
"start_date": "2026-07-01",
|
||||
"end_date": "2026-08-01",
|
||||
"page_no": 1,
|
||||
"page_size": 15,
|
||||
}
|
||||
newer["extend"]["scope"]["label"] = "最新范围"
|
||||
queued[1][1]["on_success"](newer)
|
||||
current_item = table.item(0, 0)
|
||||
stale = queued[0][0]()
|
||||
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||
queued[0][1]["on_success"](stale)
|
||||
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 0) is current_item
|
||||
assert workspace.scope_label.text() == "最新范围"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||
workspace.use_dates.setChecked(True)
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_error"](RuntimeError("订单查询失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 0) is current_item
|
||||
assert workspace.metrics["orders"].text() == "47"
|
||||
assert workspace.banner.isVisible()
|
||||
assert "订单查询失败" in _banner_text(workspace)
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_success"]({"lists": [], "count": 0})
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert "当前范围内暂无订单" in " ".join(
|
||||
label.text() for label in workspace.content_stack.currentWidget().findChildren(QLabel)
|
||||
)
|
||||
assert not workspace.diagnosis_button.isEnabled()
|
||||
assert not workspace.detail_button.isEnabled()
|
||||
assert workspace.action_menu.actions() == []
|
||||
assert workspace.pager.total == 0
|
||||
assert workspace.metrics["orders"].text() == "0"
|
||||
assert workspace.metrics["amount"].text() == "¥0.00"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1270, 680), (1014, 490), (760, 380)])
|
||||
def test_layout_keeps_filters_actions_and_load_status_reachable_at_narrow_viewports(
|
||||
application: QApplication, workspace_factory, width: int, height: int
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(width=width, height=height)
|
||||
table = workspace.table
|
||||
assert workspace.size().width() == width
|
||||
assert workspace.size().height() == height
|
||||
assert table.columnCount() == 12
|
||||
assert table.horizontalHeader().height() == 46
|
||||
assert all(table.rowHeight(index) == 68 for index in range(table.rowCount()))
|
||||
assert table.font().pixelSize() == 14
|
||||
assert workspace.scope_label.font().pixelSize() == 13
|
||||
assert all(metric.font().pixelSize() == 18 for metric in workspace.metrics.values())
|
||||
for widget in (
|
||||
workspace.keyword_edit,
|
||||
workspace.rx_audit,
|
||||
workspace.pay_audit,
|
||||
workspace.fulfillment,
|
||||
workspace.search_button,
|
||||
workspace.reset_button,
|
||||
workspace.use_dates,
|
||||
workspace.start_date,
|
||||
workspace.end_date,
|
||||
):
|
||||
assert widget.isVisibleTo(workspace)
|
||||
assert workspace.filter_card.rect().contains(_rect_in(widget, workspace.filter_card))
|
||||
for metric in workspace.metrics.values():
|
||||
assert workspace.summary_strip.rect().contains(_rect_in(metric, workspace.summary_strip))
|
||||
if width == 1270:
|
||||
assert workspace.filter_card.height() == 132
|
||||
assert workspace.summary_strip.height() == 96
|
||||
if width == 760:
|
||||
assert workspace.filter_card.height() == 184
|
||||
assert (
|
||||
len(
|
||||
{
|
||||
metric.mapTo(workspace.summary_strip, QPoint()).y()
|
||||
for metric in workspace.metrics.values()
|
||||
}
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert table.horizontalScrollBar().maximum() > 0
|
||||
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||
|
||||
for host in (
|
||||
workspace.filter_card,
|
||||
workspace.summary_strip,
|
||||
workspace.action_bar,
|
||||
workspace.pager,
|
||||
):
|
||||
workspace.scroll.ensureWidgetVisible(host, 0, 0)
|
||||
_settle(application)
|
||||
assert (
|
||||
workspace.scroll.viewport().rect().contains(_rect_in(host, workspace.scroll.viewport()))
|
||||
), host.objectName()
|
||||
for button in (workspace.diagnosis_button, workspace.detail_button, workspace.action_button):
|
||||
assert workspace.action_bar.rect().contains(_rect_in(button, workspace.action_bar))
|
||||
assert workspace.pager.height() == 24
|
||||
for widget in (workspace.pager.summary_label,):
|
||||
assert workspace.pager.rect().contains(_rect_in(widget, workspace.pager))
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
table.scrollToBottom()
|
||||
_settle(application)
|
||||
assert table.columnViewportPosition(11) >= 0
|
||||
assert table.columnViewportPosition(11) + table.columnWidth(11) <= table.viewport().width()
|
||||
assert table.rowViewportPosition(2) + table.rowHeight(2) <= table.viewport().height()
|
||||
|
||||
|
||||
def test_tab_switches_keep_patient_styles_scope_deduplication_and_progress_timer(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
page, _repository = workspace_factory(width=1328, height=884, page=True)
|
||||
patient = page.patient_workspace
|
||||
progress = page.progress_workspace
|
||||
patient_style = patient.styleSheet()
|
||||
assert patient.table.objectName() == "PatientTable"
|
||||
assert patient.table.columnCount() == 10
|
||||
assert patient.table.rowHeight(0) == 66
|
||||
assert patient.table.horizontalHeader().height() == 42
|
||||
assert patient.table.font().pixelSize() == 14
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not patient.search_toolbar.isVisible()
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
assert patient.search_toolbar.isVisible()
|
||||
assert not progress.timer.isActive()
|
||||
page.tabs.setCurrentIndex(2)
|
||||
_settle(application)
|
||||
progress_style = progress.styleSheet()
|
||||
progress_table_font = progress.schedule_table.font()
|
||||
progress_metric_font = progress.overview["total"][0].font()
|
||||
assert progress.timer.interval() == 15_000
|
||||
assert progress.timer.isActive()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not progress.scope_label.isVisible()
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
assert progress.scope_label.isVisible()
|
||||
assert progress_table_font.pixelSize() == 14
|
||||
assert progress_metric_font.pixelSize() == 18
|
||||
assert progress.schedule_table.rowCount() == 7
|
||||
assert progress.schedule_table.columnCount() == 7
|
||||
|
||||
page.tabs.setCurrentIndex(1)
|
||||
_settle(application)
|
||||
assert not progress.timer.isActive()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not patient.search_toolbar.isVisible()
|
||||
assert page.order_workspace.scope_label.isVisible()
|
||||
assert page.order_workspace.scope_label.text() == "测试部门订单范围"
|
||||
assert patient.styleSheet() == patient_style
|
||||
assert progress.styleSheet() == progress_style
|
||||
assert progress.schedule_table.font() == progress_table_font
|
||||
assert progress.overview["total"][0].font() == progress_metric_font
|
||||
assert patient.table.rowHeight(0) == 66
|
||||
assert patient.table.horizontalHeader().height() == 42
|
||||
|
||||
page.tabs.setCurrentIndex(0)
|
||||
_settle(application)
|
||||
assert patient.search_toolbar.isVisible()
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert not progress.timer.isActive()
|
||||
page.tabs.setCurrentIndex(2)
|
||||
_settle(application)
|
||||
assert not page.scope_badge.isVisible()
|
||||
assert progress.scope_label.isVisible()
|
||||
assert progress.timer.isActive()
|
||||
page.hide()
|
||||
_settle(application)
|
||||
assert not progress.timer.isActive()
|
||||
page.show()
|
||||
_settle(application)
|
||||
assert progress.timer.isActive()
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Progress data/display contracts and native reachability after the blue redesign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientProgressWorkspace, PatientsPage
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.result = {
|
||||
"lists": [{"id": 101, "diagnosis_id": 501, "patient_id": 301,
|
||||
"patient_name": "林晓岚", "doctor_name": "陈医生(演示)",
|
||||
"appointment_time": "09:00-09:30"}],
|
||||
"count": 37,
|
||||
"extend": {"scope": {"label": "本部门及下级"}, "schedule_mode": "self",
|
||||
"summary": {"waiting": 37, "completed": 2, "missed": 3}},
|
||||
}
|
||||
|
||||
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(deepcopy(query))
|
||||
return deepcopy(self.result)
|
||||
|
||||
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def patient_orders(self, **_query: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _texts(table) -> list[list[str]]:
|
||||
return [[table.item(row, column).text() for column in range(table.columnCount())]
|
||||
for row in range(table.rowCount())]
|
||||
|
||||
|
||||
def _metrics(workspace) -> dict[str, tuple[str, str]]:
|
||||
return {key: (value.text(), hint.text()) for key, (value, hint) in workspace.overview.items()}
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args, **_kwargs):
|
||||
pytest.fail("Progress regression tests must use local fixtures or Demo only")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||
opened = []
|
||||
|
||||
def create(*, page=False, shell=False, permissions=("*",), width=1274, height=696,
|
||||
repository=None, refresh=True):
|
||||
repository = repository if repository is not None else _Repository()
|
||||
permission_set = PermissionSet(list(permissions))
|
||||
if shell:
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
# A real shell with only the tested navigation route, not unrelated workspaces.
|
||||
widget = ShellWindow(repository, {
|
||||
"user": session.user, "permissions": permission_set,
|
||||
"menu": [{"perms": "firstvisit.myPatient/lists"}],
|
||||
}, permissions=permission_set)
|
||||
elif page:
|
||||
widget = PatientsPage(repository, permissions=permission_set)
|
||||
else:
|
||||
widget = PatientProgressWorkspace(repository)
|
||||
opened.append(widget)
|
||||
widget.resize(width, height)
|
||||
widget.show()
|
||||
if shell:
|
||||
widget.navigate("patients")
|
||||
widget.pages["patients"].tabs.setCurrentIndex(2)
|
||||
elif page:
|
||||
widget.tabs.setCurrentIndex(2)
|
||||
elif refresh:
|
||||
widget.refresh()
|
||||
_settle(application)
|
||||
return widget, repository
|
||||
|
||||
yield create
|
||||
for widget in opened:
|
||||
for timer in widget.findChildren(QTimer):
|
||||
timer.stop()
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extend", "total", "expected_mode", "expected"),
|
||||
[
|
||||
({"schedule_mode": "self", "summary": {"waiting": 5, "completed": 2, "missed": 1}},
|
||||
99, "按本人归属", {"total": ("8", "0 位接诊医生"), "waiting": ("5", "本人归属患者"), "completed": ("2", "已过号 1 人")}),
|
||||
({"schedule_mode": "ownership", "summary": {"completed": 3, "missed": 2}},
|
||||
7, "按本人归属", {"total": ("12", "0 位接诊医生"), "waiting": ("7", "本人归属患者"), "completed": ("3", "已过号 2 人")}),
|
||||
({"schedule_mode": "self", "summary": {"waiting": 8, "completed": 9, "missed": 7},
|
||||
"today_overview": {"total_visits": 0, "booked": 0, "completed": 0, "missed": 0, "doctor_count": 4}},
|
||||
99, "按本人归属", {"total": ("0", "4 位接诊医生"), "waiting": ("0", "本人归属患者"), "completed": ("0", "已过号 0 人")}),
|
||||
({"schedule_mode": "roster", "summary": {"waiting": 30, "completed": 40, "missed": 5},
|
||||
"today_overview": {"total_visits": 18, "booked": 12, "completed": 4, "empty_slots": 6, "doctor_count": 3, "missed": 2}},
|
||||
99, "与排班合并", {"total": ("18", "3 位接诊医生"), "waiting": ("12", "有效挂号"), "completed": ("6", "当前空号")}),
|
||||
({"schedule_mode": "roster", "summary": {"waiting": 6, "completed": 3, "missed": 1}},
|
||||
99, "与排班合并", {"total": ("10", "0 位接诊医生"), "waiting": ("6", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||
({}, 4, "与排班合并", {"total": ("4", "0 位接诊医生"), "waiting": ("4", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||
],
|
||||
ids=["self-summary", "ownership-total-fallback", "explicit-zero-wins", "roster-empty-slots", "roster-summary-fallback", "missing-extend"],
|
||||
)
|
||||
def test_overview_preserves_mode_fallbacks_and_fixed_completed_caption(
|
||||
workspace_factory, extend, total, expected_mode, expected
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result.update(count=total, extend=extend)
|
||||
workspace.refresh()
|
||||
assert _metrics(workspace) == expected
|
||||
assert workspace.mode_label.text() == expected_mode
|
||||
assert [label.text() for label in workspace.overview_card.findChildren(QLabel)
|
||||
if label.text() in {"今日面诊总数", "待面诊", "已完成"}] == ["今日面诊总数", "待面诊", "已完成"]
|
||||
assert workspace.queue_count.text() == f"共 {total} 人 · 每 15 秒刷新"
|
||||
assert workspace.queue_table.rowCount() == 1 # Totals are server scope, not visible-row counts.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("week_schedule", [None, [], "invalid"], ids=["missing", "empty", "invalid-type"])
|
||||
def test_schedule_fallback_is_seven_consecutive_days_with_blank_doctors(
|
||||
workspace_factory, monkeypatch, week_schedule
|
||||
) -> None:
|
||||
# Sunday fixes the distinction between rolling seven days and a calendar week.
|
||||
class Sunday:
|
||||
@staticmethod
|
||||
def currentDate():
|
||||
return QDate(2026, 9, 6)
|
||||
|
||||
monkeypatch.setattr(patients_module, "QDate", Sunday)
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result["extend"]["week_schedule"] = week_schedule
|
||||
workspace.refresh()
|
||||
rows = _texts(workspace.schedule_table)
|
||||
assert [row[0] for row in rows] == ["09-06 周日", "09-07 周一", "09-08 周二", "09-09 周三", "09-10 周四", "09-11 周五", "09-12 周六"]
|
||||
assert all(row[1:6] == ["0"] * 5 and row[6] == "" for row in rows)
|
||||
|
||||
|
||||
def test_schedule_zero_priority_missing_fallback_and_doctor_names(workspace_factory) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result["extend"]["week_schedule"] = [
|
||||
{"date_text": "09-05", "weekday": "周六", "doctor_count": 2, "total_appointments": 0,
|
||||
"waiting_appointments": 0, "completed_appointments": 0, "total_slots": 11,
|
||||
"booked_slots": 7, "empty_slots": 4, "missed_appointments": 1, "doctors": []},
|
||||
{"date_text": "09-06", "weekday": "周日", "doctor_count": 2, "total_appointments": None,
|
||||
"waiting_appointments": "", "total_slots": 11, "booked_slots": 7, "empty_slots": 4,
|
||||
"missed_appointments": 0, "doctors": [{"doctor_name": "甲医生"}, {"name": "乙医生"}]},
|
||||
{"date_text": "09-07", "weekday": "周一", "doctors": "invalid"},
|
||||
]
|
||||
workspace.refresh()
|
||||
rows = {row[0]: row for row in _texts(workspace.schedule_table)}
|
||||
assert rows["09-05 周六"] == ["09-05 周六", "2", "0", "0", "0", "1", ""]
|
||||
assert rows["09-06 周日"] == ["09-06 周日", "2", "11", "7", "4", "0", "甲医生、乙医生"]
|
||||
assert rows["09-07 周一"][6] == "—"
|
||||
|
||||
|
||||
def test_demo_row_keeps_missing_queue_fields_and_empty_cells_visually_blank(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
repository = DemoDoctorRepository()
|
||||
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
workspace, _ = workspace_factory(repository=repository)
|
||||
assert _texts(workspace.queue_table) == [["—", "林晓岚", "陈医生(演示)", "09:00", "0 位", "—"]]
|
||||
assert _metrics(workspace) == {"total": ("1", "0 位接诊医生"), "waiting": ("1", "本人归属患者"), "completed": ("0", "已过号 0 人")}
|
||||
assert workspace.scope == "演示医生本人患者"
|
||||
assert workspace.pager.summary_label.text() == "共 1 条 · 已全部加载"
|
||||
assert not workspace.pager.has_more
|
||||
table = workspace.schedule_table
|
||||
table.scrollToItem(table.item(0, 6))
|
||||
_settle(application)
|
||||
rect = table.visualItemRect(table.item(0, 6)).adjusted(3, 3, -3, -3)
|
||||
image = table.viewport().grab(rect).toImage()
|
||||
assert not image.isNull()
|
||||
# Read the actual delegate output, not just the model's DisplayRole: blank is not “—”.
|
||||
assert all(image.pixelColor(x, y).lightness() > 160
|
||||
for x in range(image.width()) for y in range(image.height()))
|
||||
dash_table = workspace.queue_table
|
||||
dash_image = dash_table.viewport().grab(dash_table.visualItemRect(dash_table.item(0, 0))).toImage()
|
||||
assert any(dash_image.pixelColor(x, y).lightness() < 100
|
||||
for x in range(dash_image.width()) for y in range(dash_image.height()))
|
||||
|
||||
|
||||
def test_queue_waiting_statuses_keep_source_counts_and_no_status_desc_fallback(workspace_factory) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
examples = [
|
||||
({"queue_status": "consulting", "queue_status_text": "就诊中", "ahead_count": 12}, "0(进行中)", "就诊中"),
|
||||
({"queue_status": "next", "queue_status_text": "待确认", "ahead_count": 12}, "0(待接诊)", "待确认"),
|
||||
({"queue_status": "waiting", "queue_status_text": "等待中", "ahead_count": "3"}, "3 位 · 约 45 分钟", "等待中"),
|
||||
({"queue_status": "waiting", "ahead_count": 2, "estimated_wait_minutes": 7}, "2 位 · 约 7 分钟", "—"),
|
||||
({"queue_status": "missed", "queue_status_text": "已过号", "ahead_count": 2, "estimated_wait_minutes": -5}, "2 位 · 约 0 分钟", "已过号"),
|
||||
({"queue_status": "completed", "queue_status_text": "已完成", "ahead_count": -8}, "0 位", "已完成"),
|
||||
({"status_desc": "待接诊", "diagnosis_confirmed": 1, "ahead_count": "bad"}, "0 位", "—"),
|
||||
]
|
||||
base = repository.result["lists"][0]
|
||||
repository.result["lists"] = [dict(base, **fields, id=100 + index, queue_no=40 + index)
|
||||
for index, (fields, _, _) in enumerate(examples)]
|
||||
workspace.refresh()
|
||||
table = workspace.queue_table
|
||||
for row_index in range(table.rowCount()):
|
||||
row = table.item(row_index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
fields, expected_wait, expected_status = examples[row["id"] - 100]
|
||||
assert table.item(row_index, 0).text() == str(row["queue_no"])
|
||||
assert table.item(row_index, 4).text() == expected_wait
|
||||
assert table.item(row_index, 5).text() == expected_status
|
||||
assert all(table.item(row_index, col).data(Qt.ItemDataRole.UserRole) == row for col in range(6))
|
||||
assert table.selectionMode() == QAbstractItemView.SelectionMode.SingleSelection
|
||||
assert table.editTriggers() == QAbstractItemView.EditTrigger.NoEditTriggers
|
||||
assert table.isSortingEnabled()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permissions", "expected"),
|
||||
[(('tcm.diagnosis/edit', 'tcm.diagnosis/readonlyDetail'), "edit"),
|
||||
(("tcm.diagnosis/readonlyDetail",), "read"), ((), "denied"),
|
||||
(("tcm.diagnosis.edit", "tcm.diagnosis.readonlyDetail"), "denied")],
|
||||
ids=["edit-preferred", "readonly", "no-permission", "noncanonical-permission"],
|
||||
)
|
||||
def test_sorted_double_click_uses_selected_diagnosis_and_canonical_permissions(
|
||||
application, workspace_factory, monkeypatch, permissions, expected
|
||||
) -> None:
|
||||
page, repository = workspace_factory(page=True, width=1328, height=884, permissions=permissions)
|
||||
workspace = page.progress_workspace
|
||||
repository.result["lists"] = [dict(repository.result["lists"][0], id=101 + index,
|
||||
diagnosis_id=501 + index, patient_id=301 + index,
|
||||
patient_name=name, queue_no=index + 1)
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))]
|
||||
workspace.refresh()
|
||||
calls, toasts = [], []
|
||||
dialog = SimpleNamespace(
|
||||
open_for=lambda resource, **kwargs: calls.append(("edit", resource, kwargs)),
|
||||
open_view_only=lambda resource, **kwargs: calls.append(("read", resource, kwargs)),
|
||||
)
|
||||
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: dialog)
|
||||
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||
table = workspace.queue_table
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
table.selectRow(0)
|
||||
table.scrollToItem(table.item(0, 1))
|
||||
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||
_settle(application)
|
||||
row = table.item(0, 0).data(Qt.ItemDataRole.UserRole)
|
||||
assert row["id"] == 103 # A sorted row must not resolve to the first source row (101).
|
||||
point = table.visualItemRect(table.item(0, 1)).center()
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||
QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||
_settle(application)
|
||||
if expected == "denied":
|
||||
assert calls == [] and toasts == ["当前账号没有诊单查看权限。"]
|
||||
else:
|
||||
assert len(calls) == 1 and calls[0][0:2] == (expected, row["diagnosis_id"])
|
||||
assert calls[0][1] != row["patient_id"] and calls[0][2]["seed"] == row
|
||||
assert not toasts
|
||||
|
||||
|
||||
def test_selected_row_id_fallback_and_invalid_diagnosis_never_use_patient_id(
|
||||
workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
page, repository = workspace_factory(page=True, permissions=("tcm.diagnosis/edit",))
|
||||
workspace = page.progress_workspace
|
||||
calls, toasts = [], []
|
||||
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: SimpleNamespace(
|
||||
open_for=lambda resource, **kwargs: calls.append(resource)))
|
||||
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||
for row, expected in [({"id": 810, "patient_id": 910}, 810),
|
||||
({"id": 811, "diagnosis_id": 0, "patient_id": 911}, None),
|
||||
({"patient_id": 912}, None)]:
|
||||
repository.result["lists"] = [row]
|
||||
workspace.refresh()
|
||||
workspace.queue_table.selectRow(0)
|
||||
before = len(calls)
|
||||
workspace.queue_table.itemDoubleClicked.emit(workspace.queue_table.item(0, 0))
|
||||
assert calls[before:] == ([] if expected is None else [expected])
|
||||
assert toasts == ["患者诊单信息不完整。", "患者诊单信息不完整。"]
|
||||
|
||||
|
||||
def test_query_captures_today_status_and_page_before_worker_runs(
|
||||
application, workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
queued = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
workspace.refresh(silent=True)
|
||||
queued.pop()[1]["on_success"](deepcopy(repository.result))
|
||||
workspace.pager.load_more()
|
||||
workspace.pager.load_more()
|
||||
assert len(queued) == 1 # Repeated bottom events share the in-flight request.
|
||||
assert not workspace.banner.isVisible()
|
||||
# Later GUI changes must not reach the pending worker's immutable query.
|
||||
workspace._page = 9
|
||||
queued[0][0]()
|
||||
assert repository.queries[-1:] == [
|
||||
{"status": 1, "start_date": today, "end_date": today, "page_no": 2, "page_size": 15},
|
||||
]
|
||||
assert "page" not in repository.queries[-1]
|
||||
assert workspace.pager.page_size == 15
|
||||
|
||||
|
||||
def test_stale_success_error_and_latest_error_preserve_the_last_good_data(
|
||||
application, workspace_factory, monkeypatch
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
pending = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda _function, **callbacks: pending.append(callbacks))
|
||||
workspace.refresh()
|
||||
# A date rollover changes the query; repeated same-day polls reuse in-flight work.
|
||||
tomorrow = patients_module.QDate.currentDate().addDays(1)
|
||||
monkeypatch.setattr(patients_module, "QDate", SimpleNamespace(currentDate=lambda: tomorrow))
|
||||
workspace.refresh()
|
||||
newer = deepcopy(repository.result)
|
||||
newer["lists"][0].update(id=202, patient_name="最新结果")
|
||||
newer["count"] = 22
|
||||
newer["extend"].update(scope={"label": "新范围"}, summary={"waiting": 22, "completed": 4, "missed": 2})
|
||||
pending[1]["on_success"](newer)
|
||||
_settle(application)
|
||||
snapshot = (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
pending[0]["on_success"]({"lists": [], "count": 0})
|
||||
pending[0]["on_error"](RuntimeError("obsolete failure"))
|
||||
_settle(application)
|
||||
assert not workspace.banner.isVisible()
|
||||
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
workspace.refresh(silent=True)
|
||||
assert not workspace.banner.isVisible()
|
||||
pending[2]["on_error"](RuntimeError("连接失败,请稍后刷新重试。"))
|
||||
_settle(application)
|
||||
assert workspace.banner.isVisible() and workspace.banner.property("kind") == "danger"
|
||||
assert "连接失败" in workspace.banner.label.text()
|
||||
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||
|
||||
|
||||
def test_empty_list_keeps_total_week_and_compact_status_visible(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
repository.result.update(lists=[], count=37)
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
assert workspace.queue_stack.currentIndex() == 1
|
||||
assert not workspace.queue_table.isVisible() and workspace.pager.isVisible()
|
||||
assert workspace.pager.height() == 24
|
||||
assert workspace.schedule_table.rowCount() == 7
|
||||
assert workspace.queue_count.text() == "共 37 人 · 每 15 秒刷新"
|
||||
labels = [label.text() for label in workspace.queue_stack.currentWidget().findChildren(QLabel)]
|
||||
assert "今日暂无候诊患者" in labels
|
||||
assert "当前权限范围内没有有效挂号。" in labels
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 768)])
|
||||
def test_actual_shell_keeps_all_columns_rows_and_load_status_reachable(
|
||||
application, workspace_factory, width, height
|
||||
) -> None:
|
||||
shell, _ = workspace_factory(shell=True, width=width, height=height)
|
||||
page = shell.pages["patients"]
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
workspace = page.progress_workspace
|
||||
assert (shell.width(), shell.height()) == (width, height)
|
||||
assert not page.scope_badge.isVisible() and workspace.scope_label.isVisible()
|
||||
schedule, queue = workspace.schedule_table, workspace.queue_table
|
||||
assert (schedule.rowCount(), schedule.columnCount(), queue.columnCount()) == (7, 7, 6)
|
||||
if width == 1536:
|
||||
assert schedule.verticalScrollBar().value() == 0
|
||||
assert schedule.rowViewportPosition(6) + schedule.rowHeight(6) <= schedule.viewport().height()
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(schedule, workspace.scroll.viewport()))
|
||||
for table in (schedule, queue):
|
||||
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||
_settle(application)
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(table, workspace.scroll.viewport()))
|
||||
for column in range(table.columnCount()):
|
||||
item = table.item(table.rowCount() - 1, column)
|
||||
table.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
_settle(application)
|
||||
assert not table.isColumnHidden(column)
|
||||
assert table.viewport().rect().contains(table.visualItemRect(item)), (width, column)
|
||||
workspace.scroll.ensureWidgetVisible(workspace.pager, 0, 0)
|
||||
_settle(application)
|
||||
assert workspace.scroll.viewport().rect().contains(_rect_in(workspace.pager, workspace.scroll.viewport()))
|
||||
assert workspace.pager.height() == 24
|
||||
for child in (workspace.pager.summary_label,):
|
||||
assert workspace.pager.rect().contains(_rect_in(child, workspace.pager))
|
||||
if width == 1024:
|
||||
assert schedule.horizontalScrollBar().maximum() > 0
|
||||
assert queue.horizontalScrollBar().maximum() > 0
|
||||
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||
|
||||
|
||||
def test_splitter_mouse_drag_reallocates_space_but_cannot_collapse_panels(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
workspace, _ = workspace_factory()
|
||||
splitter = workspace.splitter
|
||||
before = splitter.sizes()
|
||||
handle = splitter.handle(1)
|
||||
point = handle.rect().center()
|
||||
QTest.mousePress(handle, Qt.MouseButton.LeftButton, pos=point)
|
||||
QTest.mouseMove(handle, point + QPoint(0, -60))
|
||||
QTest.mouseRelease(handle, Qt.MouseButton.LeftButton, pos=point + QPoint(0, -60))
|
||||
_settle(application)
|
||||
assert splitter.sizes()[0] < before[0]
|
||||
assert not splitter.childrenCollapsible()
|
||||
for sizes in ([0, 1000], [1000, 0]):
|
||||
splitter.setSizes(sizes)
|
||||
_settle(application)
|
||||
assert splitter.sizes()[0] >= workspace.schedule_card.minimumHeight() > 0
|
||||
assert splitter.sizes()[1] >= workspace.queue_card.minimumHeight() > 0
|
||||
|
||||
|
||||
def test_typography_and_styles_are_local_to_the_progress_workspace(
|
||||
application, workspace_factory
|
||||
) -> None:
|
||||
app_style = application.styleSheet()
|
||||
unrelated = QLabel("外部标签")
|
||||
unrelated.ensurePolished()
|
||||
original_font = unrelated.font()
|
||||
workspace, _ = workspace_factory()
|
||||
assert workspace.schedule_table.font().pixelSize() == 14
|
||||
assert workspace.queue_table.font().pixelSize() == 14
|
||||
assert workspace.scope_label.font().pixelSize() == 13
|
||||
assert workspace.mode_label.font().pixelSize() == 13
|
||||
assert workspace.queue_count.font().pixelSize() == 13
|
||||
for value, hint in workspace.overview.values():
|
||||
assert value.font().pixelSize() == 18 and hint.font().pixelSize() == 13
|
||||
for label in workspace.findChildren(QLabel):
|
||||
if label.property("role") == "sectionTitle":
|
||||
assert label.font().pixelSize() == 14
|
||||
assert application.styleSheet() == app_style
|
||||
assert "ProgressWorkspace" not in app_style
|
||||
assert unrelated.font() == original_font and not unrelated.styleSheet()
|
||||
unrelated.deleteLater()
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Patient workspaces keep resource identity while appending server pages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import (
|
||||
PatientListWorkspace,
|
||||
PatientOrdersWorkspace,
|
||||
PatientProgressWorkspace,
|
||||
PatientsPage,
|
||||
)
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.failed_pages: set[int] = set()
|
||||
self.version = 0
|
||||
|
||||
def _list(self, **query: Any) -> dict[str, Any]:
|
||||
self.calls.append(dict(query))
|
||||
page = query["page_no"]
|
||||
if page in self.failed_pages:
|
||||
raise RuntimeError("连接失败,请重试。")
|
||||
start = (page - 1) * query["page_size"]
|
||||
rows = [
|
||||
{
|
||||
"id": index + 100,
|
||||
"diagnosis_id": index + 1000,
|
||||
"patient_id": index + 2000,
|
||||
"appointment_id": index + 3000,
|
||||
"order_id": index + 4000,
|
||||
"patient_name": f"患者 {index:02d} · {query.get('keyword', '')}{self.version}",
|
||||
"appointment_status": 1,
|
||||
"prescription_audit_status": 0,
|
||||
"payment_slip_audit_status": 0,
|
||||
"fulfillment_status": 2,
|
||||
"queue_no": index + 1,
|
||||
"queue_status": "waiting",
|
||||
"queue_status_text": "等待中",
|
||||
}
|
||||
for index in range(start, min(start + query["page_size"], 37))
|
||||
]
|
||||
return {
|
||||
"lists": rows,
|
||||
"count": 37,
|
||||
"extend": {
|
||||
"scope": {"label": "本人患者"},
|
||||
"summary": {"orders": 37, "amount": 3700, "today": 37, "waiting": 37},
|
||||
"schedule_mode": "ownership",
|
||||
},
|
||||
}
|
||||
|
||||
list_patients = _list
|
||||
patient_orders = _list
|
||||
patient_progress = _list
|
||||
|
||||
|
||||
def run_immediately(function: Any, **callbacks: Any) -> object:
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
callbacks["on_error"](error)
|
||||
else:
|
||||
callbacks["on_success"](result)
|
||||
finally:
|
||||
if callbacks.get("on_finished"):
|
||||
callbacks["on_finished"]()
|
||||
return object()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(params=["patients", "orders", "progress"])
|
||||
def workspace(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(patients_module, "run_async", run_immediately)
|
||||
repository = Repository()
|
||||
permissions = PermissionSet(["*"])
|
||||
constructors = {
|
||||
"patients": lambda: PatientListWorkspace(repository, permissions),
|
||||
"orders": lambda: PatientOrdersWorkspace(repository, permissions),
|
||||
"progress": lambda: PatientProgressWorkspace(repository),
|
||||
}
|
||||
widget = constructors[request.param]()
|
||||
widget.resize(1280, 900)
|
||||
widget.show()
|
||||
application.processEvents()
|
||||
yield request.param, widget, repository
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def table_for(workspace: Any) -> Any:
|
||||
return getattr(workspace, "table", None) or workspace.queue_table
|
||||
|
||||
|
||||
def settle(application: QApplication) -> None:
|
||||
application.processEvents()
|
||||
QTest.qWait(60)
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def scroll_down(workspace: Any, application: QApplication) -> None:
|
||||
table = table_for(workspace)
|
||||
table.verticalScrollBar().setValue(table.verticalScrollBar().maximum())
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_scrolling_appends_without_duplicates_or_selection_loss(workspace, application):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
table = table_for(widget)
|
||||
assert table.rowCount() == 15
|
||||
assert widget.pager.height() == 24
|
||||
assert not hasattr(widget.pager, "page_changed")
|
||||
assert not any(button.text().isdigit() for button in widget.pager.findChildren(QPushButton))
|
||||
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||
table.selectRow(5)
|
||||
chosen = table.current_data()["id"]
|
||||
if kind == "patients":
|
||||
table.cellWidget(5, 0).findChild(QCheckBox).setChecked(True)
|
||||
scroll_down(widget, application)
|
||||
assert table.rowCount() == 30
|
||||
assert table.current_data()["id"] == chosen
|
||||
if kind == "patients":
|
||||
selected_row = next(index for index in range(table.rowCount())
|
||||
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == chosen)
|
||||
assert table.cellWidget(selected_row, 0).findChild(QCheckBox).isChecked()
|
||||
scroll_down(widget, application)
|
||||
assert table.rowCount() == 37
|
||||
assert len({table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
for index in range(table.rowCount())}) == 37
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 3]
|
||||
assert all(query["page_size"] == 15 and "page" not in query for query in repository.calls)
|
||||
assert widget.pager.page == widget._page == 3
|
||||
assert widget.pager.total == 37
|
||||
assert not widget.pager.has_more
|
||||
assert widget.scope == "本人患者"
|
||||
scroll_down(widget, application)
|
||||
assert len(repository.calls) == 3
|
||||
|
||||
|
||||
def test_refresh_retains_loaded_prefix_scroll_and_distinct_action_ids(workspace, application):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
scroll_down(widget, application)
|
||||
table = table_for(widget)
|
||||
table.selectRow(20)
|
||||
chosen = dict(table.current_data())
|
||||
scroll = table.verticalScrollBar().value()
|
||||
repository.version = 1
|
||||
widget.refresh(silent=True)
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 1, 2]
|
||||
assert table.rowCount() == 30
|
||||
assert table.current_data()["id"] == chosen["id"]
|
||||
assert table.current_data()["patient_name"].endswith("1")
|
||||
assert table.verticalScrollBar().value() == scroll
|
||||
selected = []
|
||||
if kind == "patients":
|
||||
widget.diagnosis_requested.connect(lambda row, _edit: selected.append(row))
|
||||
widget._open_selected_diagnosis()
|
||||
elif kind == "orders":
|
||||
widget.detail_requested.connect(selected.append)
|
||||
widget._request_detail()
|
||||
else:
|
||||
widget.diagnosis_requested.connect(selected.append)
|
||||
widget._open_selected()
|
||||
assert selected[0]["id"] == chosen["id"]
|
||||
assert selected[0]["diagnosis_id"] == chosen["diagnosis_id"]
|
||||
assert selected[0]["diagnosis_id"] != selected[0]["patient_id"]
|
||||
|
||||
|
||||
def test_failed_append_keeps_rows_and_retry_continues_same_page(workspace, application):
|
||||
_kind, widget, repository = workspace
|
||||
repository.failed_pages.add(2)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
scroll_down(widget, application)
|
||||
table = table_for(widget)
|
||||
assert table.rowCount() == 15
|
||||
assert widget.pager.page == 1
|
||||
assert widget.pager.retry_button.isVisible()
|
||||
settle(application)
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2]
|
||||
repository.failed_pages.clear()
|
||||
widget.pager.retry_button.click()
|
||||
assert table.rowCount() == 30
|
||||
assert [query["page_no"] for query in repository.calls] == [1, 2, 2]
|
||||
assert not widget.pager.retry_button.isVisible()
|
||||
|
||||
|
||||
def test_refresh_preserves_horizontal_scroll_at_narrow_width(workspace, application):
|
||||
_kind, widget, _repository = workspace
|
||||
widget.resize(760, 600)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
table = table_for(widget)
|
||||
horizontal = table.horizontalScrollBar()
|
||||
assert horizontal.maximum() > 0
|
||||
horizontal.setValue(horizontal.maximum())
|
||||
offset = horizontal.value()
|
||||
widget.refresh(silent=True)
|
||||
assert horizontal.value() == offset
|
||||
|
||||
|
||||
def test_first_page_failure_keeps_compact_retry_accessible(workspace, application):
|
||||
_kind, widget, repository = workspace
|
||||
repository.failed_pages.add(1)
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
widget.scroll.ensureWidgetVisible(widget.pager)
|
||||
settle(application)
|
||||
assert table_for(widget).rowCount() == 0
|
||||
assert widget.pager.isVisible()
|
||||
assert widget.pager.retry_button.isVisible()
|
||||
assert widget.pager.retry_button.height() <= widget.pager.height()
|
||||
repository.failed_pages.clear()
|
||||
widget.pager.retry_button.click()
|
||||
assert table_for(widget).rowCount() == 15
|
||||
|
||||
|
||||
def test_filter_change_restarts_and_ignores_old_append(workspace, application, monkeypatch):
|
||||
kind, widget, repository = workspace
|
||||
widget.refresh()
|
||||
settle(application)
|
||||
pending = []
|
||||
monkeypatch.setattr(patients_module, "run_async",
|
||||
lambda function, **callbacks: pending.append((function, callbacks)))
|
||||
# Capture the new runner for the next scroll request without changing query.
|
||||
widget.refresh(silent=True)
|
||||
pending.pop()[1]["on_success"](repository._list(page_no=1, page_size=15))
|
||||
scroll_down(widget, application)
|
||||
assert len(pending) == 1
|
||||
if kind == "progress":
|
||||
class Tomorrow:
|
||||
@staticmethod
|
||||
def currentDate():
|
||||
return QDate.currentDate().addDays(1)
|
||||
|
||||
monkeypatch.setattr(patients_module, "QDate", Tomorrow)
|
||||
else:
|
||||
widget.keyword_edit.setText("新条件")
|
||||
widget.refresh()
|
||||
assert len(pending) == 2
|
||||
stale_function, stale_callbacks = pending[0]
|
||||
current_function, current_callbacks = pending[1]
|
||||
current_callbacks["on_success"](current_function())
|
||||
stale_callbacks["on_success"](stale_function())
|
||||
stale_callbacks["on_error"](RuntimeError("旧请求错误"))
|
||||
assert table_for(widget).rowCount() == 15
|
||||
assert widget.pager.page == 1
|
||||
assert not widget.banner.isVisible()
|
||||
latest = repository.calls[-2]
|
||||
assert latest["page_no"] == 1
|
||||
if kind == "progress":
|
||||
assert latest["start_date"] == QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||
else:
|
||||
assert latest["keyword"] == "新条件"
|
||||
|
||||
|
||||
def test_patient_route_uses_eight_pixel_bottom_margin(application, monkeypatch):
|
||||
monkeypatch.setattr(patients_module, "run_async", run_immediately)
|
||||
page = PatientsPage(Repository(), PermissionSet(["*"]))
|
||||
assert page.layout().contentsMargins().bottom() == 8
|
||||
assert page.order_workspace.action_bar.height() == 54
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.processEvents()
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint
|
||||
from PySide6.QtCore import QDate, QPoint, QRect
|
||||
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
@@ -175,6 +175,8 @@ def test_patient_refresh_generation_ignores_late_results(
|
||||
monkeypatch.setattr(patients_module, "run_async", queue_async)
|
||||
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
|
||||
workspace.refresh()
|
||||
# Identical in-flight queries coalesce; a changed query starts a new generation.
|
||||
workspace.keyword_edit.setText("新结果")
|
||||
workspace.refresh()
|
||||
newer = {
|
||||
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
|
||||
@@ -621,62 +623,72 @@ def test_patient_list_reference_geometry_and_row_actions(
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
||||
# 1366x768 shell minus its 170 px patient rail, 26 px outer gutter,
|
||||
# and 62 px top bar leaves a 1170x680 page viewport.
|
||||
page.resize(1170, 680)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
# Patient pages use the approved 208 px rail and 76 px top bar.
|
||||
page.resize(1536 - 208, 960 - 76)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.patient_workspace.refresh()
|
||||
application.processEvents()
|
||||
|
||||
workspace = page.patient_workspace
|
||||
assert page.header.height() == 62
|
||||
assert workspace.filter_card.height() <= 92
|
||||
assert all(
|
||||
button.minimumHeight() == 44 and button.maximumHeight() == 44
|
||||
for button in workspace.summary_buttons.values()
|
||||
)
|
||||
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
|
||||
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
|
||||
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
|
||||
assert workspace.date_host.objectName() == "PatientDateRangeHost"
|
||||
assert (
|
||||
workspace.keyword_edit.maximumWidth(),
|
||||
workspace.status_host.maximumWidth(),
|
||||
workspace.quick_host.maximumWidth(),
|
||||
workspace.date_host.maximumWidth(),
|
||||
) == (620, 440, 620, 420)
|
||||
assert workspace.search_button.objectName() == "PatientSearchButton"
|
||||
assert workspace.reset_button.objectName() == "PatientResetButton"
|
||||
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
|
||||
for width in (1170, 1290, 1514):
|
||||
page.resize(width, 680)
|
||||
application.processEvents()
|
||||
for widget in (
|
||||
workspace.keyword_edit,
|
||||
workspace.status_host,
|
||||
workspace.search_button,
|
||||
workspace.reset_button,
|
||||
workspace.quick_host,
|
||||
workspace.date_host,
|
||||
workspace.custom_date_button,
|
||||
assert workspace.table.rowCount() == 4
|
||||
assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
|
||||
assert workspace.table.horizontalScrollBar().maximum() == 0
|
||||
assert workspace.pager.summary_label.text() == "共 4 条 · 已全部加载"
|
||||
assert workspace.pager.height() == 24
|
||||
assert workspace.pager.page_size == 15
|
||||
assert workspace.pager.page == 1
|
||||
assert not workspace.pager.has_more
|
||||
for width, height in ((1536, 960), (1366, 768), (1024, 640)):
|
||||
page.resize(width - 208, height - 76)
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
assert page.header.height() >= page.header.minimumSizeHint().height()
|
||||
for host, widgets in (
|
||||
(workspace.search_toolbar, (workspace.keyword_edit, workspace.search_button, workspace.reset_button)),
|
||||
(workspace.filter_card, (workspace.status_host, workspace.quick_host, workspace.date_host, workspace.custom_date_button)),
|
||||
(workspace.status_host, tuple(workspace.status_buttons.values())),
|
||||
(workspace.quick_host, tuple(workspace.quick_buttons.values())),
|
||||
(workspace.date_host, (workspace.start_date, workspace.end_date)),
|
||||
(workspace.summary_strip, tuple(workspace.summary_buttons.values())),
|
||||
):
|
||||
top_left = widget.mapTo(workspace.filter_card, QPoint(0, 0))
|
||||
assert top_left.x() >= 0
|
||||
assert top_left.x() + widget.width() <= workspace.filter_card.width()
|
||||
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values())
|
||||
for widget in widgets:
|
||||
assert widget.isVisibleTo(page)
|
||||
assert host.rect().contains(QRect(widget.mapTo(host, QPoint()), widget.size())), widget.objectName()
|
||||
for host in (workspace.search_toolbar, workspace.filter_card, workspace.summary_strip, workspace.pager):
|
||||
if workspace.content.isAncestorOf(host):
|
||||
workspace.scroll.ensureWidgetVisible(host, 0, 0)
|
||||
application.processEvents()
|
||||
assert workspace.scroll.viewport().rect().contains(QRect(host.mapTo(workspace.scroll.viewport(), QPoint()), host.size())), host.objectName()
|
||||
else:
|
||||
assert page.rect().contains(QRect(host.mapTo(page, QPoint()), host.size())), host.objectName()
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
workspace.table.scrollToBottom()
|
||||
application.processEvents()
|
||||
assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
|
||||
assert page.tabs.minimumHeight() == 0
|
||||
assert workspace.content_stack.minimumHeight() == 0
|
||||
assert workspace.table.minimumHeight() == 0
|
||||
assert workspace.bottom_actions.isHidden()
|
||||
assert workspace.table.viewport().height() // 36 >= 6
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
assert workspace.table.objectName() == "PatientTable"
|
||||
assert workspace.table.columnCount() == 10
|
||||
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
|
||||
if workspace.table.rowCount():
|
||||
assert workspace.table.rowHeight(0) == 36
|
||||
assert workspace.table.cellWidget(0, 0) is not None
|
||||
assert workspace.table.cellWidget(0, 9) is not None
|
||||
assert workspace.table.horizontalHeader().visualIndex(9) == 9
|
||||
assert all(not workspace.table.isColumnHidden(column) for column in range(10))
|
||||
actions = workspace.table.cellWidget(0, 9)
|
||||
assert workspace.table.rowHeight(0) >= max(40, actions.minimumSizeHint().height() + 1)
|
||||
for button in [*actions.buttons, actions.more_button]:
|
||||
if button is not None:
|
||||
assert actions.rect().contains(button.geometry())
|
||||
assert workspace.table.cellWidget(0, 0) is not None
|
||||
assert workspace.table.cellWidget(0, 9) is not None
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Interaction contracts at risk when the patient list moves to the blue layout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import patients as patients_module
|
||||
from doctor_workstation.ui.pages.patients import PatientListWorkspace
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class _Repository:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[dict[str, Any]] = []
|
||||
self.rows = [
|
||||
{
|
||||
"id": 601 + index,
|
||||
"diagnosis_id": 601 + index,
|
||||
"source_patient_id": 301 + index,
|
||||
"appointment_id": 401 + index if index != 2 else 0,
|
||||
"patient_name": name,
|
||||
"gender": 2,
|
||||
"age": 36,
|
||||
"assistant_name": "测试医助",
|
||||
"assistant_id": 81 if index != 2 else 0,
|
||||
"appointment_doctor_name": "测试医生" if index != 2 else "",
|
||||
"appointment_status": (1, 4, 0)[index],
|
||||
"appointment_status_text": "未预约" if index == 2 else "待接诊",
|
||||
"appointment_time_text": "2026-09-05 09:00" if index != 2 else "—",
|
||||
"has_id_card": index != 1,
|
||||
"revisit_count": index,
|
||||
"confirmation_text": "待确认",
|
||||
"diagnosis_date_text": "初诊",
|
||||
"phone_masked": f"138****120{index}",
|
||||
}
|
||||
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||
]
|
||||
|
||||
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||
self.queries.append(query)
|
||||
return {
|
||||
"lists": deepcopy(self.rows),
|
||||
"count": len(self.rows),
|
||||
"extend": {
|
||||
"scope": {"label": "测试部门可见患者"},
|
||||
"summary": {"today": 2, "tomorrow": 1, "day_after": 0},
|
||||
"dates": {"today": QDate.currentDate().toString("yyyy-MM-dd")},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
def reject_network(*_args: Any, **_kwargs: Any):
|
||||
pytest.fail("Patient visual tests must use only local fixture data")
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||
opened = []
|
||||
|
||||
def create(*, permissions=("*",), width=1270, height=700):
|
||||
repository = _Repository()
|
||||
workspace = PatientListWorkspace(repository, PermissionSet(list(permissions)))
|
||||
opened.append(workspace)
|
||||
workspace.resize(width, height)
|
||||
workspace.show()
|
||||
workspace.refresh()
|
||||
_settle(application)
|
||||
return workspace, repository
|
||||
|
||||
yield create
|
||||
for workspace in opened:
|
||||
for timer in workspace.findChildren(QTimer):
|
||||
timer.stop()
|
||||
workspace.close()
|
||||
workspace.deleteLater()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _click(widget, application: QApplication) -> None:
|
||||
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||
_settle(application)
|
||||
|
||||
|
||||
def _date_selection(workspace: PatientListWorkspace) -> list[str]:
|
||||
return [key for key, button in workspace.quick_buttons.items() if button.isChecked()] + (
|
||||
["custom"] if workspace.custom_date_button.isChecked() else []
|
||||
)
|
||||
|
||||
|
||||
def test_filters_preserve_independent_dimensions_and_reset_to_unlimited_dates(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
assert repository.queries == [{
|
||||
"keyword": "", "status": "", "start_date": "", "end_date": "",
|
||||
"page_no": 1, "page_size": 15,
|
||||
}]
|
||||
assert _date_selection(workspace) == ["all"]
|
||||
assert not workspace.start_date.isEnabled()
|
||||
assert not workspace.end_date.isEnabled()
|
||||
assert workspace.start_date.date() == workspace.end_date.date() == QDate.currentDate()
|
||||
assert workspace.scope_label.text() == "测试部门可见患者"
|
||||
assert workspace.summary_buttons["today"].text() == f"今日预约 · {QDate.currentDate().toString('yyyy-MM-dd')}\n2 人"
|
||||
assert workspace.summary_buttons["tomorrow"].text() == "明日预约\n1 人"
|
||||
assert workspace.summary_buttons["day_after"].text() == "后天预约\n0 人"
|
||||
|
||||
workspace.keyword_edit.setText(" 林医生 ")
|
||||
workspace.pager.load_more()
|
||||
_click(workspace.status_buttons["pending_interview"], application)
|
||||
assert repository.queries[-1]["page_no"] == 1
|
||||
for mode, offset in (("today", 0), ("tomorrow", 1), ("day_after", 2)):
|
||||
_click(workspace.summary_buttons[mode], application)
|
||||
expected = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "林医生", "status": "pending_interview", "start_date": expected,
|
||||
"end_date": expected, "page_no": 1, "page_size": 15,
|
||||
}
|
||||
assert _date_selection(workspace) == [mode]
|
||||
assert workspace.status_buttons["pending_interview"].isChecked()
|
||||
assert workspace.summary_buttons["today"].text().endswith("2 人")
|
||||
|
||||
_click(workspace.custom_date_button, application)
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||
# Clicking the selected custom mode keeps a visible selection.
|
||||
_click(workspace.custom_date_button, application)
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
workspace.start_date.setDate(QDate.currentDate().addDays(-4))
|
||||
workspace.end_date.setDate(QDate.currentDate().addDays(-2))
|
||||
workspace.end_date.editingFinished.emit()
|
||||
assert repository.queries[-1]["start_date"] == QDate.currentDate().addDays(-4).toString("yyyy-MM-dd")
|
||||
assert repository.queries[-1]["end_date"] == QDate.currentDate().addDays(-2).toString("yyyy-MM-dd")
|
||||
assert _date_selection(workspace) == ["custom"]
|
||||
|
||||
workspace.start_date.setDate(QDate.currentDate().addDays(3))
|
||||
before = len(repository.queries)
|
||||
workspace.start_date.editingFinished.emit()
|
||||
_click(workspace.search_button, application)
|
||||
assert len(repository.queries) == before
|
||||
assert workspace.banner.isVisible()
|
||||
assert "开始日期不能晚于结束日期" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
|
||||
_click(workspace.reset_button, application)
|
||||
assert repository.queries[-1] == {
|
||||
"keyword": "", "status": "", "start_date": "", "end_date": "",
|
||||
"page_no": 1, "page_size": 15,
|
||||
}
|
||||
assert workspace.keyword_edit.text() == ""
|
||||
assert [key for key, button in workspace.status_buttons.items() if button.isChecked()] == [""]
|
||||
assert _date_selection(workspace) == ["all"]
|
||||
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("permissions", "expected", "editable"), [
|
||||
(("*",), ["诊单", "AI 分析"], True),
|
||||
(("tcm.diagnosis/readonlyDetail",), ["查看"], False),
|
||||
((), [], None),
|
||||
])
|
||||
def test_row_actions_obey_edit_readonly_and_no_permission(
|
||||
application: QApplication, workspace_factory, permissions, expected, editable
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory(permissions=permissions)
|
||||
opened = []
|
||||
workspace.diagnosis_requested.connect(lambda row, can_edit: opened.append((row["diagnosis_id"], can_edit)))
|
||||
for index in range(workspace.table.rowCount()):
|
||||
row = workspace.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
actions = workspace.table.cellWidget(index, 9)
|
||||
assert actions.visible_labels == expected
|
||||
if permissions == ("*",):
|
||||
overflow = ["预约", "重新指派" if row["assistant_id"] else "指派医助"]
|
||||
if not row["has_id_card"]:
|
||||
overflow.append("补全身份证")
|
||||
overflow.append("关联订单")
|
||||
if row["appointment_id"] and row["appointment_status"] in (1, 4):
|
||||
overflow.append("取消")
|
||||
assert actions.overflow_labels == overflow
|
||||
assert all(entry.property("danger") is True for entry in actions.menu.actions() if entry.text() == "取消")
|
||||
else:
|
||||
assert actions.more_button is None
|
||||
assert actions.overflow_labels == []
|
||||
if editable is not None:
|
||||
_click(actions.buttons[0], application)
|
||||
assert opened[-1] == (row["diagnosis_id"], editable)
|
||||
if editable is None:
|
||||
workspace.table.selectRow(0)
|
||||
workspace._open_selected_diagnosis()
|
||||
assert opened == []
|
||||
assert workspace.bottom_actions.isHidden()
|
||||
|
||||
|
||||
def _selector(workspace: PatientListWorkspace, row: int) -> QCheckBox:
|
||||
return workspace.table.cellWidget(row, 0).findChild(QCheckBox)
|
||||
|
||||
|
||||
def _assert_single_selection(workspace: PatientListWorkspace) -> None:
|
||||
checked = [row for row in range(workspace.table.rowCount()) if _selector(workspace, row).isChecked()]
|
||||
assert len(checked) <= 1
|
||||
if checked:
|
||||
assert checked == [workspace.table.currentRow()]
|
||||
|
||||
|
||||
def test_sorted_selectors_and_row_actions_keep_distinct_resource_ids(
|
||||
application: QApplication, workspace_factory
|
||||
) -> None:
|
||||
workspace, _repository = workspace_factory()
|
||||
table = workspace.table
|
||||
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"]
|
||||
table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||||
_settle(application)
|
||||
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"] != first_id
|
||||
for index in (0, 1):
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
_click(_selector(workspace, index), application)
|
||||
assert table.current_data()["diagnosis_id"] == expected["diagnosis_id"]
|
||||
assert _selector(workspace, index).isChecked()
|
||||
_assert_single_selection(workspace)
|
||||
|
||||
# Leave a different row current: every callback must use its own sorted row.
|
||||
table.selectRow(2)
|
||||
_settle(application)
|
||||
_assert_single_selection(workspace)
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
emitted = []
|
||||
workspace.diagnosis_requested.connect(lambda row, editable: emitted.append(("diagnosis", row, editable)))
|
||||
workspace.ai_consult_requested.connect(lambda row: emitted.append(("ai", row, None)))
|
||||
workspace.appointment_requested.connect(lambda row: emitted.append(("appointment", row, None)))
|
||||
for index in range(table.rowCount()):
|
||||
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
actions = table.cellWidget(index, 9)
|
||||
actions.buttons[0].click()
|
||||
actions.buttons[1].click()
|
||||
next(action for action in actions.menu.actions() if action.text() == "预约").trigger()
|
||||
for kind, row, editable in emitted[-3:]:
|
||||
assert row["diagnosis_id"] == expected["diagnosis_id"]
|
||||
assert row["source_patient_id"] == expected["source_patient_id"]
|
||||
assert row["appointment_id"] == expected["appointment_id"]
|
||||
if kind == "diagnosis":
|
||||
assert editable is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("silent", [False, True])
|
||||
def test_loading_resets_changed_query_and_ignores_stale_success_and_error(
|
||||
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||
) -> None:
|
||||
workspace, repository = workspace_factory()
|
||||
table = workspace.table
|
||||
queued = []
|
||||
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||
workspace.keyword_edit.setText("旧请求")
|
||||
workspace.refresh(silent=silent)
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.refresh(silent=silent)
|
||||
_settle(application)
|
||||
assert table.rowCount() == 0
|
||||
assert workspace.content_stack.currentIndex() == 1
|
||||
assert workspace.pager.isVisible()
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
# Workers must keep the UI-thread snapshot even after text changes again.
|
||||
workspace.keyword_edit.setText("尚未查询")
|
||||
newer = queued[1][0]()
|
||||
assert repository.queries[-1]["keyword"] == "最新请求"
|
||||
newer["lists"][0]["patient_name"] = "最新结果"
|
||||
newer["extend"]["scope"]["label"] = "最新范围"
|
||||
queued[1][1]["on_success"](newer)
|
||||
current_item = table.item(0, 1)
|
||||
assert current_item.text().startswith("最新结果")
|
||||
stale = queued[0][0]()
|
||||
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||
queued[0][1]["on_success"](stale)
|
||||
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 1) is current_item
|
||||
assert workspace.scope_label.text() == "最新范围"
|
||||
assert not workspace.banner.isVisible()
|
||||
|
||||
workspace.keyword_edit.setText("最新请求")
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_error"](RuntimeError("患者查询失败"))
|
||||
_settle(application)
|
||||
assert table.item(0, 1) is current_item
|
||||
assert workspace.banner.isVisible()
|
||||
assert "患者查询失败" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||
workspace.refresh(silent=silent)
|
||||
queued[-1][1]["on_success"](queued[-1][0]())
|
||||
_settle(application)
|
||||
assert not workspace.banner.isVisible()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Library display units and query/identity contracts after the approved redesign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import prescription_library as library
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
class QueryRepository:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.rows = [
|
||||
{"id": 41, "prescription_name": "乙方", "formula_type": "aux", "is_public": False,
|
||||
"creator_id": 7, "creator_name": "本页医生", "efficacy": "益气养阴",
|
||||
"herbs": [{"name": "白芍", "dosage": "15g"}], "create_time": "2026-07-15 10:30"},
|
||||
{"id": 42, "prescription_name": "甲方", "formula_type": "main", "is_public": True,
|
||||
"creator_id": 8, "creator_name": "其他医生", "efficacy": "清热祛湿",
|
||||
"herbs": [{"name": "茯苓", "dosage": 10}], "create_time": "2026-07-18 16:20"},
|
||||
]
|
||||
|
||||
def list_prescription_templates(self, **query):
|
||||
self.calls.append(deepcopy(query))
|
||||
# Server total deliberately differs from the current-page/effect-filter count.
|
||||
return {"lists": deepcopy(self.rows), "count": 44}
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def record_id(record):
|
||||
return record["id"] if isinstance(record, dict) else record.id
|
||||
|
||||
|
||||
def row_record(table, index):
|
||||
return table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
|
||||
def row_buttons(table, index):
|
||||
return {button.accessibleName(): button for button in table.cellWidget(index, 9).findChildren(QPushButton)}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def local_execution(monkeypatch):
|
||||
def reject(*_args, **_kwargs):
|
||||
pytest.fail("Library regression tests must not contact external services")
|
||||
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject)
|
||||
monkeypatch.setattr(socket, "create_connection", reject)
|
||||
monkeypatch.setattr(library, "run_async", immediate)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page_factory(application):
|
||||
opened = []
|
||||
|
||||
def create(repository=None, *, permissions=None, user=None):
|
||||
repository = repository or QueryRepository()
|
||||
page = library.PrescriptionLibraryPage(
|
||||
repository, {"*"} if permissions is None else permissions,
|
||||
user or SimpleNamespace(id=7, root=0, role_ids=[]),
|
||||
)
|
||||
opened.append(page)
|
||||
page.resize(1328, 884)
|
||||
page.show()
|
||||
settle(application)
|
||||
assert page.table.rowCount() == 2
|
||||
return page, repository
|
||||
|
||||
yield create
|
||||
for page in opened:
|
||||
for timer in page.findChildren(QTimer):
|
||||
timer.stop()
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dose, expected", [(10, "10g"), ("10", "10g"), ("10g", "10g"), ("15G", "15G"), (0, "0g")])
|
||||
def test_display_unit_is_added_once_without_mutating_dose(dose, expected):
|
||||
row = {"herbs": [{"medicine_id": 11, "name": "白芍", "dosage": dose}]}
|
||||
before = deepcopy(row)
|
||||
assert library._herbs_detail(None, row) == f"白芍 {expected}"
|
||||
assert row == before
|
||||
assert library._herbs_detail(None, {"herbs": [{"medicine_name": "茯苓", "amount": dose}]}) == f"茯苓 {expected}"
|
||||
|
||||
|
||||
def test_demo_raw_doses_and_default_unselected_state_are_preserved(page_factory, monkeypatch):
|
||||
monkeypatch.setattr(library, "datetime", SimpleNamespace(now=lambda: datetime(2026, 9, 5)))
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
originals = {identifier: repository.get_prescription_template(identifier) for identifier in (701, 702)}
|
||||
snapshots = {identifier: deepcopy((row.herbs, row.raw)) for identifier, row in originals.items()}
|
||||
assert library._herbs_detail(None, originals[701]) == "柴胡 10g、白芍 15g、茯苓 15g"
|
||||
assert library._herbs_detail(None, originals[702]) == "酸枣仁 20g、夜交藤 30g"
|
||||
assert {identifier: (row.herbs, row.raw) for identifier, row in originals.items()} == snapshots
|
||||
page, _ = page_factory(repository, permissions=session.permissions, user=session.user)
|
||||
assert page.table.columnCount() == 10
|
||||
assert [page.table.horizontalHeader().logicalIndex(index) for index in range(10)] == list(range(10))
|
||||
assert page.table.currentRow() == -1
|
||||
assert all(not button.isEnabled() for button in (page.view_button, page.ai_button, page.edit_button, page.delete_button))
|
||||
assert [page.metric_cards[key].value_label.text() for key in ("total", "private", "public", "month")] == ["2", "1", "1", "0"]
|
||||
for index in range(2):
|
||||
assert all(button.isEnabled() for button in row_buttons(page.table, index).values())
|
||||
assert len(row_buttons(page.table, index)) == 4
|
||||
assert page.table.item(index, 5).text() == "—"
|
||||
identifier = record_id(row_record(page.table, index))
|
||||
assert page.table.item(index, 4).text() == library._herbs_detail(None, originals[identifier])
|
||||
|
||||
|
||||
def test_query_return_reset_favorite_tab_and_page_send_original_dto(page_factory, application):
|
||||
page, repository = page_factory()
|
||||
repository.calls.clear()
|
||||
page.name_filter.setText(" 测试名称 ")
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
settle(application)
|
||||
page.formula_filter.setCurrentIndex(page.formula_filter.findData("主方"))
|
||||
page.visibility_filter.setCurrentIndex(page.visibility_filter.findData(1))
|
||||
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
|
||||
assert repository.calls == [] # Combo selection still requires query or Return.
|
||||
QTest.keyClick(page.name_filter, Qt.Key.Key_Return)
|
||||
settle(application)
|
||||
filtered = {"prescription_name": "测试名称", "formula_type": "主方", "is_public": 1, "page_no": 1, "page_size": 15}
|
||||
assert repository.calls == [filtered]
|
||||
assert page.table.rowCount() == 1
|
||||
assert record_id(row_record(page.table, 0)) == 42
|
||||
assert page.metric_cards["total"].value_label.text() == "44"
|
||||
assert page.metric_cards["private"].value_label.text() == "0"
|
||||
assert page.metric_cards["public"].value_label.text() == "1"
|
||||
page.pager.load_more()
|
||||
assert repository.calls[-1] == dict(filtered, page_no=2)
|
||||
assert page.pager.page == 2
|
||||
page.favorite_tab.click()
|
||||
assert repository.calls[-2:] == [filtered, dict(filtered, page_no=2)] # Same query refreshes its loaded prefix.
|
||||
assert page.favorite_tab.isChecked() and not page.all_tab.isChecked()
|
||||
page.reset_button.click()
|
||||
assert repository.calls[-1] == {"prescription_name": "", "formula_type": "", "is_public": "", "page_no": 1, "page_size": 15}
|
||||
assert page.effect_filter.currentData() == ""
|
||||
assert page.favorite_tab.isChecked()
|
||||
assert page.table.rowCount() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column, order", [(0, Qt.SortOrder.DescendingOrder), (2, Qt.SortOrder.DescendingOrder)])
|
||||
def test_sort_then_refresh_keeps_selection_tags_and_each_action_bound_to_visible_id(
|
||||
page_factory, application, monkeypatch, column, order
|
||||
):
|
||||
page, repository = page_factory()
|
||||
original_rows = deepcopy(repository.rows)
|
||||
table = page.table
|
||||
visited = []
|
||||
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(record_id(table.current_data())))
|
||||
table.sortItems(column, order)
|
||||
settle(application)
|
||||
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
|
||||
|
||||
def verify_visible_actions():
|
||||
for index in range(2):
|
||||
record = row_record(table, index)
|
||||
assert table.item(index, 2).text() == ("主方" if record["formula_type"] == "main" else "辅方")
|
||||
assert table.item(index, 6).text() == ("所有人可见" if record["is_public"] else "仅自己可见")
|
||||
buttons = row_buttons(table, index)
|
||||
assert buttons["编辑处方模板"].isEnabled() == (record["creator_id"] == 7)
|
||||
table.selectRow(1 - index)
|
||||
button = buttons["查看处方模板"]
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||
assert visited[-1] == record["id"]
|
||||
assert record_id(table.current_data()) == record["id"]
|
||||
|
||||
verify_visible_actions()
|
||||
table.selectRow(next(index for index in range(2) if record_id(row_record(table, index)) == 41))
|
||||
page.refresh()
|
||||
settle(application)
|
||||
assert record_id(table.current_data()) == 41
|
||||
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
|
||||
verify_visible_actions()
|
||||
assert visited == [42, 41, 42, 41]
|
||||
assert repository.rows == original_rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"permissions, user, expected_actions, can_edit_foreign",
|
||||
[({"wcf.prescription/read"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释"}, False),
|
||||
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, False),
|
||||
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[3]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, True)],
|
||||
)
|
||||
def test_row_action_permissions_and_foreign_public_management_are_preserved(
|
||||
page_factory, permissions, user, expected_actions, can_edit_foreign
|
||||
):
|
||||
page, _ = page_factory(permissions=permissions, user=user)
|
||||
index = next(index for index in range(2) if record_id(row_record(page.table, index)) == 42)
|
||||
buttons = row_buttons(page.table, index)
|
||||
assert set(buttons) == expected_actions
|
||||
assert buttons["查看处方模板"].isEnabled()
|
||||
assert buttons["AI 解释"].isEnabled()
|
||||
if "编辑处方模板" in buttons:
|
||||
assert buttons["编辑处方模板"].isEnabled() == can_edit_foreign
|
||||
assert buttons["删除处方模板"].isEnabled() == can_edit_foreign
|
||||
else:
|
||||
assert page.edit_button.isHidden() and page.delete_button.isHidden()
|
||||
@@ -1,268 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
from doctor_workstation.ui.widgets import BusinessPager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
|
||||
monkeypatch.setattr(library_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _issued_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 1000 + index,
|
||||
"sn": f"CF-202608-{1000 + index}",
|
||||
"prescription_type": "汤剂",
|
||||
"is_system_auto": index % 2,
|
||||
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
|
||||
"gender": 2 if index % 2 else 1,
|
||||
"age": 29 + index,
|
||||
"audit_status": index % 3,
|
||||
"void_status": 0,
|
||||
"has_prescription_order": index % 2,
|
||||
"creator_id": 7,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "赵医助",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
|
||||
|
||||
def _library_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 2000 + index,
|
||||
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
|
||||
"formula_type": "主方" if index % 3 else "辅方",
|
||||
"herbs": [
|
||||
{"name": "黄芪", "dosage": 15},
|
||||
{"name": "党参", "dosage": 12},
|
||||
],
|
||||
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
|
||||
"is_public": index % 2,
|
||||
"disable_edit": 0,
|
||||
"creator_id": 7,
|
||||
"creator_name": "陈医生",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
|
||||
}
|
||||
|
||||
|
||||
class DensityRepository:
|
||||
def __init__(self) -> None:
|
||||
self.issued_rows = [_issued_row(index) for index in range(15)]
|
||||
self.library_rows = [_library_row(index) for index in range(15)]
|
||||
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
|
||||
|
||||
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.issued_rows, "count": 44}
|
||||
|
||||
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.library_rows, "count": 41}
|
||||
|
||||
|
||||
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
|
||||
repository = DensityRepository()
|
||||
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
|
||||
permissions = {"*"}
|
||||
if kind == "issued":
|
||||
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
|
||||
repository, permissions, user
|
||||
)
|
||||
else:
|
||||
page = PrescriptionLibraryPage(repository, permissions, user)
|
||||
page.refresh()
|
||||
return page
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
|
||||
viewport = page.table.viewport()
|
||||
return sum(
|
||||
1
|
||||
for row in range(page.table.rowCount())
|
||||
if (
|
||||
(item := page.table.item(row, 0)) is not None
|
||||
and (rect := page.table.visualItemRect(item)).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
pager = BusinessPager(15)
|
||||
pager.update_state(2, 44)
|
||||
pager.show()
|
||||
_settle(application)
|
||||
|
||||
assert prescriptions_module.BusinessPager is BusinessPager
|
||||
assert 40 <= pager.height() <= 44
|
||||
assert pager.minimumHeight() == pager.maximumHeight() == 42
|
||||
assert pager.findChildren(QComboBox) == []
|
||||
assert pager.page_size_label.text() == "15 条/页"
|
||||
margins = pager.layout().contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4)
|
||||
assert pager.page_label is not None and pager.page_label.text() == "2"
|
||||
pager.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 6), ((1710, 920), 9)],
|
||||
)
|
||||
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
||||
application: QApplication,
|
||||
kind: str,
|
||||
size: tuple[int, int],
|
||||
minimum_visible_rows: int,
|
||||
) -> None:
|
||||
page = _new_page(kind)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
_settle(application)
|
||||
|
||||
header = page.findChild(QWidget, "PageHeader")
|
||||
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
|
||||
toolbar = page.findChild(QFrame, toolbar_name)
|
||||
assert header is not None and 60 <= header.height() <= 64
|
||||
assert toolbar is not None and 44 <= toolbar.height() <= 48
|
||||
assert 40 <= page.pager.height() <= 44
|
||||
assert page.pager.minimumHeight() == page.pager.maximumHeight()
|
||||
assert page.table.minimumHeight() == 0
|
||||
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert _fully_visible_rows(page) >= minimum_visible_rows
|
||||
|
||||
pager_position = page.pager.mapTo(page, QPoint())
|
||||
assert pager_position.x() >= 0
|
||||
assert pager_position.x() + page.pager.width() <= page.width()
|
||||
assert pager_position.y() >= 0
|
||||
assert pager_position.y() + page.pager.height() <= page.height()
|
||||
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + (
|
||||
page.pager.page_size_label.width()
|
||||
)
|
||||
assert page_size_right <= page.width()
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
toolbar_margins = toolbar.layout().contentsMargins()
|
||||
assert pager_margins.left() == toolbar_margins.left() == 16
|
||||
assert pager_margins.right() == toolbar_margins.right() == 16
|
||||
|
||||
if kind == "issued":
|
||||
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
||||
assert filters is not None and 84 <= filters.height() <= 92
|
||||
actions_host = page.table.cellWidget(0, 2)
|
||||
assert actions_host is not None
|
||||
row_edit = next(
|
||||
button
|
||||
for button in actions_host.findChildren(QPushButton)
|
||||
if button.accessibleName() == "编辑处方"
|
||||
)
|
||||
edit_top_left = row_edit.mapTo(page.table.viewport(), row_edit.rect().topLeft())
|
||||
edit_bottom_right = row_edit.mapTo(
|
||||
page.table.viewport(), row_edit.rect().bottomRight()
|
||||
)
|
||||
assert row_edit.text() == "编辑"
|
||||
assert page.table.viewport().rect().contains(edit_top_left)
|
||||
assert page.table.viewport().rect().contains(edit_bottom_right)
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
from doctor_workstation.ui.widgets import BusinessPager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
|
||||
monkeypatch.setattr(library_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _issued_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 1000 + index,
|
||||
"sn": f"CF-202608-{1000 + index}",
|
||||
"prescription_type": "汤剂",
|
||||
"is_system_auto": index % 2,
|
||||
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
|
||||
"gender": 2 if index % 2 else 1,
|
||||
"age": 29 + index,
|
||||
"audit_status": index % 3,
|
||||
"void_status": 0,
|
||||
"has_prescription_order": index % 2,
|
||||
"creator_id": 7,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "赵医助",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
|
||||
|
||||
def _library_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 2000 + index,
|
||||
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
|
||||
"formula_type": "主方" if index % 3 else "辅方",
|
||||
"herbs": [
|
||||
{"name": "黄芪", "dosage": 15},
|
||||
{"name": "党参", "dosage": 12},
|
||||
],
|
||||
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
|
||||
"is_public": index % 2,
|
||||
"disable_edit": 0,
|
||||
"creator_id": 7,
|
||||
"creator_name": "陈医生",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
|
||||
}
|
||||
|
||||
|
||||
class DensityRepository:
|
||||
def __init__(self) -> None:
|
||||
self.issued_rows = [_issued_row(index) for index in range(15)]
|
||||
self.library_rows = [_library_row(index) for index in range(15)]
|
||||
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
|
||||
|
||||
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.issued_rows, "count": 44}
|
||||
|
||||
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.library_rows, "count": 41}
|
||||
|
||||
|
||||
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
|
||||
repository = DensityRepository()
|
||||
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
|
||||
permissions = {"*"}
|
||||
if kind == "issued":
|
||||
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
|
||||
repository, permissions, user
|
||||
)
|
||||
else:
|
||||
page = PrescriptionLibraryPage(repository, permissions, user)
|
||||
page.refresh()
|
||||
return page
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
|
||||
viewport = page.table.viewport()
|
||||
return sum(
|
||||
1
|
||||
for row in range(page.table.rowCount())
|
||||
if (
|
||||
(item := page.table.item(row, 0)) is not None
|
||||
and (rect := page.table.visualItemRect(item)).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_infinite_footer_is_shared_compact_and_has_no_page_controls(application):
|
||||
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||
footer = InfiniteList(15)
|
||||
footer.show()
|
||||
_settle(application)
|
||||
assert prescriptions_module.InfiniteList is InfiniteList
|
||||
assert footer.height() == 24
|
||||
assert footer.findChildren(QComboBox) == []
|
||||
assert not hasattr(footer, "page_size_label")
|
||||
assert not hasattr(footer, "next")
|
||||
assert footer.layout().contentsMargins().bottom() == 0
|
||||
footer.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 6), ((1710, 920), 9)],
|
||||
)
|
||||
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
||||
application: QApplication,
|
||||
kind: str,
|
||||
size: tuple[int, int],
|
||||
minimum_visible_rows: int,
|
||||
) -> None:
|
||||
page = _new_page(kind)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
_settle(application)
|
||||
|
||||
header = page.findChild(QWidget, "PageHeader")
|
||||
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
|
||||
toolbar = page.findChild(QFrame, toolbar_name)
|
||||
assert header is not None and header.height() >= header.minimumSizeHint().height()
|
||||
assert toolbar is not None
|
||||
if kind == "issued":
|
||||
assert toolbar.height() == 64
|
||||
assert page.pager.height() == 24
|
||||
# Approved two-line rows retain complete 14 px text and warning content.
|
||||
minimum_visible_rows = 3 if size[1] == 768 else 5
|
||||
else:
|
||||
# The approved library design adds the four metric cards and readable
|
||||
# multiline herb/date rows. Keep meaningful row and pager reachability.
|
||||
assert 60 <= toolbar.height() <= 70
|
||||
assert page.pager.height() == 24
|
||||
minimum_visible_rows = 2 if size[1] == 768 else 4
|
||||
assert page.pager.minimumHeight() == page.pager.maximumHeight()
|
||||
assert page.table.minimumHeight() == 0
|
||||
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert _fully_visible_rows(page) >= minimum_visible_rows
|
||||
|
||||
pager_position = page.pager.mapTo(page, QPoint())
|
||||
assert pager_position.x() >= 0
|
||||
assert pager_position.x() + page.pager.width() <= page.width()
|
||||
assert pager_position.y() >= 0
|
||||
assert pager_position.y() + page.pager.height() <= page.height()
|
||||
page_size_right = page.pager.summary_label.mapTo(page, QPoint()).x() + (
|
||||
page.pager.summary_label.width()
|
||||
)
|
||||
assert page_size_right <= page.width()
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
toolbar_margins = toolbar.layout().contentsMargins()
|
||||
expected_inset = 16 if kind == "issued" else 18
|
||||
assert pager_margins.left() == 12
|
||||
assert toolbar_margins.left() == expected_inset
|
||||
assert pager_margins.right() == 12
|
||||
assert toolbar_margins.right() == expected_inset
|
||||
|
||||
if kind == "issued":
|
||||
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
||||
assert filters is not None and filters.height() == 144
|
||||
actions_host = page.table.cellWidget(0, 2)
|
||||
assert actions_host is not None
|
||||
row_edit = next(
|
||||
button
|
||||
for button in actions_host.findChildren(QPushButton)
|
||||
if button.accessibleName() == "编辑处方"
|
||||
)
|
||||
edit_top_left = row_edit.mapTo(page.table.viewport(), row_edit.rect().topLeft())
|
||||
edit_bottom_right = row_edit.mapTo(
|
||||
page.table.viewport(), row_edit.rect().bottomRight()
|
||||
)
|
||||
assert row_edit.text() == "编辑"
|
||||
assert page.table.viewport().rect().contains(edit_top_left)
|
||||
assert page.table.viewport().rect().contains(edit_bottom_right)
|
||||
else:
|
||||
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
|
||||
assert filters is not None
|
||||
assert page.name_filter.minimumWidth() < 500
|
||||
filter_right = filters.contentsRect().right()
|
||||
for control in (
|
||||
page.name_filter,
|
||||
page.formula_filter,
|
||||
page.visibility_filter,
|
||||
page.effect_filter,
|
||||
page.query_button,
|
||||
page.reset_button,
|
||||
):
|
||||
right = control.mapTo(filters, QPoint()).x() + control.width()
|
||||
assert right <= filter_right
|
||||
|
||||
page.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_density_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
|
||||
1366,
|
||||
768,
|
||||
),
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
|
||||
1710,
|
||||
920,
|
||||
),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1366x768.png": (1366, 768),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1710x920.png": (1710, 920),
|
||||
}
|
||||
for path, dimensions in expected.items():
|
||||
image = QImage(str(path))
|
||||
assert not image.isNull(), path
|
||||
assert (image.width(), image.height()) == dimensions
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
assert page.name_filter.minimumWidth() < 500
|
||||
filter_right = filters.contentsRect().right()
|
||||
for control in (
|
||||
page.name_filter,
|
||||
page.formula_filter,
|
||||
page.visibility_filter,
|
||||
page.effect_filter,
|
||||
page.query_button,
|
||||
page.reset_button,
|
||||
):
|
||||
right = control.mapTo(filters, QPoint()).x() + control.width()
|
||||
assert right <= filter_right
|
||||
|
||||
page.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_density_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
|
||||
1366,
|
||||
768,
|
||||
),
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
|
||||
1710,
|
||||
920,
|
||||
),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1366x768.png": (1366, 768),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1710x920.png": (1710, 920),
|
||||
}
|
||||
for path, dimensions in expected.items():
|
||||
image = QImage(str(path))
|
||||
assert not image.isNull(), path
|
||||
assert (image.width(), image.height()) == dimensions
|
||||
|
||||
@@ -140,7 +140,7 @@ def test_number_column_renders_sn_id_and_visible_warning_with_dynamic_height(
|
||||
assert "已有关联业务订单,当前处方存在重复药材:黄 芪" in duplicate_tip
|
||||
assert blank_height > normal_height
|
||||
assert duplicate_height > normal_height
|
||||
assert page.table.columnWidth(1) >= 250
|
||||
assert page.table.columnWidth(1) == 192
|
||||
|
||||
image = page.table.viewport().grab().toImage().convertToFormat(QImage.Format.Format_RGB32)
|
||||
red_pixels = 0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -200,7 +200,7 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
|
||||
"disable_edit": 1,
|
||||
"creator_id": 9,
|
||||
"creator_name": "张医生",
|
||||
"create_time": "2026-08-10 12:00:00",
|
||||
"create_time": QDate.currentDate().toString("yyyy-MM-dd") + " 12:00:00",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
@@ -237,8 +237,9 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
|
||||
"创建时间",
|
||||
"操作",
|
||||
]
|
||||
assert page.table.cellWidget(0, 2) is not None
|
||||
assert page.table.cellWidget(0, 6) is not None
|
||||
# 处方类型与公开范围改由 _RowDecorationDelegate 绘制,只有操作列仍是真实控件。
|
||||
assert page.table.item(0, 2).data(prescription_module._ROLE_TAG_KIND) == "accent"
|
||||
assert page.table.item(0, 6).data(prescription_module._ROLE_LEAD_ICON) == "lock"
|
||||
assert page.table.cellWidget(0, 9) is not None
|
||||
assert not page.view_button.isHidden() and page.view_button.isEnabled()
|
||||
assert not page.ai_button.isHidden() and page.ai_button.isEnabled()
|
||||
@@ -305,8 +306,10 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
|
||||
assert page.table.columnCount() == 11
|
||||
assert page.table.horizontalHeaderItem(2).text() == "操作"
|
||||
assert page.table.cellWidget(0, 2) is not None
|
||||
assert page.table.cellWidget(0, 3) is not None
|
||||
assert page.table.cellWidget(0, 6) is not None
|
||||
# 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
|
||||
# 各挂一个 QWidget;这里改为断言驱动绘制的角色数据仍然写入。
|
||||
assert page.table.item(0, 3).data(prescription_module._ROLE_TAG_KIND) == "accent"
|
||||
assert page.table.item(0, 6).data(prescription_module._ROLE_TAG_KIND)
|
||||
row_edit = next(
|
||||
button
|
||||
for button in page.table.cellWidget(0, 2).findChildren(QPushButton)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Real prescription pages append server pages without losing row actions."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def inline(function, *, on_success, on_error, on_finished):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
on_error(error)
|
||||
else:
|
||||
on_success(result)
|
||||
finally:
|
||||
on_finished()
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get(self, **q):
|
||||
self.calls.append(dict(q))
|
||||
start = (q["page_no"] - 1) * q["page_size"]
|
||||
return {
|
||||
"lists": [
|
||||
{
|
||||
"id": i,
|
||||
"prescription_name": f"Template {i}",
|
||||
"sn": f"RX{i}",
|
||||
"patient_name": f"Patient {i}",
|
||||
"creator_id": 1,
|
||||
"is_public": False,
|
||||
"efficacy": "清热祛湿" if i > 15 else "益气养阴",
|
||||
"herbs": [],
|
||||
"create_time": "2026-09-01 10:00",
|
||||
}
|
||||
for i in range(start + 1, min(start + q["page_size"], 37) + 1)
|
||||
],
|
||||
"count": 37,
|
||||
"extend": {"doctors": [{"id": 1, "name": "Doctor"}]},
|
||||
}
|
||||
|
||||
list_prescriptions = get
|
||||
list_prescription_templates = get
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module,cls",
|
||||
[
|
||||
(prescriptions, prescriptions.PrescriptionsPage),
|
||||
(prescription_library, prescription_library.PrescriptionLibraryPage),
|
||||
],
|
||||
)
|
||||
def test_scroll_append_refresh_and_filter_reset(app, monkeypatch, module, cls):
|
||||
monkeypatch.setattr(module, "run_async", inline)
|
||||
repo = Repository()
|
||||
page = cls(repo, {"*"}, SimpleNamespace(id=1, root=1, role_ids=[]))
|
||||
page.resize(1250, 760)
|
||||
page.show()
|
||||
page.refresh()
|
||||
# Let deferred filter/table geometry settle before scrolling its real viewport.
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
QTest.qWait(35)
|
||||
assert page.table.rowCount() == 15
|
||||
page.table.selectRow(4)
|
||||
before_id = page.table.current_data()["id"]
|
||||
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||
for _ in range(50):
|
||||
if page.table.rowCount() == 30:
|
||||
break
|
||||
QTest.qWait(20)
|
||||
assert page.table.rowCount() == 30 and page.table.current_data()["id"] == before_id
|
||||
assert (
|
||||
len({page.table.item(r, 0).data(Qt.ItemDataRole.UserRole)["id"] for r in range(30)}) == 30
|
||||
)
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 30
|
||||
assert [q["page_no"] for q in repo.calls[-2:]] == [1, 2]
|
||||
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||
QTest.qWait(90)
|
||||
assert page.table.rowCount() == 37 and not page.pager.has_more
|
||||
edit = page.name_filter if module is prescription_library else page.patient_filter
|
||||
edit.setText("changed")
|
||||
page._search()
|
||||
assert page.table.rowCount() == 15 and repo.calls[-1]["page_no"] == 1
|
||||
assert page.pager.height() == 24 and page.layout().contentsMargins().bottom() == 8
|
||||
page.close()
|
||||
|
||||
|
||||
def test_local_effect_filter_continues_until_matching_rows_are_visible(app, monkeypatch):
|
||||
monkeypatch.setattr(prescription_library, "run_async", inline)
|
||||
page = prescription_library.PrescriptionLibraryPage(
|
||||
Repository(), {"*"}, SimpleNamespace(id=1, root=1, role_ids=[])
|
||||
)
|
||||
page.resize(1250, 760)
|
||||
page.show()
|
||||
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
|
||||
page._search()
|
||||
QTest.qWait(180)
|
||||
assert page.table.rowCount() >= 15
|
||||
assert page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] > 15
|
||||
page.close()
|
||||
|
||||
|
||||
def test_audit_status_sort_keeps_actions_on_the_visible_prescription(app, monkeypatch):
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
|
||||
monkeypatch.setattr(prescriptions, "run_async", inline)
|
||||
|
||||
class AuditRepository(Repository):
|
||||
def get(self, **query):
|
||||
result = super().get(**query)
|
||||
for row in result["lists"]:
|
||||
identifier = row["id"]
|
||||
row.update(
|
||||
audit_status=identifier % 3,
|
||||
audit_remark="rejected reason" if identifier % 3 == 2 else "",
|
||||
business_prescription_audit_rejected=identifier % 2 == 0,
|
||||
business_prescription_audit_remark="business reason"
|
||||
if identifier % 2 == 0
|
||||
else "",
|
||||
)
|
||||
return result
|
||||
|
||||
list_prescriptions = get
|
||||
|
||||
page = prescriptions.PrescriptionsPage(AuditRepository(), {"*"}, SimpleNamespace(id=1))
|
||||
page.refresh()
|
||||
opened = []
|
||||
page._view_selected = lambda: opened.append(page.table.current_data()["id"])
|
||||
page.table.sortItems(6, Qt.SortOrder.DescendingOrder)
|
||||
page.pager.load_more()
|
||||
page.refresh()
|
||||
assert page.table.rowCount() == 30
|
||||
for row in range(page.table.rowCount()):
|
||||
expected = page.table.item(row, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||
buttons = page.table.cellWidget(row, 2).findChildren(QPushButton)
|
||||
next(button for button in buttons if button.accessibleName() == "查看处方").click()
|
||||
assert opened[-1] == expected
|
||||
page.close()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Native interactions and reachability unique to the issued-prescription blue UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QRect, Qt, QTimer
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QAbstractItemView, QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui import icons
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _rect_in(widget, parent) -> QRect:
|
||||
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||
|
||||
|
||||
def _assert_visible_within(widget, viewport) -> None:
|
||||
assert widget.isVisible()
|
||||
rect = _rect_in(widget, viewport)
|
||||
assert viewport.rect().contains(rect), (widget.objectName(), rect, viewport.rect())
|
||||
|
||||
|
||||
def _record_id(table, row: int) -> int:
|
||||
return int(table.item(row, 0).data(Qt.ItemDataRole.UserRole).id)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def shell_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
network_attempts = []
|
||||
|
||||
def reject_network(*_args, **_kwargs):
|
||||
network_attempts.append("socket")
|
||||
pytest.fail("Prescription blue interaction tests must use local Demo data only")
|
||||
|
||||
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||
try:
|
||||
result = function()
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
raise
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
|
||||
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", immediate)
|
||||
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||
opened = []
|
||||
|
||||
def create(width=1536, height=960):
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
permissions = PermissionSet(["*"])
|
||||
shell = ShellWindow(repository, {
|
||||
"user": session.user,
|
||||
"permissions": permissions,
|
||||
"demo_mode": True,
|
||||
"menu": [{"perms": "tcm.prescription/lists"}],
|
||||
}, permissions=permissions)
|
||||
opened.append(shell)
|
||||
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||
shell.resize(width, height)
|
||||
shell.show()
|
||||
assert shell.navigate("prescriptions")
|
||||
_settle(application)
|
||||
page = shell.pages["prescriptions"]
|
||||
assert {_record_id(page.table, row) for row in range(page.table.rowCount())} == {801, 802}
|
||||
assert (shell.width(), shell.height()) == (width, height)
|
||||
return shell, page
|
||||
|
||||
yield create
|
||||
for shell in opened:
|
||||
for timer in shell.findChildren(QTimer):
|
||||
timer.stop()
|
||||
shell.close()
|
||||
shell.deleteLater()
|
||||
_settle(application)
|
||||
assert network_attempts == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height", "filter_height"),
|
||||
[(1536, 960, 144), (1366, 768, 144), (1024, 768, 200), (1024, 640, 200)],
|
||||
ids=["full-desktop", "compact-desktop", "small-window", "minimum-window"],
|
||||
)
|
||||
def test_shell_preserves_all_columns_filters_and_reachable_pager(
|
||||
shell_factory, application, width, height, filter_height
|
||||
) -> None:
|
||||
shell, page = shell_factory(width, height)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
table = page.table
|
||||
assert [column.key for column in table.columns] == [
|
||||
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
|
||||
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
|
||||
]
|
||||
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
|
||||
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
|
||||
]
|
||||
assert table.horizontalHeaderItem(2).text() == "操作"
|
||||
assert page.filter_card.height() == filter_height
|
||||
for name in ("quick_date", "start_time", "end_time", "audit_filter", "source_filter",
|
||||
"sn_filter", "patient_filter", "doctor_filter", "query_button", "reset_button"):
|
||||
_assert_visible_within(getattr(page, name), page.filter_card)
|
||||
|
||||
outer_scroll = page.scroll.verticalScrollBar()
|
||||
if width >= 1366:
|
||||
assert outer_scroll.maximum() == 0
|
||||
_assert_visible_within(page.filter_card, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager, page.scroll.viewport())
|
||||
else:
|
||||
# The compact footer allows 1024x768 to fit without outer scrolling.
|
||||
if height <= 640:
|
||||
assert outer_scroll.maximum() > 0
|
||||
assert table.horizontalScrollBar().maximum() > 0
|
||||
|
||||
if width == 1536:
|
||||
assert table.horizontalScrollBar().maximum() == 0
|
||||
for row in range(table.rowCount()):
|
||||
for column in range(11):
|
||||
assert table.viewport().rect().contains(table.visualItemRect(table.item(row, column))), (row, column)
|
||||
_assert_visible_within(page.pager, shell)
|
||||
|
||||
# Every logical column remains reachable in the native table at every width.
|
||||
outer_scroll.setValue(outer_scroll.maximum())
|
||||
for column in range(11):
|
||||
table.scrollToItem(table.item(0, column), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
_settle(application)
|
||||
assert table.viewport().rect().contains(table.visualItemRect(table.item(0, column))), column
|
||||
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||
_settle(application)
|
||||
action_host = table.cellWidget(0, 2)
|
||||
assert action_host is not None
|
||||
_assert_visible_within(action_host, table.viewport())
|
||||
_assert_visible_within(action_host, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager, page.scroll.viewport())
|
||||
_assert_visible_within(page.pager.summary_label, shell)
|
||||
assert page.pager.height() == 24
|
||||
assert not hasattr(page.pager, "next")
|
||||
for button in page.pager.findChildren(QPushButton):
|
||||
if button.isVisible():
|
||||
_assert_visible_within(button, page.scroll.viewport())
|
||||
for button in action_host.findChildren(QPushButton):
|
||||
_assert_visible_within(button, table.viewport())
|
||||
|
||||
|
||||
def test_native_checkbox_center_clicks_and_space_keep_row_binding(shell_factory, application) -> None:
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
row = next(index for index in range(table.rowCount()) if _record_id(table, index) == 802)
|
||||
selector = table.item(row, 0)
|
||||
table.setCurrentItem(selector)
|
||||
table.setFocus()
|
||||
_settle(application)
|
||||
center = table.visualItemRect(selector).center()
|
||||
assert table.viewport().rect().contains(center)
|
||||
assert selector.checkState() == Qt.CheckState.Unchecked
|
||||
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||
assert selector.checkState() == Qt.CheckState.Checked
|
||||
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||
assert selector.checkState() == Qt.CheckState.Unchecked
|
||||
QTest.keyClick(table, Qt.Key.Key_Space)
|
||||
assert selector.checkState() == Qt.CheckState.Checked
|
||||
assert int(table.current_data().id) == 802
|
||||
assert selector.data(Qt.ItemDataRole.UserRole).id == 802
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", [Qt.SortOrder.AscendingOrder, Qt.SortOrder.DescendingOrder])
|
||||
def test_sorted_native_row_view_button_targets_visible_record(
|
||||
shell_factory, application, monkeypatch, order
|
||||
) -> None:
|
||||
_shell, page = shell_factory()
|
||||
table = page.table
|
||||
visited = []
|
||||
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(int(table.current_data().id)))
|
||||
table.sortItems(1, order)
|
||||
_settle(application)
|
||||
expected_order = [801, 802] if order == Qt.SortOrder.AscendingOrder else [802, 801]
|
||||
assert [_record_id(table, index) for index in range(2)] == expected_order
|
||||
for row in range(2):
|
||||
# Start on the other record, so a stale action that only uses selection fails.
|
||||
table.selectRow(1 - row)
|
||||
host = table.cellWidget(row, 2)
|
||||
button = next(button for button in host.findChildren(QPushButton)
|
||||
if button.accessibleName() == "查看处方")
|
||||
_assert_visible_within(button, table.viewport())
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||
assert visited[-1] == expected_order[row]
|
||||
assert int(table.current_data().id) == expected_order[row]
|
||||
assert visited == expected_order
|
||||
|
||||
|
||||
def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(application) -> None:
|
||||
kind, color, size = "pencil", "#1769E8", 19
|
||||
shared = icons.icon(kind, color, size)
|
||||
original_key = shared.cacheKey()
|
||||
original_normal = shared.pixmap(size, size, QIcon.Mode.Normal).toImage()
|
||||
original_disabled = shared.pixmap(size, size, QIcon.Mode.Disabled).toImage()
|
||||
|
||||
blue = prescriptions_module._blue_prescription_icon(kind, color, size)
|
||||
|
||||
assert icons.icon(kind, color, size) is shared
|
||||
assert shared.cacheKey() == original_key
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, "#A4ADBA", size).toImage()
|
||||
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() != original_disabled
|
||||
|
||||
|
||||
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
|
||||
_shell, page = shell_factory(1024, 768)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
button = page.doctor_filter.button
|
||||
full_text = "联合会诊专家门诊陈医生、疑难病联合门诊林医生 等3人"
|
||||
button.setText(full_text)
|
||||
_settle(application)
|
||||
assert button.fontMetrics().horizontalAdvance(full_text) > button.width() - 48
|
||||
assert button.text() == full_text
|
||||
assert button.toolTip() == full_text
|
||||
assert not button.grab().isNull() # Execute the actual compact paint path.
|
||||
assert button.text() == full_text
|
||||
assert page.doctor_filter.values() == []
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(8):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
widget.resize(1340, 900)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
widget.patient_name_label.setText("林晓岚")
|
||||
widget.patient_meta_label.setText("女 · 46岁 · 138****1203 | 就诊号:101")
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_primary_action_and_reading_ink_use_separate_colors(page, application):
|
||||
page.video_button.setEnabled(True)
|
||||
settle(application)
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == "#1769e8"
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#ffffff"
|
||||
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||
page.video_button.setEnabled(False)
|
||||
settle(application)
|
||||
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#8e8f90"
|
||||
|
||||
|
||||
def test_typography_preserves_regular_body_and_distinct_values(page):
|
||||
body = page.case_labels["present"]
|
||||
assert body.font().pixelSize() == 14
|
||||
assert body.font().weight() == 400
|
||||
assert page.patient_name_label.font().pixelSize() == 22
|
||||
assert page.patient_name_label.font().weight() == 600
|
||||
assert page.vital_labels["height"].font().pixelSize() == 18
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1340, 1080, 816])
|
||||
def test_identity_and_actions_stay_inside_hero_at_both_layouts(page, application, width):
|
||||
page.resize(width, 900)
|
||||
settle(application)
|
||||
hero = page.findChild(QFrame, "ReceptionHero")
|
||||
for button in (page.notify_button, page.history_button, page.video_button, page.more_button):
|
||||
assert hero.rect().contains(button.geometry())
|
||||
assert button.width() >= button.minimumSizeHint().width()
|
||||
if width == 1340:
|
||||
assert abs(page.video_button.geometry().center().y() - page.patient_avatar_label.geometry().center().y()) < 20
|
||||
else:
|
||||
assert page.video_button.geometry().top() >= page.patient_meta_label.geometry().bottom()
|
||||
|
||||
|
||||
def test_body_growth_stays_readable_and_does_not_cover_actions(page, application):
|
||||
body = page.case_labels["present"]
|
||||
body.setText("患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n" * 12)
|
||||
settle(application)
|
||||
assert body.height() >= body.heightForWidth(body.width())
|
||||
assert page.detail_scroll.verticalScrollBar().maximum() > 0
|
||||
assert body.textFormat() == Qt.TextFormat.PlainText
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["6.8 mmol/L", "1234.56 mg/g", "≤0.25 mg/L", "—"])
|
||||
def test_lab_value_keeps_full_plain_text_and_wraps_long_units(page, application, value):
|
||||
label = page.lab_labels["fasting"]
|
||||
label.setText(value)
|
||||
settle(application)
|
||||
assert label.text() == value
|
||||
assert label.textFormat() == Qt.TextFormat.PlainText
|
||||
for width in (55, 120):
|
||||
layout, size = label._value_layout(width)
|
||||
assert sum(layout.lineAt(i).textLength() for i in range(layout.lineCount())) == len(value)
|
||||
assert size.height() == label.heightForWidth(width)
|
||||
for i in range(layout.lineCount()):
|
||||
line = layout.lineAt(i)
|
||||
assert line.naturalTextWidth() <= width + 1
|
||||
assert line.y() + line.height() <= size.height()
|
||||
|
||||
|
||||
def test_queue_summary_and_navigation_tabs_remain_available(page):
|
||||
summary = page.findChild(QLabel, "ReceptionQueueSummary")
|
||||
assert summary.isVisible()
|
||||
assert [page.detail_tabs.tabText(i) for i in range(page.detail_tabs.count())] == [
|
||||
"问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"
|
||||
]
|
||||
|
||||
|
||||
def test_risks_remain_in_full_report_while_preview_matches_approved_image(page, application):
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
settle(application)
|
||||
page._render_ai_risk_chips([
|
||||
{"label": "血糖波动风险", "level": "medium"},
|
||||
{"label": "睡眠质量下降", "level": "low"},
|
||||
])
|
||||
settle(application)
|
||||
assert not page.ai_risk_chip_host.isVisible()
|
||||
dialog = reception_module._ReceptionAiAnalysisDialog({"qwen": {
|
||||
"diagnosis_advice": "需要结合病史复核。",
|
||||
"risk_assessment": [{"label": "血糖波动风险", "level": "medium"}],
|
||||
"treatment_advice": "完整治疗建议仍可阅读。",
|
||||
}})
|
||||
dialog.show()
|
||||
settle(application)
|
||||
try:
|
||||
risk = next(label for label in dialog.findChildren(QLabel) if label.text() == "血糖波动风险")
|
||||
assert risk.isVisible()
|
||||
assert risk.width() >= risk.fontMetrics().horizontalAdvance(risk.text())
|
||||
assert any(label.text() == "完整治疗建议仍可阅读。" for label in dialog.findChildren(QLabel))
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_compact_ai_keeps_full_treatment_data_without_pushing_controls_out_of_view(page, application):
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
page.ai_summary_label.setText("需要结合完整病史与复诊记录核对。" * 25)
|
||||
page.ai_treatment_label.setText("请结合患者实际情况复核完整分析。" * 25)
|
||||
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
|
||||
settle(application)
|
||||
assert page.ai_treatment_label.text() == "请结合患者实际情况复核完整分析。" * 25
|
||||
assert not page.ai_treatment_label.isVisible()
|
||||
assert not page.ai_question_edit.isVisible()
|
||||
assert not page.ai_analysis_history_button.isVisible()
|
||||
actions = [action.text() for action in page.more_button.menu().actions()]
|
||||
assert "向 AI 提问" in actions
|
||||
assert "历史 AI 报告" in actions
|
||||
assert "重新分析 AI 报告" in actions
|
||||
assert page.ai_analysis_card.height() < 320
|
||||
|
||||
|
||||
def test_patient_switch_clears_previous_condition_tooltip(page):
|
||||
page.patient_meta_label.setToolTip("上一位患者的病情提示")
|
||||
page._reset_detail_content({"id": 202, "patient_name": "新患者"})
|
||||
assert page.patient_meta_label.toolTip() == ""
|
||||
page.patient_meta_label.setToolTip("已失效的病情提示")
|
||||
page._render_detail_load_failure("详情暂不可用")
|
||||
assert page.patient_meta_label.toolTip() == ""
|
||||
|
||||
|
||||
def test_moved_ai_controls_keep_actions_and_enabled_state(application, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(ReceptionPage, "_open_ai_analysis_dialog", lambda self: calls.append("history"))
|
||||
monkeypatch.setattr(ReceptionPage, "_regenerate_patient_ai_reports", lambda self: calls.append("regenerate"))
|
||||
monkeypatch.setattr(ReceptionPage, "_open_ai_assistant", lambda self, prompt: calls.append(("ask", prompt)))
|
||||
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||
try:
|
||||
widget.poll_timer.stop()
|
||||
assert not widget.ai_history_action.isEnabled()
|
||||
assert not widget.ai_regenerate_action.isEnabled()
|
||||
widget._ai_analysis_histories["qwen"] = [{"diagnosis_advice": "示例分析"}]
|
||||
widget._ai_analysis_patient_flow = True
|
||||
widget._ai_analysis_patient_id = 101
|
||||
widget._sync_ai_analysis_view()
|
||||
assert widget.ai_history_action.isEnabled()
|
||||
assert widget.ai_regenerate_action.isEnabled()
|
||||
widget.ai_history_action.trigger()
|
||||
widget.ai_regenerate_action.trigger()
|
||||
next(action for action in widget.more_button.menu().actions() if action.text() == "向 AI 提问").trigger()
|
||||
widget.ai_assistant_card.findChild(QPushButton, "ReceptionAiTitle").click()
|
||||
assert calls == ["history", "regenerate", ("ask", ""), ("ask", "")]
|
||||
widget._ai_analysis_regenerating = True
|
||||
widget._sync_ai_analysis_view()
|
||||
assert not widget.ai_regenerate_action.isEnabled()
|
||||
finally:
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
@@ -0,0 +1,379 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint, QSize
|
||||
from PySide6.QtGui import QFont, QFontInfo, QIcon
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.reception_style import (
|
||||
TECH_BLUE,
|
||||
body_family,
|
||||
heading_family,
|
||||
number_family,
|
||||
)
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||
|
||||
|
||||
class _Page(QWidget):
|
||||
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
self.label = QLabel("原有页面正文", self)
|
||||
layout.addWidget(self.label)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||
navigation = [
|
||||
NavigationItem(key, title, "", _Page, ("doctor.appointment/lists",))
|
||||
for key, title in (
|
||||
("appointments", "挂号列表"),
|
||||
("consultations", "问诊列表"),
|
||||
("reception", "接诊台"),
|
||||
("patients", "我的患者"),
|
||||
("prescriptions", "已开处方"),
|
||||
("prescription_library", "我的处方库"),
|
||||
("legacy_reference", "原版框架参照"),
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
"_resolve_navigation",
|
||||
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
|
||||
)
|
||||
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||
shell = ShellWindow(
|
||||
object(),
|
||||
{"user": {"name": "陈医生"}, "demo_mode": True},
|
||||
permissions={"doctor.appointment/lists", "tcm.diagnosis/aiAssistant"},
|
||||
)
|
||||
shell.resize(1536, 960)
|
||||
shell.show()
|
||||
application.processEvents()
|
||||
yield shell
|
||||
shell.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _pixel(widget: QWidget, x: int, y: int) -> str:
|
||||
image = widget.grab().toImage()
|
||||
ratio = image.devicePixelRatio()
|
||||
return image.pixelColor(round(x * ratio), round(y * ratio)).name().upper()
|
||||
|
||||
|
||||
def test_approved_chrome_restores_styles_fonts_icons_and_geometry(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
original_app_qss = application.styleSheet()
|
||||
original_app_font = application.font().toString()
|
||||
original_colors = dict(COLORS)
|
||||
original_page_fonts = {key: page.label.font().toString() for key, page in window.pages.items()}
|
||||
original_styles = dict(window._legacy_chrome_styles)
|
||||
original_avatar = window.user_menu_button.icon().pixmap(34, 34).toImage()
|
||||
original_ai = window.ai_top_button.icon().pixmap(18, 18).toImage()
|
||||
original_nav = (
|
||||
window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
)
|
||||
|
||||
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library", "consultations", "appointments"):
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert _pixel(window, 6, 300) == "#F3F7FD"
|
||||
assert _pixel(window.sidebar, 5, 300) == "#F3F7FD"
|
||||
assert _pixel(window.workspace, 600, 10) == TECH_BLUE["surface"]
|
||||
assert window.nav_buttons[key].font().family() == body_family()
|
||||
assert window.nav_buttons[key].font().weight() == QFont.Weight.Normal
|
||||
assert window.brand_name.font().family() == heading_family()
|
||||
assert window.brand_name.font().weight() == QFont.Weight.DemiBold
|
||||
assert window.assistant_glyph.property("receptionTechBlue") is True
|
||||
assert window.assistant_status.property("receptionTechBlue") is True
|
||||
assert original_avatar != window.user_menu_button.icon().pixmap(34, 34).toImage()
|
||||
assert original_ai != window.ai_top_button.icon().pixmap(18, 18).toImage()
|
||||
assert (
|
||||
original_nav
|
||||
!= window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
)
|
||||
assert {
|
||||
key: page.label.font().toString() for key, page in window.pages.items()
|
||||
} == original_page_fonts
|
||||
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 190
|
||||
assert _pixel(window, 6, 300) == COLORS["canvas"].upper()
|
||||
assert _pixel(window, 18, 313) == COLORS["canvas"].upper()
|
||||
assert {widget: widget.styleSheet() for widget in original_styles} == original_styles
|
||||
assert window.user_menu_button.icon().pixmap(34, 34).toImage() == original_avatar
|
||||
assert window.ai_top_button.icon().pixmap(18, 18).toImage() == original_ai
|
||||
assert (
|
||||
window.nav_buttons["reception"]
|
||||
.icon()
|
||||
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||
.toImage()
|
||||
== original_nav
|
||||
)
|
||||
assert window.assistant_glyph.property("receptionTechBlue") is False
|
||||
assert window.assistant_status.property("receptionTechBlue") is False
|
||||
|
||||
assert application.styleSheet() == original_app_qss
|
||||
assert application.font().toString() == original_app_font
|
||||
assert original_colors == COLORS
|
||||
assert {
|
||||
key: page.label.font().toString() for key, page in window.pages.items()
|
||||
} == original_page_fonts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_selected_navigation_and_ai_tile_use_approved_blue(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
window.navigate(key)
|
||||
application.processEvents()
|
||||
button = window.nav_buttons[key]
|
||||
button.clearFocus()
|
||||
application.processEvents()
|
||||
assert _pixel(button, 1, button.height() // 2) == TECH_BLUE["accent"]
|
||||
assert _pixel(button, button.width() - 12, button.height() // 2) == TECH_BLUE["selection"]
|
||||
assert _pixel(window.assistant_glyph, 10, 25) == TECH_BLUE["accent"]
|
||||
assert _pixel(window.assistant_status, 3, 9) == TECH_BLUE["accent"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_approved_brand_mark_uses_blue_medical_cross_and_restores_original(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
mark = window.brand_mark
|
||||
original = mark.grab().toImage()
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert mark.property("receptionTechBlue") is True
|
||||
assert mark.width() == mark.height() == 38
|
||||
assert _pixel(mark, 5, 18) == TECH_BLUE["accent"]
|
||||
assert _pixel(mark, 19, 19) == TECH_BLUE["accent"]
|
||||
assert _pixel(mark, 18, 9) == TECH_BLUE["surface"]
|
||||
assert original != mark.grab().toImage()
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert mark.property("receptionTechBlue") is False
|
||||
assert mark.grab().toImage() == original
|
||||
|
||||
|
||||
def test_collapsed_rail_keeps_state_across_approved_and_original_pages(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
window.toggle_sidebar()
|
||||
assert window.sidebar.width() == 68
|
||||
window.navigate("reception")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["reception"].isChecked()
|
||||
assert not window.assistant_card.isVisible()
|
||||
assert window.nav_buttons["reception"].toolTip() == "接诊台"
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert window.assistant_card.isVisible()
|
||||
window.toggle_sidebar()
|
||||
window.navigate("appointments")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["appointments"].isChecked()
|
||||
assert window.nav_buttons["appointments"].toolTip() == "挂号列表"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.navigate("consultations")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["consultations"].isChecked()
|
||||
assert window.nav_buttons["consultations"].toolTip() == "问诊列表"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.navigate("patients")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["patients"].isChecked()
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
window.toggle_sidebar()
|
||||
window.navigate("prescriptions")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.nav_buttons["prescriptions"].isChecked()
|
||||
assert window.nav_buttons["prescriptions"].toolTip() == "已开处方"
|
||||
assert not window.assistant_card.isVisible()
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
window.toggle_sidebar()
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
window.toggle_sidebar()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 190
|
||||
assert window.nav_buttons["legacy_reference"].text() == "原版框架参照"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_approved_shell_geometry_and_function_entries_restore(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
original_geometry = {
|
||||
widget: (widget.size(), widget.iconSize() if hasattr(widget, "iconSize") else None)
|
||||
for widget in (
|
||||
window.sidebar, window.topbar, window.search_host, window.brand_mark,
|
||||
window.assistant_card, window.assistant_button, window.assistant_status,
|
||||
window.upload_settings_button, window.user_menu_button,
|
||||
window.refresh_button, window.notification_button, window.settings_button,
|
||||
*window.nav_buttons.values(),
|
||||
)
|
||||
}
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.workspace.pos() == QPoint(208, 0)
|
||||
assert window.topbar.height() == 76
|
||||
assert window.centralWidget().layout().contentsMargins().isNull()
|
||||
assert window.search_host.mapTo(window, QPoint()) == QPoint(234, 15)
|
||||
assert window.search_host.size() == QSize(384, 44)
|
||||
assert window.assistant_card.mapTo(window, QPoint()) == QPoint(20, 695)
|
||||
assert window.assistant_card.size() == QSize(168, 171)
|
||||
assert window.assistant_button.height() == 46
|
||||
assert window.brand_name.font().pixelSize() == 18
|
||||
assert window.brand_subtitle.font().pixelSize() == 13
|
||||
for index, button in enumerate(window.nav_buttons.values()):
|
||||
assert button.mapTo(window, QPoint()).y() == 99 + 58 * index
|
||||
assert button.height() == 52
|
||||
assert button.font().pixelSize() == 15
|
||||
assert button.iconSize() == QSize(20, 20)
|
||||
assert not window.fold_button.isVisible()
|
||||
assert not window.ai_top_button.isVisible()
|
||||
assert window.menu_sidebar_action.isVisible()
|
||||
assert window.menu_ai_action.isVisible()
|
||||
window.menu_sidebar_action.trigger()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 68
|
||||
assert window.menu_sidebar_action.isVisible()
|
||||
window.menu_sidebar_action.trigger()
|
||||
application.processEvents()
|
||||
assert window.sidebar.width() == 208
|
||||
assert window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert window.workspace.pos() == QPoint(203, 13)
|
||||
assert window.fold_button.isVisible()
|
||||
assert window.ai_top_button.isVisible()
|
||||
assert not window.menu_sidebar_action.isVisible()
|
||||
assert not window.menu_ai_action.isVisible()
|
||||
assert not window.user_separator.isVisible()
|
||||
for widget, (size, icon_size) in original_geometry.items():
|
||||
assert widget.size() == size
|
||||
if icon_size is not None:
|
||||
assert widget.iconSize() == icon_size
|
||||
|
||||
|
||||
def test_reception_fonts_resolve_real_requested_weights(application: QApplication) -> None:
|
||||
for family, weights in (
|
||||
(body_family(), (QFont.Weight.Normal,)),
|
||||
(heading_family(), (QFont.Weight.Medium, QFont.Weight.DemiBold)),
|
||||
(number_family(), (QFont.Weight.Normal, QFont.Weight.DemiBold)),
|
||||
):
|
||||
for weight in weights:
|
||||
font = QFont(family)
|
||||
font.setPixelSize(15)
|
||||
font.setWeight(weight)
|
||||
resolved = QFontInfo(font)
|
||||
assert resolved.family() == family
|
||||
assert resolved.weight() == weight
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_minimum_window_keeps_search_and_all_chrome_actions_separate(
|
||||
application: QApplication, window: ShellWindow, key: str
|
||||
) -> None:
|
||||
window.navigate(key)
|
||||
window.resize(1024, 640)
|
||||
application.processEvents()
|
||||
assert window.width() == 1024
|
||||
assert 220 <= window.search_host.width() < 384
|
||||
controls = (
|
||||
window.search_host, window.refresh_button, window.notification_button,
|
||||
window.settings_button, window.user_menu_button, window.minimize_button,
|
||||
window.fullscreen_button, window.close_button,
|
||||
)
|
||||
for left, right in zip(controls, controls[1:], strict=False):
|
||||
assert left.x() + left.width() <= right.x()
|
||||
assert right.x() + right.width() <= window.topbar.width()
|
||||
window.resize(1536, 960)
|
||||
application.processEvents()
|
||||
assert window.search_host.size() == QSize(384, 44)
|
||||
|
||||
|
||||
def test_refresh_animation_keeps_running_and_restores_palette_on_page_switch(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
button = window.refresh_button
|
||||
original = button.icon().pixmap(18, 18).toImage()
|
||||
button.start_spin()
|
||||
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"):
|
||||
window.navigate(key)
|
||||
application.processEvents()
|
||||
assert button._timer.isActive()
|
||||
assert button.property("receptionTechBlue") is True
|
||||
window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
assert button._timer.isActive()
|
||||
assert button.property("receptionTechBlue") is False
|
||||
assert button._resting.pixmap(18, 18).toImage() == original
|
||||
button._timer.stop()
|
||||
|
||||
|
||||
def test_navigation_between_approved_pages_keeps_existing_chrome_assets(
|
||||
application: QApplication, window: ShellWindow
|
||||
) -> None:
|
||||
assert window.navigate("appointments")
|
||||
application.processEvents()
|
||||
styles = {widget: widget.styleSheet() for widget in window._legacy_chrome_styles}
|
||||
icons = {
|
||||
widget: widget.icon().cacheKey()
|
||||
for widget in (
|
||||
window.user_menu_button, window.refresh_button, window.ai_top_button,
|
||||
*window.nav_buttons.values(),
|
||||
)
|
||||
}
|
||||
window.global_search.setText("患者查询")
|
||||
|
||||
for key in ("consultations", "reception", "patients", "prescriptions", "prescription_library", "appointments", "consultations"):
|
||||
assert window.navigate(key)
|
||||
application.processEvents()
|
||||
assert window.nav_buttons[key].isChecked()
|
||||
assert window.global_search.text() == "患者查询"
|
||||
assert window._reception_chrome_active
|
||||
assert {widget: widget.styleSheet() for widget in styles} == styles
|
||||
assert {widget: widget.icon().cacheKey() for widget in icons} == icons
|
||||
@@ -59,7 +59,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
|
||||
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
|
||||
clicked: list[bool] = []
|
||||
button.clicked.connect(lambda: clicked.append(True))
|
||||
assert surface_color(button) == "#fff7f8"
|
||||
assert surface_color(button) == "#ffffff"
|
||||
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
@@ -81,7 +81,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
|
||||
button.clearFocus()
|
||||
app.processEvents()
|
||||
assert clicked == []
|
||||
assert surface_color(button) == "#fff7f8"
|
||||
assert surface_color(button) == "#ffffff"
|
||||
assert button.geometry() == geometry
|
||||
assert neighbor.geometry() == neighbor_geometry
|
||||
|
||||
@@ -93,14 +93,14 @@ def test_completion_disabled_hover_does_not_look_or_act_enabled(controls):
|
||||
button.setEnabled(False)
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
app.processEvents()
|
||||
assert surface_color(button) == "#f7f8fb"
|
||||
assert surface_color(button) == "#f7fafe"
|
||||
assert button.cursor().shape() == Qt.CursorShape.ArrowCursor
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
|
||||
assert clicked == []
|
||||
button.setEnabled(True)
|
||||
app.processEvents()
|
||||
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||
assert surface_color(button) != "#f7f8fb"
|
||||
assert surface_color(button) != "#f7f7f7"
|
||||
|
||||
|
||||
def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
||||
@@ -112,11 +112,11 @@ def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
||||
button.setFocus(Qt.FocusReason.TabFocusReason)
|
||||
app.processEvents()
|
||||
assert button.hasFocus()
|
||||
assert surface_color(button, border=True) == "#cf4656"
|
||||
assert surface_color(button, border=True) == "#be4b58"
|
||||
assert surface_color(button, border=True) != border_before
|
||||
assert button.geometry() == geometry
|
||||
assert neighbor.geometry() == neighbor_geometry
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
app.processEvents()
|
||||
assert surface_color(button) == "#fff0f2"
|
||||
assert surface_color(button, border=True) == "#cf4656"
|
||||
assert surface_color(button, border=True) == "#be4b58"
|
||||
|
||||
@@ -51,6 +51,52 @@ def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1114, 1320, 1494])
|
||||
def test_visible_history_updates_grow_clinical_card_without_clipping(
|
||||
application: QApplication,
|
||||
queued_async: list[dict[str, Any]],
|
||||
width: int,
|
||||
) -> None:
|
||||
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||
page.resize(width, 824)
|
||||
page.show()
|
||||
try:
|
||||
application.processEvents()
|
||||
page.detail_stack.setCurrentIndex(1)
|
||||
for _ in range(8):
|
||||
application.processEvents()
|
||||
label = page.case_labels["present"]
|
||||
clinical = page.clinical_info_group
|
||||
initial_height = clinical.height()
|
||||
initial_scroll_maximum = page.detail_scroll.verticalScrollBar().maximum()
|
||||
history = (
|
||||
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
|
||||
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
|
||||
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
|
||||
)
|
||||
label.setText(history)
|
||||
application.processEvents()
|
||||
assert label.text() == history
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
|
||||
label.setText("\n".join([history] * 5))
|
||||
application.processEvents()
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert clinical.height() > initial_height
|
||||
assert page.detail_scroll.verticalScrollBar().maximum() > initial_scroll_maximum
|
||||
assert label.maximumHeight() > label.height()
|
||||
|
||||
expanded_height = clinical.height()
|
||||
label.setText("无特殊不适。")
|
||||
application.processEvents()
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert clinical.height() < expanded_height
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
@@ -622,6 +668,7 @@ def test_queue_date_picker_filters_the_selected_day(
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.filter_disclosure.button.click()
|
||||
page.queue_date_button.click()
|
||||
calendar = page.queue_date_button.calendarWidget()
|
||||
assert calendar.isVisible()
|
||||
@@ -1036,14 +1083,14 @@ def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summar
|
||||
caption = medication.findChild(QLabel, "ReceptionCaseFieldCaption")
|
||||
value = medication.findChild(QLabel, "ReceptionCaseFieldValue")
|
||||
assert title is not None and title.font().pixelSize() == 18
|
||||
assert caption is not None and caption.font().pixelSize() == 12
|
||||
assert caption is not None and caption.font().pixelSize() == 13
|
||||
assert value is not None and value.font().pixelSize() == 14
|
||||
assert title.font().weight() >= 700
|
||||
assert caption.font().weight() >= 600
|
||||
assert value.font().weight() >= 700
|
||||
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#17264d"
|
||||
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#617092"
|
||||
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#253a83"
|
||||
assert title.font().weight() == 600
|
||||
assert caption.font().weight() == 400
|
||||
assert value.font().weight() == 600
|
||||
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#5d6b80"
|
||||
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||
assert value.wordWrap()
|
||||
assert value.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
|
||||
@@ -1135,7 +1182,7 @@ def test_queue_load_more_accumulates_to_total_boundary(
|
||||
assert [call["page_no"] for call in calls] == [1, 2]
|
||||
assert all(call["page_size"] == 15 for call in calls)
|
||||
assert page.queue_list.count() == 22
|
||||
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
|
||||
assert page.queue_summary.text() == "共 22 位患者"
|
||||
assert not page._queue_has_more()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
@@ -1466,11 +1513,11 @@ def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
|
||||
buttons = [
|
||||
button
|
||||
for button in (
|
||||
page.complete_button,
|
||||
page.notify_button,
|
||||
page.history_button,
|
||||
page.video_button,
|
||||
page.more_button,
|
||||
page.complete_button,
|
||||
)
|
||||
if button.isVisibleTo(page)
|
||||
]
|
||||
@@ -1688,8 +1735,8 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
||||
assert page.ai_analysis_card.maximumHeight() > 520
|
||||
assert page.ai_analysis_card.findChildren(QScrollArea) == []
|
||||
assert left.height() == right.height()
|
||||
assert 0 <= right.left() - left.right() - 1 <= 2
|
||||
assert abs(left.width() * 5 - right.width() * 4) <= 10
|
||||
assert right.left() - left.right() - 1 == 10
|
||||
assert abs(left.width() / (left.width() + right.width()) - 0.425) < 0.01
|
||||
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
|
||||
assert page.ai_summary_label.minimumSizeHint().width() <= 48
|
||||
|
||||
|
||||
@@ -400,9 +400,11 @@ def test_refresh_button_visible_and_f5_uses_same_debounce(harness, application)
|
||||
page.poll_timer.stop()
|
||||
application.processEvents()
|
||||
assert page.refresh_button.isVisible()
|
||||
assert page.refresh_button.text() == "刷新"
|
||||
assert page.refresh_button.width() >= 50
|
||||
assert page.refresh_button.geometry().right() < page.queue_date_button.geometry().left()
|
||||
assert not page.refresh_button.icon().isNull()
|
||||
assert page.refresh_button.accessibleName() == "刷新接诊台"
|
||||
assert page.refresh_button.width() >= 34
|
||||
assert page.refresh_button.geometry().right() < page.filter_disclosure.button.geometry().left()
|
||||
assert not page.queue_date_button.isVisible()
|
||||
page.note_edit.setFocus()
|
||||
application.processEvents()
|
||||
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
|
||||
|
||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidge
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||
|
||||
|
||||
def _logical_pixel(image: Any, x: int, y: int):
|
||||
@@ -134,7 +134,8 @@ def shell_window(
|
||||
),
|
||||
("prescriptions", "已开处方", "笺", "tcm.prescription/lists"),
|
||||
("patients", "我的患者", "患", "firstvisit.myPatient/lists"),
|
||||
("consultations", "问诊列表", "询", "tcm.diagnosis/lists"),
|
||||
("consultations", "问诊列表", "询", "tcm.diagnosis/lists"),
|
||||
("legacy_reference", "原版框架参照", "旧", "legacy.reference/lists"),
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
@@ -160,38 +161,40 @@ def shell_window(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
|
||||
def test_legacy_shell_matches_reference_geometry_at_both_acceptance_sizes(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("legacy_reference")
|
||||
for width, height in ((1024, 640), (1440, 900)):
|
||||
shell_window.resize(width, height)
|
||||
application.processEvents()
|
||||
|
||||
assert shell_window.sidebar.width() == 179
|
||||
# 独立legacy参照路由继续验证原有导轨和外圈留白。
|
||||
assert shell_window.sidebar.width() == 190
|
||||
assert shell_window.topbar.height() == 62
|
||||
assert shell_window.tabs_host.height() == 0
|
||||
assert shell_window.workspace.width() == width - 26 - 179
|
||||
assert shell_window.stack.width() == width - 26 - 179
|
||||
assert shell_window.workspace.width() == width - 26 - 190
|
||||
assert shell_window.stack.width() == width - 26 - 190
|
||||
assert shell_window.stack.height() == height - 26 - 62
|
||||
assert shell_window.stack.geometry().right() < shell_window.workspace.width()
|
||||
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
|
||||
|
||||
image = shell_window.grab().toImage()
|
||||
assert _logical_pixel(image, 20, 300).name().lower() in {
|
||||
"#f2f5fd",
|
||||
"#f3f6fd",
|
||||
"#f2f6fe",
|
||||
"#f3f6fe",
|
||||
}
|
||||
# 侧边栏不再是画布上的一块面板:它就是画布本身,所以导轨内任意一点
|
||||
# 都必须与外圈留白同色。原先它是 #F4F7FE→#EEF3FD 的斜向渐变,
|
||||
# 沿整条左边缘都对不上画布,形成一道常驻接缝。
|
||||
assert _logical_pixel(image, 20, 300).name().lower() == COLORS["canvas"].lower()
|
||||
assert _logical_pixel(image, 6, 300).name().lower() == COLORS["canvas"].lower()
|
||||
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
|
||||
assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe"
|
||||
assert _logical_pixel(image, 220, 90).name().lower() == COLORS["canvas_mid"].lower()
|
||||
|
||||
|
||||
def test_topbar_search_actions_and_navigation_controls_stay_aligned(
|
||||
def test_legacy_topbar_search_actions_and_navigation_controls_stay_aligned(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("legacy_reference")
|
||||
for width, height in ((1024, 640), (1366, 768)):
|
||||
shell_window.resize(width, height)
|
||||
application.processEvents()
|
||||
@@ -243,6 +246,7 @@ def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -
|
||||
def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("legacy_reference")
|
||||
assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint
|
||||
assert (
|
||||
shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号"
|
||||
@@ -405,15 +409,17 @@ def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
|
||||
]
|
||||
|
||||
|
||||
def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_shell_ai_menu_on_approved_pages_opens_global_patient_picker(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
key: str,
|
||||
) -> None:
|
||||
reception = shell_window.pages["reception"]
|
||||
assert isinstance(reception, _ShellPageDouble)
|
||||
assert shell_window.navigate("reception")
|
||||
reception.ai_context_available = True
|
||||
page = shell_window.pages[key]
|
||||
assert isinstance(page, _ShellPageDouble)
|
||||
assert shell_window.navigate(key)
|
||||
page.ai_context_available = True
|
||||
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
@@ -421,25 +427,34 @@ def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
|
||||
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
|
||||
)
|
||||
|
||||
shell_window.ai_top_button.click()
|
||||
assert shell_window.menu_ai_action.isVisible()
|
||||
shell_window.menu_ai_action.trigger()
|
||||
application.processEvents()
|
||||
|
||||
assert reception.ai_open_count == 0
|
||||
assert page.ai_open_count == 0
|
||||
assert len(opened) == 1
|
||||
assert shell_window.stack.currentWidget() is reception
|
||||
assert shell_window.stack.currentWidget() is page
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "permission"),
|
||||
[("appointments", "doctor.appointment/lists"), ("consultations", "tcm.diagnosis/lists"),
|
||||
("patients", "firstvisit.myPatient/lists"), ("prescriptions", "tcm.prescription/lists"),
|
||||
("prescription_library", "tcm.prescriptionLibrary/lists")],
|
||||
)
|
||||
def test_shell_hides_global_ai_entries_without_ai_permission(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
key: str,
|
||||
permission: str,
|
||||
) -> None:
|
||||
navigation = [
|
||||
NavigationItem(
|
||||
"appointments",
|
||||
key,
|
||||
"问诊列表",
|
||||
"号",
|
||||
_ShellPageDouble,
|
||||
("doctor.appointment/lists",),
|
||||
(permission,),
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
@@ -452,13 +467,19 @@ def test_shell_hides_global_ai_entries_without_ai_permission(
|
||||
window = ShellWindow(
|
||||
object(),
|
||||
{"user": {"name": "无 AI 权限医生"}, "demo_mode": True},
|
||||
permissions={"doctor.appointment/lists"},
|
||||
permissions={permission},
|
||||
)
|
||||
window.show()
|
||||
application.processEvents()
|
||||
|
||||
assert window.assistant_card.isHidden()
|
||||
assert window.ai_top_button.isHidden()
|
||||
assert not window.menu_ai_action.isVisible()
|
||||
assert window.menu_sidebar_action.isVisible()
|
||||
assert window._active_page_key == key
|
||||
assert window.sidebar.width() == 208
|
||||
assert window.topbar.height() == 76
|
||||
assert window.centralWidget().layout().contentsMargins().isNull()
|
||||
|
||||
window.close()
|
||||
application.processEvents()
|
||||
@@ -640,20 +661,135 @@ def test_non_fixed_tabs_close_and_active_close_renavigates(
|
||||
assert shell_window.stack.currentWidget() is shell_window.pages["appointments"]
|
||||
|
||||
|
||||
def test_sidebar_collapse_preserves_active_navigation(
|
||||
def test_topbar_refresh_button_reloads_whichever_page_is_open(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("consultations")
|
||||
"""The button existed in the code but was never added to the layout.
|
||||
|
||||
It also matters more than it used to: list loads no longer raise a banner,
|
||||
so this is the only control that acknowledges a manual reload.
|
||||
"""
|
||||
|
||||
button = shell_window.refresh_button
|
||||
assert button.isVisible()
|
||||
assert button.parentWidget() is shell_window.topbar
|
||||
|
||||
for key in ("consultations", "patients"):
|
||||
assert shell_window.navigate(key)
|
||||
application.processEvents()
|
||||
page = shell_window.pages[key]
|
||||
before = page.refresh_count
|
||||
button.click()
|
||||
application.processEvents()
|
||||
assert page.refresh_count == before + 1
|
||||
|
||||
|
||||
def test_topbar_refresh_button_spins_while_the_page_loads(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
"""A spin that cannot stop is worse than no spin at all."""
|
||||
|
||||
button = shell_window.refresh_button
|
||||
resting = button.icon().cacheKey()
|
||||
|
||||
button.click()
|
||||
application.processEvents()
|
||||
assert button._timer.isActive()
|
||||
|
||||
# The page double never reports itself busy, so the spin ends as soon as the
|
||||
# minimum has elapsed rather than running for the full cap.
|
||||
button._elapsed = _ElapsedStub(button._MIN_MS + 1)
|
||||
button._tick()
|
||||
|
||||
assert not button._timer.isActive()
|
||||
assert button.icon().cacheKey() == resting
|
||||
|
||||
|
||||
class _ElapsedStub:
|
||||
def __init__(self, value: int) -> None:
|
||||
self._value = value
|
||||
|
||||
def elapsed(self) -> int:
|
||||
return self._value
|
||||
|
||||
def restart(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_legacy_window_ground_carries_the_only_corner(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
"""The bottom-most layer is the one that rounds; nothing nests inside it.
|
||||
|
||||
The rail used to paint its own 16 px corner on top of a square canvas, so
|
||||
each window corner showed a corner inside a corner.
|
||||
"""
|
||||
|
||||
assert shell_window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
image = shell_window.grab().toImage()
|
||||
|
||||
# Outside the ground's corner there is nothing at all ...
|
||||
assert _logical_pixel(image, 2, 2).alpha() == 0
|
||||
# ... and well inside it the ground is the flat canvas colour, both in the
|
||||
# outer gutter and inside the rail.
|
||||
assert _logical_pixel(image, 8, 200).name().lower() == COLORS["canvas"].lower()
|
||||
assert _logical_pixel(image, 60, 200).name().lower() == COLORS["canvas"].lower()
|
||||
|
||||
|
||||
def test_approved_pages_share_shell_geometry_and_other_pages_restore(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
"""The six approved pages share chrome; each remaining page keeps its geometry."""
|
||||
|
||||
geometries = set()
|
||||
for key in [*shell_window.pages, "reception", "consultations", "appointments", "patients", "prescriptions", "prescription_library"]:
|
||||
assert shell_window.navigate(key)
|
||||
application.processEvents()
|
||||
geometry = (
|
||||
shell_window.sidebar.width(),
|
||||
shell_window.workspace.x(),
|
||||
shell_window.workspace.width(),
|
||||
shell_window.stack.width(),
|
||||
)
|
||||
if key in {"appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"}:
|
||||
assert geometry == (208, 208, shell_window.width() - 208, shell_window.width() - 208)
|
||||
assert shell_window.topbar.height() == 76
|
||||
assert shell_window.workspace.y() == 0
|
||||
else:
|
||||
assert geometry == (190, 203, shell_window.width() - 216, shell_window.width() - 216)
|
||||
assert shell_window.topbar.height() == 62
|
||||
assert shell_window.workspace.y() == 13
|
||||
geometries.add(geometry)
|
||||
|
||||
assert len(geometries) == 1, f"navigation moved the shell: {geometries}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "expanded_width"),
|
||||
[("appointments", 208), ("consultations", 208), ("reception", 208), ("patients", 208), ("prescriptions", 208), ("prescription_library", 208), ("legacy_reference", 190)],
|
||||
)
|
||||
def test_sidebar_collapse_preserves_active_navigation(
|
||||
shell_window: ShellWindow,
|
||||
key: str,
|
||||
expanded_width: int,
|
||||
) -> None:
|
||||
assert shell_window.navigate(key)
|
||||
title = shell_window.nav_buttons[key].text()
|
||||
|
||||
shell_window.toggle_sidebar()
|
||||
assert shell_window.sidebar.width() == 68
|
||||
assert shell_window.nav_buttons["consultations"].text() == ""
|
||||
assert shell_window.nav_buttons["consultations"].isChecked()
|
||||
assert shell_window.nav_buttons[key].text() == ""
|
||||
assert shell_window.nav_buttons[key].isChecked()
|
||||
|
||||
shell_window.toggle_sidebar()
|
||||
assert shell_window.sidebar.width() == 195
|
||||
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表")
|
||||
assert shell_window.nav_buttons["consultations"].isChecked()
|
||||
assert shell_window.sidebar.width() == expanded_width
|
||||
assert shell_window.nav_buttons[key].text() == title
|
||||
assert shell_window.nav_buttons[key].isChecked()
|
||||
|
||||
|
||||
def test_shell_directional_controls_have_no_unicode_arrow_text(
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""List pages must load without a banner that reflows the page.
|
||||
|
||||
Every list refresh used to raise an info banner ("正在加载…") into the page
|
||||
layout and drop it again on completion. That is two relayouts per refresh - the
|
||||
filter bar and the whole table jump down and back - and the appointment and
|
||||
consultation pages poll every five seconds, so the jump repeated on its own with
|
||||
nobody touching the keyboard.
|
||||
|
||||
Errors still surface on the banner; only the routine load is silent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.pages import appointments as appointments_module
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Run the worker inline so the banner state can be inspected mid-load."""
|
||||
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001 - mirrors the real worker
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
for module in (prescriptions_module, library_module, appointments_module):
|
||||
monkeypatch.setattr(module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _pages(application: QApplication) -> list[Any]:
|
||||
del application
|
||||
repository = DemoDoctorRepository()
|
||||
return [
|
||||
PrescriptionsPage(repository, ["*"]),
|
||||
PrescriptionLibraryPage(
|
||||
repository, ["*"], SimpleNamespace(id=1, root=1, role_ids=[])
|
||||
),
|
||||
AppointmentsPage(repository, PermissionSet(["*"])),
|
||||
]
|
||||
|
||||
|
||||
def test_a_list_refresh_never_raises_a_loading_banner(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
seen: list[tuple[str, str]] = []
|
||||
|
||||
for page in _pages(application):
|
||||
banner = page.banner
|
||||
original = banner.show_message
|
||||
|
||||
def record(text: str, kind: str = "info", _page: Any = page) -> None:
|
||||
seen.append((type(_page).__name__, text))
|
||||
|
||||
banner.show_message = record # type: ignore[method-assign]
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
banner.show_message = original # type: ignore[method-assign]
|
||||
page.close()
|
||||
|
||||
assert seen == [], f"a list refresh still announced itself: {seen}"
|
||||
|
||||
|
||||
def test_a_refresh_does_not_move_anything_on_the_page(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""The real symptom was reflow, so assert on geometry rather than on a flag.
|
||||
|
||||
A hidden widget keeps whatever size it was last given, so checking the
|
||||
banner's own height proves nothing; what matters is that the table below it
|
||||
does not shift when a refresh runs.
|
||||
"""
|
||||
|
||||
for page in _pages(application):
|
||||
page.resize(1200, 700)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
table = page.table
|
||||
settled = (table.mapTo(page, table.rect().topLeft()), table.size())
|
||||
|
||||
for _ in range(3):
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
assert not page.banner.isVisible()
|
||||
assert (table.mapTo(page, table.rect().topLeft()), table.size()) == settled
|
||||
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_a_failed_load_still_reports_on_the_banner(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
"""Silence is only for the happy path; failures must stay visible."""
|
||||
|
||||
class Failing(DemoDoctorRepository):
|
||||
def list_prescriptions(self, **_filters: Any) -> Any:
|
||||
raise RuntimeError("上游不可用")
|
||||
|
||||
page = PrescriptionsPage(Failing(), ["*"])
|
||||
page.resize(1200, 700)
|
||||
page.show()
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
|
||||
assert page.banner.isVisible()
|
||||
assert page.banner.label.text()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
@@ -4,8 +4,16 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QSettings
|
||||
from PySide6.QtGui import QPageSize, QRawFont
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QMessageBox, QVBoxLayout
|
||||
from PySide6.QtGui import QFont, QPageSize, QRawFont
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from doctor_workstation import app as app_module
|
||||
from doctor_workstation.app import ApplicationController
|
||||
@@ -54,12 +62,25 @@ def test_theme_resolves_real_chinese_glyphs() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
raw_font = QRawFont.fromFont(application.font())
|
||||
assert raw_font.familyName() == "Noto Sans SC"
|
||||
assert not application.font().styleStrategy() & QFont.StyleStrategy.NoSubpixelAntialias
|
||||
assert application.font().hintingPreference() == QFont.HintingPreference.PreferDefaultHinting
|
||||
glyphs = raw_font.glyphIndexesForString("甄养堂医生工作站")
|
||||
|
||||
assert glyphs
|
||||
assert all(glyph > 0 for glyph in glyphs)
|
||||
assert len(set(glyphs)) > 1
|
||||
|
||||
# QSS must not override the resolved platform face on real controls.
|
||||
for widget in (QLabel("甄养堂医生工作站"), QPushButton("确认接诊")):
|
||||
widget.ensurePolished()
|
||||
resolved = QRawFont.fromFont(widget.font())
|
||||
assert resolved.familyName() == raw_font.familyName()
|
||||
assert all(glyph > 0 for glyph in resolved.glyphIndexesForString(widget.text()))
|
||||
assert not widget.font().styleStrategy() & QFont.StyleStrategy.NoSubpixelAntialias
|
||||
assert widget.font().hintingPreference() == QFont.HintingPreference.PreferDefaultHinting
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_theme_marks_dynamic_business_dialogs_and_semantic_buttons() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PySide6.QtGui import QPalette
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui.pages import reception as reception_module
|
||||
from doctor_workstation.ui.pages.reception import ReceptionDailyRecordsPanel, ReceptionPage
|
||||
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
def settle(app):
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(application, monkeypatch):
|
||||
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||
widget = ReceptionPage(object(), PermissionSet([]))
|
||||
widget.resize(1080, 760)
|
||||
widget.show()
|
||||
widget.poll_timer.stop()
|
||||
widget.detail_stack.setCurrentIndex(1)
|
||||
settle(application)
|
||||
yield widget
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
settle(application)
|
||||
|
||||
|
||||
def test_reception_prompts_remain_readable_in_a_narrow_workspace(page, application):
|
||||
settle(application)
|
||||
for button in page.ai_prompt_buttons:
|
||||
assert button.width() >= button.minimumSizeHint().width()
|
||||
assert button.parentWidget().rect().contains(button.geometry())
|
||||
analysis = page.ai_analysis_card.geometry()
|
||||
assistant = page.ai_assistant_card.geometry()
|
||||
assert assistant.top() > analysis.bottom()
|
||||
|
||||
|
||||
def test_hidden_tall_consultation_does_not_expand_daily_records(page, application):
|
||||
page.detail_tabs.setCurrentIndex(0)
|
||||
settle(application)
|
||||
consultation_height = page.clinical_pages.height()
|
||||
page.detail_tabs.setCurrentIndex(3)
|
||||
settle(application)
|
||||
assert page.clinical_pages.height() < consultation_height - 80
|
||||
|
||||
|
||||
def test_short_risk_labels_stay_on_one_line(page, application):
|
||||
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
|
||||
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||
settle(application)
|
||||
chip = page.ai_risk_chip_layout.itemAt(0).widget()
|
||||
assert chip.width() >= chip.fontMetrics().horizontalAdvance(chip.text()) + 18
|
||||
|
||||
|
||||
def test_seven_day_matrix_fits_readable_columns_without_unnecessary_horizontal_scroll(application):
|
||||
panel = ReceptionDailyRecordsPanel()
|
||||
panel.resize(900, 580)
|
||||
panel.set_data({}, [])
|
||||
panel.show()
|
||||
settle(application)
|
||||
try:
|
||||
assert panel.matrix.columnCount() == 8
|
||||
assert panel.matrix.horizontalScrollBar().maximum() == 0
|
||||
assert all(panel.matrix.columnWidth(i) >= 96 for i in range(1, 8))
|
||||
panel.range_buttons["30"].click()
|
||||
panel.set_data({}, [])
|
||||
settle(application)
|
||||
assert panel.matrix.columnCount() == 31
|
||||
assert panel.matrix.horizontalScrollBar().maximum() > 0
|
||||
assert panel.matrix.columnWidth(1) >= 96
|
||||
finally:
|
||||
panel.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["secondary", "success", "warning", "danger", "link"])
|
||||
def test_disabled_semantic_buttons_do_not_look_actionable(application, variant):
|
||||
button = QPushButton("查看记录")
|
||||
button.setProperty("variant", variant)
|
||||
button.setEnabled(False)
|
||||
button.ensurePolished()
|
||||
assert button.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText).name() == COLORS["disabled_text"].lower()
|
||||
button.close()
|
||||
@@ -0,0 +1,26 @@
|
||||
# Reception shell typography reduction
|
||||
|
||||
Changed only reception-specific text sizing in `app/src/doctor_workstation/ui/shell.py`, plus the existing matching size assertions in `app/tests/test_reception_chrome.py`.
|
||||
|
||||
| Text | Previous | Updated |
|
||||
| --- | --- | --- |
|
||||
| Brand name | 20 px | 18 px |
|
||||
| Brand subtitle | 14 px | 13 px |
|
||||
| Navigation labels | 17 px | 15 px |
|
||||
| AI assistant title | 18 px | 16 px |
|
||||
| AI assistant CTA | 17 px | 15 px |
|
||||
| Settings label | 17 px | 15 px |
|
||||
| Assistant status metadata | 14 px | 13 px |
|
||||
| Global search text | 17 px | 15 px |
|
||||
| User menu name | 14 px | 13 px |
|
||||
|
||||
The 17 px custom-painted text near line 694 is the avatar initial, not navigation. It remains 17 px. AI tile lettering, shortcut hint, icons, colors, font families, font weights, margins, padding, borders, and every explicit geometry value are unchanged.
|
||||
|
||||
Validation:
|
||||
|
||||
- `QT_QPA_PLATFORM=offscreen .venv/Scripts/python.exe -m pytest tests/test_reception_chrome.py tests/test_shell_contract.py --basetemp artifacts/test-tmp/reception-smaller-chrome-20260905` — 36 passed. Pytest emitted one cache-write permission warning for its pre-existing `.pytest_cache`; test temporary files used the project-local path above.
|
||||
- `.venv/Scripts/python.exe -m ruff check src/doctor_workstation/ui/shell.py tests/test_reception_chrome.py` — passed.
|
||||
- Existing geometry assertions passed unchanged: sidebar 208 px, top bar 76 px, search at (234, 15) sized 384 × 44 px, AI card at (20, 695) sized 168 × 171 px, AI CTA height 46 px, navigation rows height 52 px and 58 px vertical stride, icons 20 × 20 px.
|
||||
- Existing restoration tests verify original page fonts, application stylesheet and font, colors, chrome styles, icons, and effective widget sizes return when navigating to other pages. Minimum-window controls and sidebar collapse behavior also pass.
|
||||
|
||||
No files under packaging, global theme, reception page implementation, or distribution output were modified by this subtask.
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | 企业微信客服桌面端管理员认证接口
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
namespace app\adminapi\controller;
|
||||
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\common\enum\AdminTerminalEnum;
|
||||
use app\common\enum\YesNoEnum;
|
||||
use app\common\model\auth\Admin;
|
||||
use app\common\model\auth\AdminRole;
|
||||
|
||||
/**
|
||||
* 用最小身份信息把 ZYT 管理员账号映射到客服软件租户。
|
||||
*
|
||||
* session 标为免登录是为了跳过管理后台的菜单权限和登录 IP 限制;它并非匿名
|
||||
* 接口,方法内部仍会严格校验 token 以及 token 的桌面终端类型。客服后台从另一
|
||||
* 台服务器验证 token 时,请求 IP 本来就不会等于桌面用户登录时的 IP。
|
||||
*/
|
||||
class DesktopController extends BaseAdminController
|
||||
{
|
||||
public array $notNeedLogin = ['session'];
|
||||
|
||||
public function session()
|
||||
{
|
||||
$identity = (array)($this->request->adminInfo ?? []);
|
||||
if (empty($identity) || empty($identity['admin_id'])) {
|
||||
return $this->fail('登录已失效,请重新登录');
|
||||
}
|
||||
if ((int)($identity['terminal'] ?? 0) !== AdminTerminalEnum::WECOM_RPA) {
|
||||
return $this->fail('当前登录令牌不属于企业微信客服桌面端');
|
||||
}
|
||||
|
||||
$admin = Admin::where(['id' => (int)$identity['admin_id']])
|
||||
->field('id,account,name,avatar,disable,root')
|
||||
->findOrEmpty();
|
||||
if ($admin->isEmpty() || (int)$admin['disable'] === YesNoEnum::YES) {
|
||||
return $this->fail('管理员不存在或已禁用');
|
||||
}
|
||||
|
||||
// 身份和权限在专用接口一次返回,客服后台无需跨 IP 调用网页端 mySelf。
|
||||
$roleIds = array_values(array_unique(array_map('intval',
|
||||
AdminRole::where('admin_id', (int)$admin['id'])->column('role_id')
|
||||
)));
|
||||
sort($roleIds);
|
||||
$permissions = AuthLogic::getBtnAuthByRoleId([
|
||||
'id' => (int)$admin['id'],
|
||||
'root' => (int)$admin['root'],
|
||||
'role_id' => $roleIds,
|
||||
]);
|
||||
|
||||
$account = trim((string)$admin['account']);
|
||||
$name = trim((string)$admin['name']);
|
||||
return $this->data([
|
||||
// 加类型前缀,避免管理员 id 与普通用户 id 相同时落到同一个客服租户。
|
||||
'user_id' => 'admin:' . (int)$admin['id'],
|
||||
'sn' => $account,
|
||||
'nickname' => $name !== '' ? $name : $account,
|
||||
'mobile' => '',
|
||||
'avatar' => (string)$admin['avatar'],
|
||||
'terminal' => AdminTerminalEnum::WECOM_RPA,
|
||||
'expire_time' => (int)($identity['expire_time'] ?? 0),
|
||||
'status' => 'active',
|
||||
'account_type' => 'admin',
|
||||
'root' => (int)$admin['root'] === YesNoEnum::YES,
|
||||
'role_ids' => $roleIds,
|
||||
'permissions' => array_values($permissions),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -359,9 +359,37 @@ class WecomPromotionLogic
|
||||
$automation,
|
||||
$skipVerify,
|
||||
$createdRemote,
|
||||
&$eligibleUserIds,
|
||||
$now,
|
||||
$adminId
|
||||
): void {
|
||||
if ($existingPool !== null) {
|
||||
$lockedPool = Db::name('qywx_promotion_pool')
|
||||
->where('id', $id)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$lockedPool) {
|
||||
throw new RuntimeException('分流方案不存在或已删除');
|
||||
}
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $id)->lock(true)->find();
|
||||
$lockedRules = Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $id)
|
||||
->order('id', 'asc')
|
||||
->lock(true)
|
||||
->select()->toArray();
|
||||
$lockedActiveRules = array_values(array_filter(
|
||||
$lockedRules,
|
||||
static fn (array $rule): bool => ($rule['delete_time'] ?? null) === null
|
||||
));
|
||||
// 用锁内最新状态再校验最终成员集合,避免与单个或批量下线并发后留下空范围。
|
||||
$eligibleUserIds = self::eligibleSelectedUserIds(
|
||||
$id,
|
||||
$members,
|
||||
$automation ?? [],
|
||||
$lockedActiveRules
|
||||
);
|
||||
}
|
||||
if ($existingPool === null) {
|
||||
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($poolData + [
|
||||
'public_key' => bin2hex(random_bytes(16)),
|
||||
@@ -428,7 +456,13 @@ class WecomPromotionLogic
|
||||
}
|
||||
|
||||
$syncError = '';
|
||||
if (!$createdRemote) {
|
||||
// 已有方案补建官方链接时,创建接口发生在事务锁之前;若锁内成员范围已变化,
|
||||
// 仍需把最终范围加入同步队列,避免新链接停留在过期的远端成员集合。
|
||||
$createdRangeChanged = $createdRemote
|
||||
&& $remote !== null
|
||||
&& !QywxPromotionMemberRange::same((array) ($remote['range_userids'] ?? []), $eligibleUserIds);
|
||||
$needsQueuedSync = !$createdRemote || $createdRangeChanged;
|
||||
if ($needsQueuedSync) {
|
||||
QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId);
|
||||
if ($syncImmediately) {
|
||||
try {
|
||||
@@ -455,15 +489,15 @@ class WecomPromotionLogic
|
||||
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
|
||||
'automation_saved' => $automation !== null,
|
||||
'sync_error' => $syncError,
|
||||
'sync_queued' => !$createdRemote && !$syncImmediately,
|
||||
'sync_queued' => $needsQueuedSync && !$syncImmediately,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量局部更新分流方案。changes 只覆盖显式传入的字段;每个方案仍复用
|
||||
* savePool 的成员、自动化、素材和企业微信同步校验。
|
||||
* 批量局部更新分流方案。changes 只覆盖显式传入的字段;方案配置仍复用
|
||||
* savePool 的成员、自动化、素材和企业微信同步校验,员工状态按方案合并更新。
|
||||
*
|
||||
* @return array{pool_ids:list<int>,updated:int,failed:int,sync_error_count:int,sync_queued_count:int,results:list<array<string,mixed>>}
|
||||
* @return array{pool_ids:list<int>,updated:int,failed:int,sync_error_count:int,sync_queued_count:int,member_matched:int,member_updated:int,results:list<array<string,mixed>>}
|
||||
*/
|
||||
public static function batchUpdatePools(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
@@ -481,7 +515,7 @@ class WecomPromotionLogic
|
||||
if (!is_array($changes)) {
|
||||
throw new RuntimeException('批量修改内容格式不正确');
|
||||
}
|
||||
$allowedFields = ['skip_verify', 'fallback_url', 'status', 'automation_config'];
|
||||
$allowedFields = ['skip_verify', 'fallback_url', 'status', 'automation_config', 'member_status'];
|
||||
$unknownFields = array_diff(array_keys($changes), $allowedFields);
|
||||
if ($unknownFields !== []) {
|
||||
throw new RuntimeException('批量修改包含不支持的字段');
|
||||
@@ -493,6 +527,31 @@ class WecomPromotionLogic
|
||||
throw new RuntimeException('兜底获客助手链接格式不正确');
|
||||
}
|
||||
|
||||
$memberStatusPatch = null;
|
||||
if (array_key_exists('member_status', $changes)) {
|
||||
if (!is_array($changes['member_status'])) {
|
||||
throw new RuntimeException('员工上下线配置格式不正确');
|
||||
}
|
||||
if (array_diff(array_keys($changes['member_status']), ['member_admin_ids', 'status']) !== []) {
|
||||
throw new RuntimeException('员工上下线配置包含不支持的字段');
|
||||
}
|
||||
$memberAdminIds = self::normalizePositiveIds((array) ($changes['member_status']['member_admin_ids'] ?? []));
|
||||
if ($memberAdminIds === []) {
|
||||
throw new RuntimeException('请至少选择一名需要批量上线或下线的员工');
|
||||
}
|
||||
if (count($memberAdminIds) > 100) {
|
||||
throw new RuntimeException('单次最多设置 100 名员工');
|
||||
}
|
||||
$memberStatus = $changes['member_status']['status'] ?? null;
|
||||
if (!in_array($memberStatus, [0, 1, '0', '1'], true)) {
|
||||
throw new RuntimeException('员工上线状态只能为上线或下线');
|
||||
}
|
||||
$memberStatusPatch = [
|
||||
'member_admin_ids' => $memberAdminIds,
|
||||
'status' => (int) $memberStatus,
|
||||
];
|
||||
}
|
||||
|
||||
$automationPatch = null;
|
||||
if (array_key_exists('automation_config', $changes)) {
|
||||
if (!is_array($changes['automation_config']) || $changes['automation_config'] === []) {
|
||||
@@ -510,6 +569,9 @@ class WecomPromotionLogic
|
||||
QywxPromotionConfig::assertInstalled();
|
||||
$automationPatch = $changes['automation_config'];
|
||||
}
|
||||
if ($memberStatusPatch !== null && count($changes) > 1) {
|
||||
throw new RuntimeException('员工上下线需要单独批量保存,请勿与其他方案配置同时修改');
|
||||
}
|
||||
|
||||
// 必须在任何方案写入前完成整批权限校验,避免越权请求产生部分更新。
|
||||
$pools = [];
|
||||
@@ -523,11 +585,48 @@ class WecomPromotionLogic
|
||||
);
|
||||
}
|
||||
|
||||
// 在任何方案写入前验证员工范围和“至少一名上线员工”约束,避免可预见的部分更新。
|
||||
if ($memberStatusPatch !== null) {
|
||||
$memberRowsByPool = [];
|
||||
foreach (Db::name('qywx_promotion_pool_member')
|
||||
->whereIn('pool_id', $poolIds)
|
||||
->whereNull('delete_time')
|
||||
->select()->toArray() as $memberRow) {
|
||||
$memberRowsByPool[(int) ($memberRow['pool_id'] ?? 0)][] = $memberRow;
|
||||
}
|
||||
$matchedMembers = 0;
|
||||
foreach ($poolIds as $poolId) {
|
||||
$simulatedAutomation = QywxPromotionConfig::forPool($poolId);
|
||||
if ($automationPatch !== null) {
|
||||
$simulatedAutomation = QywxPromotionConfig::normalize(array_replace(
|
||||
$simulatedAutomation,
|
||||
$automationPatch
|
||||
));
|
||||
}
|
||||
$poolMembers = $memberRowsByPool[$poolId] ?? [];
|
||||
$simulatedAutomation = self::automationWithMemberUserIds($simulatedAutomation, $poolMembers);
|
||||
$preview = self::previewPoolMemberStatus(
|
||||
$poolMembers,
|
||||
$memberStatusPatch['member_admin_ids'],
|
||||
$memberStatusPatch['status'],
|
||||
$simulatedAutomation,
|
||||
(string) ($pools[$poolId]['name'] ?? ('#' . $poolId))
|
||||
);
|
||||
$matchedMembers += count($preview['matched_ids']);
|
||||
}
|
||||
if ($matchedMembers === 0) {
|
||||
throw new RuntimeException('所选方案中没有找到指定员工,请刷新页面后重试');
|
||||
}
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$updated = 0;
|
||||
$failed = 0;
|
||||
$syncErrorCount = 0;
|
||||
$syncQueuedCount = 0;
|
||||
$memberMatched = 0;
|
||||
$memberUpdated = 0;
|
||||
$hasPoolConfigChanges = array_diff(array_keys($changes), ['member_status']) !== [];
|
||||
foreach ($poolIds as $poolId) {
|
||||
$pool = $pools[$poolId];
|
||||
$currentAutomation = QywxPromotionConfig::forPool($poolId);
|
||||
@@ -565,14 +664,28 @@ class WecomPromotionLogic
|
||||
}
|
||||
|
||||
try {
|
||||
// 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。
|
||||
$saved = self::savePool($saveParams, $adminId, $adminInfo, false);
|
||||
$saved = ['sync_error' => '', 'sync_queued' => false];
|
||||
if ($hasPoolConfigChanges) {
|
||||
// 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。
|
||||
$saved = self::savePool($saveParams, $adminId, $adminInfo, false);
|
||||
}
|
||||
$poolMemberResult = ['matched' => 0, 'updated' => 0, 'dispatch' => null];
|
||||
if ($memberStatusPatch !== null) {
|
||||
$poolMemberResult = self::updatePoolMemberStatuses(
|
||||
$poolId,
|
||||
$memberStatusPatch['member_admin_ids'],
|
||||
$memberStatusPatch['status']
|
||||
);
|
||||
$memberMatched += $poolMemberResult['matched'];
|
||||
$memberUpdated += $poolMemberResult['updated'];
|
||||
}
|
||||
$syncError = trim((string) ($saved['sync_error'] ?? ''));
|
||||
$updated++;
|
||||
if ($syncError !== '') {
|
||||
$syncErrorCount++;
|
||||
}
|
||||
$syncQueued = !empty($saved['sync_queued']);
|
||||
$syncQueued = !empty($saved['sync_queued'])
|
||||
|| !empty($poolMemberResult['dispatch']['queued']);
|
||||
if ($syncQueued) {
|
||||
$syncQueuedCount++;
|
||||
}
|
||||
@@ -582,6 +695,8 @@ class WecomPromotionLogic
|
||||
'success' => true,
|
||||
'sync_error' => $syncError,
|
||||
'sync_queued' => $syncQueued,
|
||||
'member_matched' => $poolMemberResult['matched'],
|
||||
'member_updated' => $poolMemberResult['updated'],
|
||||
];
|
||||
} catch (\Throwable $error) {
|
||||
$failed++;
|
||||
@@ -600,10 +715,156 @@ class WecomPromotionLogic
|
||||
'failed' => $failed,
|
||||
'sync_error_count' => $syncErrorCount,
|
||||
'sync_queued_count' => $syncQueuedCount,
|
||||
'member_matched' => $memberMatched,
|
||||
'member_updated' => $memberUpdated,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $memberAdminIds
|
||||
* @return array{matched:int,updated:int,dispatch:?array}
|
||||
*/
|
||||
private static function updatePoolMemberStatuses(int $poolId, array $memberAdminIds, int $status): array
|
||||
{
|
||||
return Db::transaction(function () use ($poolId, $memberAdminIds, $status): array {
|
||||
// 先锁方案阻止同方案获客回调进入,再按“同步任务 -> 成员规则”顺序加锁。
|
||||
$pool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$pool) {
|
||||
throw new RuntimeException('分流方案不存在或已删除');
|
||||
}
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
|
||||
$members = Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)
|
||||
->whereNull('delete_time')
|
||||
->lock(true)
|
||||
->select()->toArray();
|
||||
$preview = self::previewPoolMemberStatus(
|
||||
$members,
|
||||
$memberAdminIds,
|
||||
$status,
|
||||
QywxPromotionConfig::forPool($poolId)
|
||||
);
|
||||
$matchedIds = $preview['matched_ids'];
|
||||
$updateIds = $preview['update_ids'];
|
||||
if ($updateIds !== []) {
|
||||
Db::name('qywx_promotion_pool_member')->whereIn('id', $updateIds)->update([
|
||||
'enabled' => $status,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
$dispatch = $updateIds !== []
|
||||
? QywxPromotionMemberSchedulerService::reconcilePool($poolId)
|
||||
: null;
|
||||
// 下线必须能够安全收缩远端范围;上线即使尚未到生效时段,也应先保存规则。
|
||||
if ($dispatch !== null && $status === 0) {
|
||||
self::assertMemberDispatchReady($dispatch);
|
||||
}
|
||||
|
||||
return [
|
||||
'matched' => count($matchedIds),
|
||||
'updated' => count($updateIds),
|
||||
'dispatch' => $dispatch,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $members
|
||||
* @param list<int> $memberAdminIds
|
||||
* @param array<string,mixed> $automation
|
||||
* @return array{members:list<array<string,mixed>>,matched_ids:list<int>,update_ids:list<int>}
|
||||
*/
|
||||
private static function previewPoolMemberStatus(
|
||||
array $members,
|
||||
array $memberAdminIds,
|
||||
int $status,
|
||||
array $automation,
|
||||
string $poolName = ''
|
||||
): array {
|
||||
$targetAdminIds = array_fill_keys($memberAdminIds, true);
|
||||
$matchedIds = [];
|
||||
$updateIds = [];
|
||||
$targetedEnabled = 0;
|
||||
$remainingEnabled = 0;
|
||||
foreach ($members as &$member) {
|
||||
$isTarget = isset($targetAdminIds[(int) ($member['admin_id'] ?? 0)]);
|
||||
if ($isTarget) {
|
||||
$matchedIds[] = (int) ($member['id'] ?? 0);
|
||||
if ((int) ($member['enabled'] ?? 0) !== $status) {
|
||||
$updateIds[] = (int) ($member['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
if ((int) ($member['enabled'] ?? 0) === 1) {
|
||||
if ($isTarget) {
|
||||
$targetedEnabled++;
|
||||
} else {
|
||||
$remainingEnabled++;
|
||||
}
|
||||
}
|
||||
if ($isTarget) {
|
||||
$member['enabled'] = $status;
|
||||
}
|
||||
}
|
||||
unset($member);
|
||||
if ($status === 0 && $targetedEnabled > 0 && $remainingEnabled === 0) {
|
||||
throw new RuntimeException(self::memberStatusError(
|
||||
$poolName,
|
||||
'至少需要保留一名上线员工'
|
||||
));
|
||||
}
|
||||
if ($status === 0 && $targetedEnabled > 0) {
|
||||
$range = QywxPromotionMemberRange::evaluate($members, date('Y-m-d'), time(), $automation);
|
||||
if ($range['userids'] === []) {
|
||||
throw new RuntimeException(self::memberStatusError(
|
||||
$poolName,
|
||||
'至少需要保留一名当前可用的上线员工'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return ['members' => $members, 'matched_ids' => $matchedIds, 'update_ids' => $updateIds];
|
||||
}
|
||||
|
||||
private static function memberStatusError(string $poolName, string $message): string
|
||||
{
|
||||
return $poolName === '' ? $message : sprintf('分流方案“%s”%s', $poolName, $message);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $dispatch */
|
||||
private static function assertMemberDispatchReady(array $dispatch): void
|
||||
{
|
||||
if (!empty($dispatch['blocked'])) {
|
||||
throw new RuntimeException('企业微信成员范围当前无法同步,请先检查官方链接状态和可用员工');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $automation @param list<array<string,mixed>> $members */
|
||||
private static function automationWithMemberUserIds(array $automation, array $members): array
|
||||
{
|
||||
$userIdByAdminId = [];
|
||||
foreach ($members as $member) {
|
||||
$adminId = (int) ($member['admin_id'] ?? 0);
|
||||
$userId = trim((string) ($member['userid'] ?? ''));
|
||||
if ($adminId > 0 && $userId !== '') {
|
||||
$userIdByAdminId[$adminId] = $userId;
|
||||
}
|
||||
}
|
||||
$automation['backup_userids'] = array_values(array_filter(array_map(
|
||||
static fn ($adminId): string => $userIdByAdminId[(int) $adminId] ?? '',
|
||||
(array) ($automation['backup_member_admin_ids'] ?? [])
|
||||
)));
|
||||
foreach ((array) ($automation['reception_schedule'] ?? []) as $index => $slot) {
|
||||
$automation['reception_schedule'][$index]['member_userids'] = array_values(array_filter(array_map(
|
||||
static fn ($adminId): string => $userIdByAdminId[(int) $adminId] ?? '',
|
||||
(array) ($slot['member_admin_ids'] ?? [])
|
||||
)));
|
||||
}
|
||||
|
||||
return $automation;
|
||||
}
|
||||
|
||||
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['pool_id'] ?? $params['id'] ?? 0));
|
||||
@@ -877,36 +1138,93 @@ class WecomPromotionLogic
|
||||
$poolId = (int) $member['pool_id'];
|
||||
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$enabled = (int) ($params['status'] ?? $params['enabled'] ?? 1) === 1 ? 1 : 0;
|
||||
if ($enabled === 0) {
|
||||
$otherEnabled = (int) Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)
|
||||
->where('id', '<>', $id)
|
||||
->where('enabled', 1)
|
||||
->whereNull('delete_time')
|
||||
->count();
|
||||
if ($otherEnabled <= 0) {
|
||||
throw new RuntimeException('至少需要保留一名启用的获客医助');
|
||||
}
|
||||
}
|
||||
$startAt = self::parseTime($params['active_start'] ?? null);
|
||||
$endAt = self::parseTime($params['active_end'] ?? null);
|
||||
$statusOnly = !empty($params['_status_only']);
|
||||
$startAt = $statusOnly ? 0 : self::parseTime($params['active_start'] ?? null);
|
||||
$endAt = $statusOnly ? 0 : self::parseTime($params['active_end'] ?? null);
|
||||
if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) {
|
||||
throw new RuntimeException('生效结束时间必须晚于开始时间');
|
||||
}
|
||||
$now = time();
|
||||
Db::transaction(function () use ($id, $enabled, $params, $startAt, $endAt, $now): void {
|
||||
Db::name('qywx_promotion_pool_member')->where('id', $id)->update([
|
||||
$planned = Db::transaction(function () use (
|
||||
$id,
|
||||
$poolId,
|
||||
$enabled,
|
||||
$statusOnly,
|
||||
$params,
|
||||
$startAt,
|
||||
$endAt,
|
||||
$now
|
||||
): array {
|
||||
// 所有成员状态写入口使用相同锁顺序,防止单个与批量下线并发绕过保底校验。
|
||||
$lockedPool = Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$lockedPool) {
|
||||
throw new RuntimeException('分流方案不存在或已删除');
|
||||
}
|
||||
Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->lock(true)->find();
|
||||
$members = Db::name('qywx_promotion_pool_member')
|
||||
->where('pool_id', $poolId)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->lock(true)
|
||||
->select()->toArray();
|
||||
$lockedMember = null;
|
||||
foreach ($members as $current) {
|
||||
if ((int) ($current['id'] ?? 0) === $id) {
|
||||
$lockedMember = $current;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($lockedMember === null) {
|
||||
throw new RuntimeException('分流成员不存在或已移除');
|
||||
}
|
||||
$memberData = [
|
||||
'enabled' => $enabled,
|
||||
// 企业微信原生多人路由不支持逐成员权重;字段固定为 1,仅兼容已部署表结构。
|
||||
'weight' => 1,
|
||||
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
|
||||
'active_start' => $startAt,
|
||||
'active_end' => $endAt,
|
||||
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
|
||||
'daily_limit' => $statusOnly
|
||||
? (int) ($lockedMember['daily_limit'] ?? 0)
|
||||
: min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
|
||||
'active_start' => $statusOnly ? (int) ($lockedMember['active_start'] ?? 0) : $startAt,
|
||||
'active_end' => $statusOnly ? (int) ($lockedMember['active_end'] ?? 0) : $endAt,
|
||||
'remark' => $statusOnly
|
||||
? (string) ($lockedMember['remark'] ?? '')
|
||||
: mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
];
|
||||
$isGoingOffline = (int) ($lockedMember['enabled'] ?? 0) === 1 && $enabled === 0;
|
||||
$simulatedMembers = $members;
|
||||
$enabledAfter = 0;
|
||||
foreach ($simulatedMembers as &$simulatedMember) {
|
||||
if ((int) ($simulatedMember['id'] ?? 0) === $id) {
|
||||
$simulatedMember = array_replace($simulatedMember, $memberData);
|
||||
}
|
||||
if ((int) ($simulatedMember['enabled'] ?? 0) === 1) {
|
||||
$enabledAfter++;
|
||||
}
|
||||
}
|
||||
unset($simulatedMember);
|
||||
if ($isGoingOffline) {
|
||||
if ($enabledAfter === 0) {
|
||||
throw new RuntimeException('至少需要保留一名启用的获客医助');
|
||||
}
|
||||
}
|
||||
$today = date('Y-m-d', $now);
|
||||
$automation = QywxPromotionConfig::forPool($poolId);
|
||||
$currentRange = QywxPromotionMemberRange::evaluate($members, $today, $now, $automation);
|
||||
$nextRange = QywxPromotionMemberRange::evaluate($simulatedMembers, $today, $now, $automation);
|
||||
$rangeShrank = array_diff($currentRange['userids'], $nextRange['userids']) !== [];
|
||||
if (($isGoingOffline || $rangeShrank) && $nextRange['userids'] === []) {
|
||||
throw new RuntimeException('至少需要保留一名当前可用的上线员工');
|
||||
}
|
||||
Db::name('qywx_promotion_pool_member')->where('id', $id)->whereNull('delete_time')->update($memberData);
|
||||
|
||||
$dispatch = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
|
||||
if ($isGoingOffline || $rangeShrank) {
|
||||
self::assertMemberDispatchReady($dispatch);
|
||||
}
|
||||
|
||||
return $dispatch;
|
||||
});
|
||||
$planned = QywxPromotionMemberSchedulerService::reconcilePool($poolId);
|
||||
$syncError = '';
|
||||
if ($planned['queued']) {
|
||||
try {
|
||||
@@ -931,10 +1249,7 @@ class WecomPromotionLogic
|
||||
return self::saveMember([
|
||||
'id' => $id,
|
||||
'status' => $status,
|
||||
'daily_limit' => (int) ($row['daily_limit'] ?? 0),
|
||||
'active_start' => (int) ($row['active_start'] ?? 0),
|
||||
'active_end' => (int) ($row['active_end'] ?? 0),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
'_status_only' => true,
|
||||
], $adminId, $adminInfo);
|
||||
}
|
||||
|
||||
@@ -1274,14 +1589,19 @@ class WecomPromotionLogic
|
||||
}
|
||||
|
||||
/** @param list<array{id:int,userid:string}> $members @return list<string> */
|
||||
private static function eligibleSelectedUserIds(int $poolId, array $members, array $config = []): array
|
||||
private static function eligibleSelectedUserIds(
|
||||
int $poolId,
|
||||
array $members,
|
||||
array $config = [],
|
||||
?array $existingRules = null
|
||||
): array
|
||||
{
|
||||
if ($members === []) {
|
||||
throw new RuntimeException('请至少选择一名获客成员');
|
||||
}
|
||||
$rules = $poolId > 0
|
||||
$rules = $existingRules ?? ($poolId > 0
|
||||
? Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->select()->toArray()
|
||||
: [];
|
||||
: []);
|
||||
$rulesByUserId = [];
|
||||
foreach ($rules as $rule) {
|
||||
$rulesByUserId[(string) $rule['userid']] = $rule;
|
||||
|
||||
@@ -9,6 +9,7 @@ use app\common\model\auth\Admin;
|
||||
use app\common\model\QywxExternalContact;
|
||||
use app\common\model\QywxSyncSettings;
|
||||
use app\common\service\qywx\MediaChannelService;
|
||||
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
|
||||
use app\common\service\wechat\WechatWorkService;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Db;
|
||||
@@ -589,7 +590,11 @@ class CustomerLogic extends BaseLogic
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92130
|
||||
*/
|
||||
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
|
||||
public static function upsertSingleExternalContactFromApi(
|
||||
string $externalUserId,
|
||||
int $snapshotEventId = 0,
|
||||
string $snapshotFollowUserId = ''
|
||||
): void
|
||||
{
|
||||
$externalUserId = trim($externalUserId);
|
||||
if ($externalUserId === '') {
|
||||
@@ -652,6 +657,13 @@ class CustomerLogic extends BaseLogic
|
||||
$updateCount,
|
||||
$skippedCount
|
||||
);
|
||||
if ($snapshotEventId > 0 && $snapshotFollowUserId !== '') {
|
||||
QywxExternalContactEventTagSnapshotService::captureFromFollowUsers(
|
||||
$snapshotEventId,
|
||||
$snapshotFollowUserId,
|
||||
$followUsers
|
||||
);
|
||||
}
|
||||
MediaChannelService::forgetCurrentTagCatalogCache();
|
||||
}
|
||||
|
||||
@@ -674,12 +686,12 @@ class CustomerLogic extends BaseLogic
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function recordExternalContactEvent(array $data): void
|
||||
public static function recordExternalContactEvent(array $data): int
|
||||
{
|
||||
$changeType = (string) ($data['change_type'] ?? '');
|
||||
if ($changeType === '') {
|
||||
// 没有 ChangeType 的事件流水没有价值,直接丢弃
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
$eventTime = (int) ($data['event_time'] ?? 0);
|
||||
@@ -716,9 +728,18 @@ class CustomerLogic extends BaseLogic
|
||||
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES ('
|
||||
. implode(',', array_fill(0, count($cols), '?')) . ')';
|
||||
Db::execute($sql, array_values($row));
|
||||
|
||||
return (int) Db::name('qywx_external_contact_event')
|
||||
->where('change_type', $row['change_type'])
|
||||
->where('user_id', $row['user_id'])
|
||||
->where('external_userid', $row['external_userid'])
|
||||
->where('event_time', $row['event_time'])
|
||||
->value('id');
|
||||
} catch (\Throwable $e) {
|
||||
// 事件流水只用于统计,失败只记日志不阻塞主回调
|
||||
Log::warning('qywx external contact event insert failed: ' . $e->getMessage());
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,8 +340,8 @@ class ConversionLogic
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the distinct external contacts behind an add_fans_count row.
|
||||
/**
|
||||
* Return the add events behind an add_fans_count row.
|
||||
*
|
||||
* The caller must pass the already-authorized admin range and the exact
|
||||
* department ids represented by the clicked tree node. This keeps the
|
||||
@@ -505,7 +505,7 @@ class ConversionLogic
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$pairs = self::loadFanDetailRows(
|
||||
$pairs = self::loadFanDetailRows(
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
$mediaChannel,
|
||||
@@ -515,15 +515,20 @@ class ConversionLogic
|
||||
);
|
||||
// Target membership has already been resolved before the event query,
|
||||
// so sorting and pagination operate on the clicked row's small set.
|
||||
$matched = $pairs;
|
||||
$matched = $pairs;
|
||||
|
||||
usort($matched, static function (array $left, array $right): int {
|
||||
$timeCompare = ((int) ($right['add_time'] ?? 0)) <=> ((int) ($left['add_time'] ?? 0));
|
||||
if ($timeCompare !== 0) {
|
||||
return $timeCompare;
|
||||
}
|
||||
|
||||
return strcmp(
|
||||
if ($timeCompare !== 0) {
|
||||
return $timeCompare;
|
||||
}
|
||||
|
||||
$eventCompare = ((int) ($right['add_event_id'] ?? 0)) <=> ((int) ($left['add_event_id'] ?? 0));
|
||||
if ($eventCompare !== 0) {
|
||||
return $eventCompare;
|
||||
}
|
||||
|
||||
return strcmp(
|
||||
(string) ($left['external_userid'] ?? ''),
|
||||
(string) ($right['external_userid'] ?? '')
|
||||
) ?: strcmp(
|
||||
@@ -552,45 +557,45 @@ class ConversionLogic
|
||||
$pageRows,
|
||||
static fn (array $row): bool => !empty($row['is_deleted'])
|
||||
));
|
||||
$deleteTimesByPair = [];
|
||||
if ($deletedPageRows !== []) {
|
||||
$deletedUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedEvents = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'del_external_contact')
|
||||
->where('event_time', '<=', $endTimestamp)
|
||||
->whereIn('user_id', $deletedUserIds)
|
||||
->whereIn('external_userid', $deletedExternalUserIds)
|
||||
->field('user_id,external_userid,MAX(event_time) AS delete_time')
|
||||
->group('user_id,external_userid')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($deletedEvents as $deletedEvent) {
|
||||
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
|
||||
. "\0"
|
||||
. trim((string) ($deletedEvent['external_userid'] ?? ''));
|
||||
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
|
||||
}
|
||||
}
|
||||
$deleteTimesByPair = [];
|
||||
if ($deletedPageRows !== []) {
|
||||
$deletedUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedExternalUserIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['external_userid'] ?? '')),
|
||||
$deletedPageRows
|
||||
))));
|
||||
$deletedEvents = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'del_external_contact')
|
||||
->where('event_time', '<=', $endTimestamp)
|
||||
->whereIn('user_id', $deletedUserIds)
|
||||
->whereIn('external_userid', $deletedExternalUserIds)
|
||||
->field('user_id,external_userid,MAX(event_time) AS delete_time')
|
||||
->group('user_id,external_userid')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($deletedEvents as $deletedEvent) {
|
||||
$key = trim((string) ($deletedEvent['user_id'] ?? ''))
|
||||
. "\0"
|
||||
. trim((string) ($deletedEvent['external_userid'] ?? ''));
|
||||
$deleteTimesByPair[$key] = max(0, (int) ($deletedEvent['delete_time'] ?? 0));
|
||||
}
|
||||
}
|
||||
$staffNames = self::resolveQywxUserNames(array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['user_id'] ?? '')),
|
||||
$pageRows
|
||||
)))));
|
||||
|
||||
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
|
||||
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
|
||||
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
|
||||
$addTime = max(0, (int) ($row['add_time'] ?? 0));
|
||||
$deleted = !empty($row['is_deleted']);
|
||||
$deleteTime = $deleted
|
||||
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
|
||||
: 0;
|
||||
$lists = array_map(static function (array $row) use ($customerNames, $staffNames, $deleteTimesByPair): array {
|
||||
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
|
||||
$wecomUserId = trim((string) ($row['user_id'] ?? ''));
|
||||
$addTime = max(0, (int) ($row['add_time'] ?? 0));
|
||||
$deleted = !empty($row['is_deleted']);
|
||||
$deleteTime = $deleted
|
||||
? max(0, (int) ($deleteTimesByPair[$wecomUserId . "\0" . $externalUserId] ?? 0))
|
||||
: 0;
|
||||
if ($deleteTime < $addTime) {
|
||||
$deleteTime = 0;
|
||||
}
|
||||
@@ -606,18 +611,18 @@ class ConversionLogic
|
||||
];
|
||||
}, $pageRows);
|
||||
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'deleted_count' => $deletedCount,
|
||||
return [
|
||||
'lists' => $lists,
|
||||
'count' => $count,
|
||||
'deleted_count' => $deletedCount,
|
||||
'page_no' => $pageNo,
|
||||
'page_size' => $pageSize,
|
||||
'date_range' => [$startDate, $endDate],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Department scope imposed by an actively maintained media-channel cost
|
||||
'date_range' => [$startDate, $endDate],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Department scope imposed by an actively maintained media-channel cost
|
||||
* binding. null means the channel has no binding and therefore does not
|
||||
* restrict business statistics.
|
||||
*
|
||||
@@ -1262,17 +1267,17 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
|
||||
*
|
||||
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数):
|
||||
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
|
||||
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间,
|
||||
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计);
|
||||
/**
|
||||
* 区间新增客户关系:按企微员工聚合后,再投影到部门/成员/虚拟桶。
|
||||
*
|
||||
* 口径:
|
||||
* - 以 (user_id, external_userid) 作为唯一组合,同一员工的同一客户只计一次;
|
||||
* - 同一客户添加到不同员工名下时,因 user_id 不同,每名员工分别计一次;
|
||||
* - 同一组合在区间开始前已经产生过 add_external_contact 时,不再算区间新增;
|
||||
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
|
||||
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
|
||||
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除),仍计入加粉总数,
|
||||
* 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||
* - 组合在统计结束时已删除,仍计入加粉总数,并额外计入已删提示子集;
|
||||
* 不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
|
||||
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
|
||||
*
|
||||
@@ -1285,25 +1290,21 @@ class ConversionLogic
|
||||
int $endTimestamp,
|
||||
?array $mediaChannel,
|
||||
?array $adminIds = null
|
||||
): array {
|
||||
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
|
||||
$effectivePairs = array_values(array_filter(
|
||||
$detailRows,
|
||||
static fn (array $row): bool => empty($row['is_deleted'])
|
||||
));
|
||||
|
||||
return self::buildFanCountRows($detailRows, $effectivePairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the exact distinct (user_id, external_userid) pairs represented by
|
||||
* add_fans_count. Both aggregate statistics and the detail endpoint consume
|
||||
* this method, so channel, add-way, prior-add and deletion rules cannot
|
||||
* drift apart.
|
||||
): array {
|
||||
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds);
|
||||
|
||||
return self::buildFanCountRows($detailRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the exact distinct (user_id, external_userid) pairs represented by
|
||||
* add_fans_count. Both aggregate statistics and the detail endpoint consume
|
||||
* this method, so channel, add-way, prior-add and deletion rules cannot
|
||||
* drift apart.
|
||||
*
|
||||
* @param array<string, mixed>|null $mediaChannel
|
||||
* @param int[]|null $adminIds
|
||||
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function loadFanDetailRows(
|
||||
int $startTimestamp,
|
||||
@@ -1335,7 +1336,7 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [
|
||||
$baseKey = self::requestRowsCacheKey('fans-detail-v3-employee-customer', [
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
@@ -1379,23 +1380,56 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->where('e.user_id', '<>', '')
|
||||
->where('e.external_userid', '<>', '')
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
|
||||
. ' WHERE prev_e.user_id = e.user_id'
|
||||
. ' AND prev_e.external_userid = e.external_userid'
|
||||
. ' AND prev_e.change_type = ?'
|
||||
. ' AND prev_e.event_time < ?)',
|
||||
['add_external_contact', $startTimestamp]
|
||||
)
|
||||
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time')
|
||||
->group('e.user_id, e.external_userid');
|
||||
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
|
||||
$livePairPredicate =
|
||||
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time <= ?'
|
||||
. ' AND (surviving_del.event_time > surviving_e.event_time'
|
||||
. ' OR (surviving_del.event_time = surviving_e.event_time'
|
||||
. ' AND surviving_del.id > surviving_e.id))))';
|
||||
$livePairBindings = [
|
||||
'add_external_contact',
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
'del_external_contact',
|
||||
$endTimestamp,
|
||||
];
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->where('e.user_id', '<>', '')
|
||||
->where('e.external_userid', '<>', '')
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` prev_e'
|
||||
. ' WHERE prev_e.user_id = e.user_id'
|
||||
. ' AND prev_e.external_userid = e.external_userid'
|
||||
. ' AND prev_e.change_type = ?'
|
||||
. ' AND prev_e.event_time < ?)',
|
||||
['add_external_contact', $startTimestamp]
|
||||
)
|
||||
// 同一员工+客户只保留区间内最早事件;不同员工拥有不同 user_id,会分别保留。
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` earlier_e'
|
||||
. ' WHERE earlier_e.user_id = e.user_id'
|
||||
. ' AND earlier_e.external_userid = e.external_userid'
|
||||
. ' AND earlier_e.change_type = ?'
|
||||
. ' AND earlier_e.event_time >= ?'
|
||||
. ' AND earlier_e.event_time <= ?'
|
||||
. ' AND (earlier_e.event_time < e.event_time'
|
||||
. ' OR (earlier_e.event_time = e.event_time AND earlier_e.id < e.id)))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp]
|
||||
)
|
||||
->field('e.id AS add_event_id,e.user_id,e.external_userid,e.event_time AS add_time');
|
||||
if ($workWechatUserIds !== null) {
|
||||
$query->whereIn('e.user_id', $workWechatUserIds);
|
||||
} elseif ($unboundOnly) {
|
||||
@@ -1406,58 +1440,49 @@ class ConversionLogic
|
||||
. ' AND bound_a.delete_time IS NULL)'
|
||||
);
|
||||
}
|
||||
$effectiveQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
// 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。
|
||||
MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel);
|
||||
}
|
||||
$effectivePairs = $effectiveQuery
|
||||
->whereRaw(
|
||||
'EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time >= surviving_e.event_time'
|
||||
. ' AND surviving_del.event_time <= ?))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
|
||||
)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$deletedQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
// del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。
|
||||
MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel);
|
||||
}
|
||||
$deletedPairs = $deletedQuery
|
||||
->whereRaw(
|
||||
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_e'
|
||||
. ' WHERE surviving_e.user_id = e.user_id'
|
||||
. ' AND surviving_e.external_userid = e.external_userid'
|
||||
. ' AND surviving_e.change_type = ?'
|
||||
. ' AND surviving_e.event_time >= ?'
|
||||
. ' AND surviving_e.event_time <= ?'
|
||||
. ' AND NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` surviving_del'
|
||||
. ' WHERE surviving_del.user_id = surviving_e.user_id'
|
||||
. ' AND surviving_del.external_userid = surviving_e.external_userid'
|
||||
. ' AND surviving_del.change_type = ?'
|
||||
. ' AND surviving_del.event_time >= surviving_e.event_time'
|
||||
. ' AND surviving_del.event_time <= ?))',
|
||||
['add_external_contact', $startTimestamp, $endTimestamp, 'del_external_contact', $endTimestamp]
|
||||
)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
// add_way 筛选对有效与已删粉丝使用同一口径。
|
||||
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
|
||||
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
|
||||
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs);
|
||||
$effectiveQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$effectiveQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
$mediaChannel
|
||||
);
|
||||
}
|
||||
$effectivePairs = $effectiveQuery
|
||||
->whereRaw($livePairPredicate, $livePairBindings)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($effectivePairs as &$effectivePair) {
|
||||
$effectivePair['is_deleted'] = false;
|
||||
}
|
||||
unset($effectivePair);
|
||||
|
||||
$deletedQuery = clone $query;
|
||||
if ($mediaChannel !== null) {
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$deletedQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
$mediaChannel,
|
||||
true
|
||||
);
|
||||
}
|
||||
$deletedPairs = $deletedQuery
|
||||
->whereRaw('NOT (' . $livePairPredicate . ')', $livePairBindings)
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($deletedPairs as &$deletedPair) {
|
||||
$deletedPair['is_deleted'] = true;
|
||||
}
|
||||
unset($deletedPair);
|
||||
|
||||
// add_way 筛选对有效与已删组合使用同一口径。
|
||||
$candidatePairs = array_merge($effectivePairs, $deletedPairs);
|
||||
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
|
||||
$rows = self::buildFanDetailRows($candidatePairs);
|
||||
|
||||
self::$requestRowsCache[$cacheKey] = $rows;
|
||||
|
||||
@@ -1465,97 +1490,69 @@ class ConversionLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $candidatePairs
|
||||
* @param array<int,array<string,mixed>> $effectivePairs
|
||||
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function buildFanDetailRows(
|
||||
array $candidatePairs,
|
||||
array $effectivePairs
|
||||
): array {
|
||||
$effectiveKeys = [];
|
||||
foreach ($effectivePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId !== '' && $externalUserId !== '') {
|
||||
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$detailsByKey = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
$addTime = max(0, (int) ($pair['add_time'] ?? 0));
|
||||
if (isset($detailsByKey[$key])) {
|
||||
$existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0);
|
||||
if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) {
|
||||
$detailsByKey[$key]['add_time'] = $addTime;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$detailsByKey[$key] = [
|
||||
'user_id' => $userId,
|
||||
'external_userid' => $externalUserId,
|
||||
'add_time' => $addTime,
|
||||
'is_deleted' => !isset($effectiveKeys[$key]),
|
||||
'delete_time' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($detailsByKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。
|
||||
*
|
||||
* 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入
|
||||
* add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入
|
||||
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
|
||||
* 若区间内删除后又重加且期末仍有效,该对仍在有效子集中,不会误计为已删。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $candidatePairs
|
||||
* @param array<int, array<string, mixed>> $effectivePairs
|
||||
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
|
||||
*/
|
||||
private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array
|
||||
{
|
||||
$effectiveKeys = [];
|
||||
foreach ($effectivePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$effectiveKeys[$userId . "\0" . $externalUserId] = true;
|
||||
}
|
||||
|
||||
$countsByUser = [];
|
||||
$seenCandidateKeys = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
if (isset($seenCandidateKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seenCandidateKeys[$key] = true;
|
||||
$countsByUser[$userId] ??= [
|
||||
* @param array<int,array<string,mixed>> $candidatePairs
|
||||
* @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
|
||||
*/
|
||||
private static function buildFanDetailRows(array $candidatePairs): array
|
||||
{
|
||||
$detailsByPair = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$eventId = max(0, (int) ($pair['add_event_id'] ?? 0));
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($eventId <= 0 || $userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
if (isset($detailsByPair[$key])) {
|
||||
continue;
|
||||
}
|
||||
$detailsByPair[$key] = [
|
||||
'add_event_id' => $eventId,
|
||||
'user_id' => $userId,
|
||||
'external_userid' => $externalUserId,
|
||||
'add_time' => max(0, (int) ($pair['add_time'] ?? 0)),
|
||||
'is_deleted' => !empty($pair['is_deleted']),
|
||||
'delete_time' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($detailsByPair);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按员工聚合全部新增员工+客户组合,并标记其中期末已删除的子集。
|
||||
*
|
||||
* 每个不同 (user_id, external_userid) 都计入 add_fans_count;is_deleted=true 的组合同时计入
|
||||
* deleted_fans_count。deleted_fans_count 只是 add_fans_count 的提示子集,不再扣减或重复累计。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $candidatePairs
|
||||
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
|
||||
*/
|
||||
private static function buildFanCountRows(array $candidatePairs): array
|
||||
{
|
||||
$countsByUser = [];
|
||||
$seenPairs = [];
|
||||
foreach ($candidatePairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$externalUserId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $externalUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $userId . "\0" . $externalUserId;
|
||||
if (isset($seenPairs[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seenPairs[$key] = true;
|
||||
$countsByUser[$userId] ??= [
|
||||
'user_id' => $userId,
|
||||
'add_fans_count' => 0,
|
||||
'deleted_fans_count' => 0,
|
||||
];
|
||||
++$countsByUser[$userId]['add_fans_count'];
|
||||
if (!isset($effectiveKeys[$key])) {
|
||||
++$countsByUser[$userId]['deleted_fans_count'];
|
||||
}
|
||||
];
|
||||
++$countsByUser[$userId]['add_fans_count'];
|
||||
if (!empty($pair['is_deleted'])) {
|
||||
++$countsByUser[$userId]['deleted_fans_count'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($countsByUser);
|
||||
@@ -1569,25 +1566,25 @@ class ConversionLogic
|
||||
* - add_way=201/202 继承/分配(内部成员共享、管理员/负责人分配,含在职/离职继承)。
|
||||
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $pairs
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function excludeUncountedFanPairs(array $pairs): array
|
||||
{
|
||||
if ($pairs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$externalUserIds = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
* @param array<int, array<string, mixed>> $pairs
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function excludeUncountedFanPairs(array $pairs): array
|
||||
{
|
||||
if ($pairs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$externalUserIds = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($extId !== '') {
|
||||
$externalUserIds[$extId] = true;
|
||||
}
|
||||
}
|
||||
$externalIdList = array_keys($externalUserIds);
|
||||
if ($externalIdList === []) {
|
||||
return $pairs;
|
||||
$externalIdList = array_keys($externalUserIds);
|
||||
if ($externalIdList === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/** @var array<string, true> $excludedKeys user_id\0external_userid */
|
||||
@@ -1628,21 +1625,21 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
if ($excludedKeys === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
$kept = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($excludedKeys === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
$kept = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $extId === '') {
|
||||
continue;
|
||||
}
|
||||
if (isset($excludedKeys[$userId . "\0" . $extId])) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = $pair;
|
||||
$kept[] = $pair;
|
||||
}
|
||||
|
||||
return $kept;
|
||||
|
||||
@@ -32,7 +32,8 @@ use think\facade\Log;
|
||||
class LoginValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'terminal' => 'require|in:' . AdminTerminalEnum::PC . ',' . AdminTerminalEnum::MOBILE,
|
||||
'terminal' => 'require|in:' . AdminTerminalEnum::PC . ',' . AdminTerminalEnum::MOBILE
|
||||
. ',' . AdminTerminalEnum::WECOM_RPA,
|
||||
'account' => 'require',
|
||||
'password' => 'require|password',
|
||||
];
|
||||
@@ -143,4 +144,4 @@ class LoginValidate extends BaseValidate
|
||||
return in_array($flag, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user