This commit is contained in:
Your Name
2026-09-07 10:07:47 +08:00
parent cf3fbdc5ef
commit d5164b7369
388 changed files with 19863 additions and 18720 deletions
+475 -423
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -300,6 +300,10 @@ export interface WecomPromotionBatchUpdatePoolsParams {
fallback_url?: string fallback_url?: string
status?: 0 | 1 status?: 0 | 1
automation_config?: Record<string, unknown> automation_config?: Record<string, unknown>
member_status?: {
member_admin_ids: number[]
status: 0 | 1
}
} }
} }
@@ -309,6 +313,8 @@ export interface WecomPromotionBatchUpdatePoolResult {
success: boolean success: boolean
sync_error?: string sync_error?: string
sync_queued?: boolean sync_queued?: boolean
member_matched?: number
member_updated?: number
error?: string error?: string
} }
@@ -318,6 +324,8 @@ export interface WecomPromotionBatchUpdatePoolsResult {
failed: number failed: number
sync_error_count: number sync_error_count: number
sync_queued_count: number sync_queued_count: number
member_matched: number
member_updated: number
results: WecomPromotionBatchUpdatePoolResult[] results: WecomPromotionBatchUpdatePoolResult[]
} }
@@ -154,7 +154,7 @@
<div class="panel-heading panel-heading--table"> <div class="panel-heading panel-heading--table">
<div> <div>
<h2>明细数据列表</h2> <h2>明细数据列表</h2>
<p>展开部门可查看人员明细;加粉=总进线=区间新增加粉(按员工+客户去重,包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已加过的重加)<template v-if="canViewDeletedFans">,(-N)表示加粉总数中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p> <p>展开部门可查看人员明细;加粉=总进线=区间新增“员工+客户”组合(同一员工的同一客户只计一次,同一客户进入不同员工分别计数;包含区间内添加后已删客户,剔除继承客户、扫一扫/搜手机号/名片分享添加及区间前已存在的相同组合)<template v-if="canViewDeletedFans">,(-N)表示加粉组合中已删除</template>;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
</div> </div>
<span>{{ dashboard.rows.length }} 个顶层节点</span> <span>{{ dashboard.rows.length }} 个顶层节点</span>
</div> </div>
@@ -193,7 +193,7 @@
v-if="hasFans(row.add_fans_count)" v-if="hasFans(row.add_fans_count)"
type="button" type="button"
class="fan-count-value fan-detail-trigger fan-detail-trigger--table" class="fan-count-value fan-detail-trigger fan-detail-trigger--table"
title="查看该行加粉客户明细" title="查看该行加粉组合明细"
@click.stop="openFansDetail(row)" @click.stop="openFansDetail(row)"
> >
{{ formatNumber(row.add_fans_count) }} {{ formatNumber(row.add_fans_count) }}
@@ -349,7 +349,7 @@
</template> </template>
</el-table-column> </el-table-column>
<template #empty> <template #empty>
<el-empty :image-size="68" description="当前条件下暂无加粉客户明细" /> <el-empty :image-size="68" description="当前条件下暂无加粉组合明细" />
</template> </template>
</el-table> </el-table>
@@ -448,7 +448,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' } { label: '自定义', value: 'custom' }
] ]
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [ const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增加粉(含已删除)' }, { key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间新增员工+客户组合(不同员工分别计数,含已删除)' },
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' }, { key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' }, { key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' }, { key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -518,7 +518,7 @@ const rankingKind = computed(() => dashboard.meta.ranking_kind || (
)) ))
const showRankings = computed(() => rankingKind.value !== 'hidden') const showRankings = computed(() => rankingKind.value !== 'hidden')
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组') const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
const fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉客户明细`) const fansDetailTitle = computed(() => `${fansDetailEntity.value.name} · 加粉组合明细`)
const detailRangeText = computed(() => { const detailRangeText = computed(() => {
const startDate = dashboard.meta.start_date || query.start_date || '' const startDate = dashboard.meta.start_date || query.start_date || ''
const endDate = dashboard.meta.end_date || query.end_date || '' const endDate = dashboard.meta.end_date || query.end_date || ''
@@ -637,7 +637,7 @@ async function loadFansDetail() {
if (requestId !== latestFansDetailRequestId) return if (requestId !== latestFansDetailRequestId) return
fansDetailRows.value = [] fansDetailRows.value = []
fansDetailPager.total = 0 fansDetailPager.total = 0
ElMessage.error(error?.message || '加粉客户明细加载失败') ElMessage.error(error?.message || '加粉组合明细加载失败')
} finally { } finally {
if (requestId === latestFansDetailRequestId) fansDetailLoading.value = false if (requestId === latestFansDetailRequestId) fansDetailLoading.value = false
} }
@@ -74,6 +74,16 @@
<div v-if="overview.pools.length" class="pool-layout"> <div v-if="overview.pools.length" class="pool-layout">
<aside class="pool-sidebar"> <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 <div
v-for="pool in overview.pools" v-for="pool in overview.pools"
:key="pool.id" :key="pool.id"
@@ -488,6 +498,62 @@
</div> </div>
</section> </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"> <section v-if="overview.automation_installed" class="batch-config-section automation-batch-section">
<h3>自动化设置</h3> <h3>自动化设置</h3>
<p class="batch-section-tip">先选择要覆盖的分组。未选择的接待、客户或欢迎语设置不会随本次批量操作改变。</p> <p class="batch-section-tip">先选择要覆盖的分组。未选择的接待、客户或欢迎语设置不会随本次批量操作改变。</p>
@@ -514,7 +580,7 @@
@busy="(busy) => batchConfigBusy = busy" @busy="(busy) => batchConfigBusy = busy"
/> />
</section> </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> </el-form>
</div> </div>
<template #footer> <template #footer>
@@ -655,6 +721,12 @@ interface PromotionMemberOption {
dept_names: string[] dept_names: string[]
} }
interface BatchMemberStatusOption extends PromotionMemberOption {
pool_count: number
online_count: number
offline_count: number
}
interface PromotionOperatorOption { interface PromotionOperatorOption {
id: number id: number
name: string name: string
@@ -714,8 +786,8 @@ const poolFormScroll = ref<HTMLElement>()
const poolFormError = ref('') const poolFormError = ref('')
const automationBusy = ref(false) const automationBusy = ref(false)
const accessForm = reactive({ pool_ids: [] as number[], operator_admin_ids: [] as number[], action: 'grant' as 'grant' | 'revoke' }) 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 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, automation_config: defaultAutomationConfig() }) 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 memberForm = reactive({ id: 0, name: '', userid: '', daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
const customerStatsLoading = ref(false) const customerStatsLoading = ref(false)
const customerStatsLoaded = 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 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 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 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 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>( const batchPrimaryMemberSets = computed(() => batchConfigPools.value.map((pool: any) => new Set<number>(
(Array.isArray(pool.member_admin_ids) ? pool.member_admin_ids : []).map(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] selectedPoolIds.value = [...next]
} }
function toggleAllPoolSelection(checked: unknown) {
selectedPoolIds.value = checked ? [...manageablePoolIds.value] : []
}
function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) { function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
const manageableIds = new Set(overview.pools const manageableIds = new Set(overview.pools
.filter((pool: any) => pool.can_manage_access) .filter((pool: any) => pool.can_manage_access)
@@ -823,6 +976,7 @@ function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
skip_verify: false, skip_verify: false,
fallback_url: false, fallback_url: false,
status: false, status: false,
member_status: false,
reception: false, reception: false,
customer: false, customer: false,
welcome: false welcome: false
@@ -832,6 +986,8 @@ function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
skip_verify: Number(reference?.skip_verify) === 1 ? 1 : 0, skip_verify: Number(reference?.skip_verify) === 1 ? 1 : 0,
fallback_url: String(reference?.fallback_url || ''), fallback_url: String(reference?.fallback_url || ''),
status: Number(reference?.status) === 1 ? 1 : 0, status: Number(reference?.status) === 1 ? 1 : 0,
member_admin_ids: [],
member_status: 1,
automation_config: cloneAutomationConfig(reference?.automation_config) automation_config: cloneAutomationConfig(reference?.automation_config)
}) })
batchConfigError.value = '' batchConfigError.value = ''
@@ -860,9 +1016,23 @@ async function saveBatchConfig() {
batchConfigError.value = '' batchConfigError.value = ''
const hasAutomationChange = overview.automation_installed const hasAutomationChange = overview.automation_installed
&& (batchConfigApply.reception || batchConfigApply.customer || batchConfigApply.welcome) && (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('请至少勾选一项需要批量修改的配置') 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())) { if (batchConfigApply.fallback_url && batchConfigForm.fallback_url.trim() && !isWebUrl(batchConfigForm.fallback_url.trim())) {
return setBatchConfigError('兜底获客助手链接必须为有效的 HTTP/HTTPS 地址') return setBatchConfigError('兜底获客助手链接必须为有效的 HTTP/HTTPS 地址')
} }
@@ -889,10 +1059,17 @@ async function saveBatchConfig() {
fallback_url?: string fallback_url?: string
status?: 0 | 1 status?: 0 | 1
automation_config?: Record<string, unknown> 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.skip_verify) changes.skip_verify = batchConfigForm.skip_verify === 1 ? 1 : 0
if (batchConfigApply.fallback_url) changes.fallback_url = batchConfigForm.fallback_url.trim() if (batchConfigApply.fallback_url) changes.fallback_url = batchConfigForm.fallback_url.trim()
if (batchConfigApply.status) changes.status = batchConfigForm.status === 1 ? 1 : 0 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) { if (hasAutomationChange) {
const serialized = serializeAutomationConfig(automation) as unknown as Record<string, unknown> const serialized = serializeAutomationConfig(automation) as unknown as Record<string, unknown>
const automationPatch: Record<string, unknown> = {} const automationPatch: Record<string, unknown> = {}
@@ -919,19 +1096,25 @@ async function saveBatchConfig() {
batchConfigDialogVisible.value = false batchConfigDialogVisible.value = false
selectedPoolIds.value = [] selectedPoolIds.value = []
await loadOverview() await loadOverview()
const memberStatusText = batchConfigForm.member_status === 1 ? '上线' : '下线'
const memberResultText = hasMemberStatusChange
? (result.member_updated > 0
? `,${result.member_updated} 条员工规则已${memberStatusText}`
: `,所选员工均已处于${memberStatusText}状态`)
: ''
if (result.failed > 0) { if (result.failed > 0) {
const detail = result.results const detail = result.results
.filter((item) => !item.success) .filter((item) => !item.success)
.slice(0, 2) .slice(0, 2)
.map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`) .map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`)
.join(';') .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) { } 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) { } 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 { } else {
ElMessage.success(`已批量更新 ${result.updated} 个分流方案`) ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText}`)
} }
} catch (error: any) { } catch (error: any) {
setBatchConfigError(error?.message || '批量修改分流方案失败') setBatchConfigError(error?.message || '批量修改分流方案失败')
@@ -946,6 +1129,7 @@ function openAccessDialog(poolIds: number[] = selectedPoolIds.value) {
.map((pool: any) => Number(pool.id))) .map((pool: any) => Number(pool.id)))
const ids = [...new Set(poolIds.map(Number).filter((id) => manageableIds.has(id)))] const ids = [...new Set(poolIds.map(Number).filter((id) => manageableIds.has(id)))]
if (!ids.length) return ElMessage.warning('请先选择可管理的分流方案') if (!ids.length) return ElMessage.warning('请先选择可管理的分流方案')
if (ids.length > 100) return ElMessage.warning('单次最多设置 100 个分流方案')
Object.assign(accessForm, { Object.assign(accessForm, {
pool_ids: ids, pool_ids: ids,
operator_admin_ids: [], operator_admin_ids: [],
@@ -1286,7 +1470,11 @@ function todayCount(row: any) {
function buildMemberDepartmentTree( function buildMemberDepartmentTree(
departmentOptions: PromotionDepartmentOption[], departmentOptions: PromotionDepartmentOption[],
memberOptions: PromotionMemberOption[] memberOptions: PromotionMemberOption[],
options: {
selectableDepartments?: boolean
memberDetail?: (member: PromotionMemberOption) => string
} = {}
): PromotionMemberTreeNode[] { ): PromotionMemberTreeNode[] {
const availableDeptIds = new Set<number>() const availableDeptIds = new Set<number>()
const collectDeptIds = (departments: PromotionDepartmentOption[]) => { const collectDeptIds = (departments: PromotionDepartmentOption[]) => {
@@ -1331,7 +1519,8 @@ function buildMemberDepartmentTree(
const placedMemberIds = new Set<number>() const placedMemberIds = new Set<number>()
const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => { const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => {
const departments = member.dept_names.length ? member.dept_names.join(' / ') : '未分部门' 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 { return {
value: member.id, value: member.id,
label: member.name, label: member.name,
@@ -1366,7 +1555,7 @@ function buildMemberDepartmentTree(
value: `dept:${departmentId}`, value: `dept:${departmentId}`,
label: departmentName, label: departmentName,
kind: 'department', kind: 'department',
disabled: true, disabled: !options.selectableDepartments,
member_count: memberCount, member_count: memberCount,
search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(), search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
children children
@@ -1383,7 +1572,7 @@ function buildMemberDepartmentTree(
value: 'dept:unassigned', value: 'dept:unassigned',
label: '未分部门', label: '未分部门',
kind: 'department', kind: 'department',
disabled: true, disabled: !options.selectableDepartments,
member_count: children.length, member_count: children.length,
search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(), search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
children children
@@ -1569,6 +1758,7 @@ h1, h2, h3, p { margin: 0; }
.selection-count { color: #117f75; font-size: 11px; font-weight: 600; } .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-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-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 { 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-select-row :deep(.el-checkbox) { justify-content: center; margin-right: 0; }
.pool-item { display: grid; grid-template-columns: 9px minmax(0, 1fr) 16px; align-items: center; gap: 9px; width: 100%; min-height: 62px; padding: 10px; border: 1px solid transparent; border-radius: 8px; text-align: left; background: transparent; cursor: pointer; } .pool-item { 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-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-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-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; } .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; } .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; } .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; } :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: 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> </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

+24 -2
View File
@@ -169,8 +169,30 @@ try {
& $Npm run build --prefix $CompanionRoot & $Npm run build --prefix $CompanionRoot
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" } if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
& $Python -m PyInstaller --noconfirm --clean $Spec $BuildPythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" } 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" $Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1 $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") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet 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_editors import DailyRecordEditorDialog
from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
@@ -480,12 +480,7 @@ def _run_immediately(
def render() -> list[Path]: def render() -> list[Path]:
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
font_path = Path("C:/Windows/Fonts/msyh.ttc") apply_theme(app)
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))
diagnosis_module.run_async = _run_immediately diagnosis_module.run_async = _run_immediately
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual" output = root / "artifacts" / "diagnosis_visual"
@@ -8,7 +8,6 @@ from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository from doctor_workstation.services import DemoDoctorRepository
@@ -19,13 +18,6 @@ def render() -> list[Path]:
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
apply_theme(app) 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] root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual" output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True) output.mkdir(parents=True, exist_ok=True)
+3 -9
View File
@@ -9,10 +9,11 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import Qt, QThreadPool, Signal 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 PySide6.QtWidgets import QApplication, QToolButton, QWidget
from doctor_workstation.core import PermissionSet 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 import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import ConsultationsPage from doctor_workstation.ui.pages.consultations import ConsultationsPage
@@ -339,14 +340,7 @@ def _save_with_payment_qr(
def _application() -> QApplication: def _application() -> QApplication:
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
# The offscreen Windows plugin does not enumerate system fonts. Register apply_theme(app)
# 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))
return app return app
@@ -9,7 +9,6 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication, QWidget from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.core.permissions import PermissionSet from doctor_workstation.core.permissions import PermissionSet
@@ -136,12 +135,6 @@ def _settle(app: QApplication, rounds: int = 8) -> None:
def render() -> list[Path]: def render() -> list[Path]:
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
apply_theme(app) 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 = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
output.mkdir(parents=True, exist_ok=True) output.mkdir(parents=True, exist_ok=True)
@@ -8,7 +8,6 @@ from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from doctor_workstation.services.mock_repository import DemoDoctorRepository from doctor_workstation.services.mock_repository import DemoDoctorRepository
@@ -19,9 +18,6 @@ from doctor_workstation.ui.theme import apply_theme
def main() -> int: def main() -> int:
application = QApplication.instance() or QApplication([]) application = QApplication.instance() or QApplication([])
apply_theme(application) apply_theme(application)
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
if font_path.is_file():
QFontDatabase.addApplicationFont(str(font_path))
repository = DemoDoctorRepository() repository = DemoDoctorRepository()
session = repository.login("doctor", "doctor123") session = repository.login("doctor", "doctor123")
+2 -2
View File
@@ -313,7 +313,7 @@ class DemoVideoDialog(QDialog):
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}" "background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}" "QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;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;}" "QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
) )
@@ -326,7 +326,7 @@ class DemoVideoDialog(QDialog):
header.addWidget(title) header.addWidget(title)
header.addStretch(1) header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云") demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#7886AA;font-size:12px;") demo.setStyleSheet("color:#707584;font-size:12px;")
header.addWidget(demo) header.addWidget(demo)
self.duration_label = QLabel("00:00") self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;") self.duration_label.setStyleSheet("font-weight:700;")
@@ -55,26 +55,25 @@ from .widgets import (
APPOINTMENT_DRAWER_QSS = r""" APPOINTMENT_DRAWER_QSS = r"""
QDialog#AppointmentDrawerOverlay { QDialog#AppointmentDrawerOverlay {
background-color: transparent; background-color: transparent;
color: #111F46; color: #1A1C1F;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; font-size: 14px;
font-size: 13px;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
background-color: #FFFFFF; background-color: #FFFFFF;
border-left: 1px solid #E6EAF5; border-left: 1px solid #EDEDEE;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
background-color: #FFFFFF; background-color: #FFFFFF;
border: 0; border: 0;
border-bottom: 1px solid #E6EAF5; border-bottom: 1px solid #EDEDEE;
} }
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle { QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
color: #111F46; color: #1A1C1F;
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 600;
} }
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose { QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
@@ -86,14 +85,14 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
background-color: transparent; background-color: transparent;
color: #7886AA; color: #606163;
font-size: 22px; font-size: 22px;
font-weight: 400; font-weight: 400;
} }
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover { QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
color: #4451E2; color: #1A1C1F;
background-color: #F0F2FF; background-color: #F0F0F0;
} }
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody, QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
@@ -109,13 +108,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
} }
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] { QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
color: #3F4E75; color: #1A1C1F;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
} }
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] { QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
color: #7886AA; color: #606163; font-size: 13px;
} }
QDialog#AppointmentDrawerOverlay QComboBox, QDialog#AppointmentDrawerOverlay QComboBox,
@@ -123,28 +122,28 @@ QDialog#AppointmentDrawerOverlay QLineEdit,
QDialog#AppointmentDrawerOverlay QPlainTextEdit { QDialog#AppointmentDrawerOverlay QPlainTextEdit {
min-height: 30px; min-height: 30px;
padding: 0 11px; padding: 0 11px;
border: 1px solid #E6EAF5; border: 1px solid #EDEDEE;
border-radius: 9px; border-radius: 9px;
background-color: #FFFFFF; background-color: #FFFFFF;
color: #111F46; color: #1A1C1F;
selection-background-color: #5761F4; selection-background-color: #EEF1FA;
selection-color: #FFFFFF; selection-color: #1A1C1F; font-size: 14px;
} }
QDialog#AppointmentDrawerOverlay QPlainTextEdit { QDialog#AppointmentDrawerOverlay QPlainTextEdit {
padding: 7px 11px; padding: 7px 11px; font-size: 14px;
} }
QDialog#AppointmentDrawerOverlay QComboBox:hover, QDialog#AppointmentDrawerOverlay QComboBox:hover,
QDialog#AppointmentDrawerOverlay QLineEdit:hover, QDialog#AppointmentDrawerOverlay QLineEdit:hover,
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover { QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
border-color: #5761F4; border-color: #8B9AD9;
} }
QDialog#AppointmentDrawerOverlay QComboBox:focus, QDialog#AppointmentDrawerOverlay QComboBox:focus,
QDialog#AppointmentDrawerOverlay QLineEdit:focus, QDialog#AppointmentDrawerOverlay QLineEdit:focus,
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus { QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
border: 2px solid #8D9BFF; border: 2px solid #8B9AD9;
} }
QDialog#AppointmentDrawerOverlay QComboBox::drop-down { QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
@@ -154,81 +153,81 @@ QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView { QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
background-color: #FFFFFF; background-color: #FFFFFF;
color: #111F46; color: #1A1C1F;
border: 1px solid #E6EAF5; border: 1px solid #EDEDEE;
selection-background-color: #5761F4; selection-background-color: #EEF1FA;
selection-color: #FFFFFF; selection-color: #1A1C1F;
outline: 0; outline: 0; font-size: 14px;
} }
QDialog#AppointmentDrawerOverlay QRadioButton { QDialog#AppointmentDrawerOverlay QRadioButton {
min-height: 24px; min-height: 24px;
spacing: 8px; spacing: 8px;
color: #3F4E75; color: #1A1C1F;
} }
QDialog#AppointmentDrawerOverlay QRadioButton::indicator { QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 7px; border-radius: 7px;
border: 1px solid #E6EAF5; border: 1px solid #EDEDEE;
background-color: #FFFFFF; background-color: #FFFFFF;
} }
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover { QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
border-color: #5761F4; border-color: #4156C4;
} }
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked { QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
width: 4px; width: 4px;
height: 4px; height: 4px;
border: 5px solid #5761F4; border: 5px solid #4F63D9;
border-radius: 7px; border-radius: 7px;
background-color: #FFFFFF; background-color: #FFFFFF;
} }
QDialog#AppointmentDrawerOverlay QRadioButton:focus { QDialog#AppointmentDrawerOverlay QRadioButton:focus {
color: #4451E2; color: #4F63D9;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] { QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
min-height: 38px; min-height: 38px;
max-height: 38px; max-height: 38px;
padding: 0; padding: 0;
border: 1px solid #E6EAF5; border: 1px solid #EDEDEE;
border-radius: 8px; border-radius: 8px;
background-color: #FFFFFF; background-color: #FFFFFF;
color: #3F4E75; color: #1A1C1F;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover { QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
color: #4451E2; color: #4156C4;
border-color: #5761F4; border-color: #4156C4;
background-color: #F0F2FF; background-color: #EEF1FA;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus { QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
border-color: #8D9BFF; border-color: #8B9AD9;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked { QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
color: #FFFFFF; color: #FFFFFF;
border-color: #5761F4; border-color: #4F63D9;
background-color: #5761F4; background-color: #4F63D9;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel { QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
background-color: #F7F9FE; background-color: #F7F7F7;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
} }
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle { QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
color: #111F46; color: #1A1C1F;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 500;
} }
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots { QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
@@ -238,87 +237,83 @@ QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
background-color: transparent; background-color: transparent;
color: #4451E2; color: #4F63D9;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
} }
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover { QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
background-color: #F0F2FF; background-color: #EEF1FA;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
min-width: 110px; min-width: 110px;
min-height: 70px; min-height: 70px;
padding: 0 8px; padding: 0 8px;
border: 2px solid #E6EAF5; border: 2px solid #EDEDEE;
border-radius: 8px; border-radius: 8px;
background-color: #FFFFFF; background-color: #FFFFFF;
color: #111F46; color: #1A1C1F;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
color: #111F46; color: #1A1C1F;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 500;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus {
padding: 0 8px; padding: 0 8px;
border-radius: 4px; border-radius: 4px;
background-color: #F4F4F5; background-color: #F7F7F7;
color: #7886AA; color: #606163;
font-size: 12px; font-size: 13px;
font-weight: 400; font-weight: 400;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
color: #17A77D; color: #287B65;
background-color: #EAF9F3; background-color: #EAF9F3;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
color: #4451E2; color: #4156C4;
border-color: #5761F4; border-color: #4156C4;
background-color: #F0F2FF; background-color: #EEF1FA;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
border-color: #8D9BFF; border-color: #8B9AD9;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
color: #FFFFFF; color: #FFFFFF;
border-color: #5761F4; border-color: #4F63D9;
background: qlineargradient( background: #4F63D9;
x1:0, y1:0, x2:1, y2:1,
stop:0 #5761F4,
stop:1 #7769F7
);
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotTime, QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime[slotSelected="true"],
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
color: #FFFFFF; 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); background-color: rgba(255, 255, 255, 46);
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
color: #A4ADC3; color: #8E8F90;
border-color: #E6EAF5; border-color: #EDEDEE;
background-color: #F0F2F8; background-color: #F7F7F7;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotTime, QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime:disabled,
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
color: #A4ADC3; color: #8E8F90;
} }
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus { QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
background-color: #F0F2F8; background-color: #F7F7F7;
} }
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty { QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
@@ -326,13 +321,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
} }
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText { QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
color: #7886AA; color: #606163;
font-size: 14px; font-size: 14px;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] { QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
background-color: #F0F4FF; background-color: #EEF1FA;
border: 1px solid #DDE5FF; border: 1px solid #EDEDEE;
border-radius: 9px; border-radius: 9px;
} }
@@ -355,65 +350,65 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel { QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
color: #4D69ED; color: #4F63D9;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel { QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
color: #D38625; color: #A9691D;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel { QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
color: #F15B67; color: #BE4B58;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel { QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
color: #17A77D; color: #287B65;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
background-color: #FFFFFF; background-color: #FFFFFF;
border: 0; border: 0;
border-top: 1px solid #E6EAF5; border-top: 1px solid #EDEDEE;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
min-height: 30px; min-height: 30px;
max-height: 30px; max-height: 30px;
padding: 0 15px; padding: 0 15px;
border: 1px solid #E6EAF5; border: 1px solid #EDEDEE;
border-radius: 9px; border-radius: 9px;
background-color: #FFFFFF; background-color: #FFFFFF;
color: #3F4E75; color: #1A1C1F;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 500;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
color: #4451E2; color: #4156C4;
border-color: #5761F4; border-color: #4156C4;
background-color: #F0F2FF; background-color: #EEF1FA;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
border-color: #8D9BFF; border-color: #8B9AD9;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
color: #FFFFFF; color: #FFFFFF;
border-color: #5761F4; border-color: #4F63D9;
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5761F4, stop:1 #7769F7); background: #4F63D9;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
color: #FFFFFF; color: #FFFFFF;
border-color: #4C57E9; border-color: #4156C4;
background-color: #4C57E9; background-color: #4156C4;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled { QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
color: #FFFFFF; color: #FFFFFF;
border-color: #E6EAF5; border-color: #EDEDEE;
background-color: #A4ADC3; background-color: #8E8F90;
} }
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay { QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
@@ -422,7 +417,7 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
} }
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText { QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
color: #7886AA; color: #606163;
font-size: 14px; font-size: 14px;
} }
@@ -436,11 +431,11 @@ QDialog#AppointmentDrawerOverlay QScrollBar:vertical {
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical { QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
min-height: 30px; min-height: 30px;
border-radius: 3px; border-radius: 3px;
background-color: #E6EAF5; background-color: #EDEDEE;
} }
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover { QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
background-color: #8D9BFF; background-color: #E4E4E5;
} }
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical, QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
@@ -722,15 +717,15 @@ class _EmptyIllustration(QWidget):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EEF2F8")) painter.setBrush(QColor("#F7F7F7"))
painter.drawEllipse(QRect(9, 48, 62, 8)) painter.drawEllipse(QRect(9, 48, 62, 8))
painter.setPen(QPen(QColor("#D8DEEA"), 1)) painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.setBrush(QColor("#FFFFFF")) painter.setBrush(QColor("#FFFFFF"))
painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4) 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.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(30, 35, 50, 35)
painter.drawLine(34, 41, 46, 41) painter.drawLine(34, 41, 46, 41)
painter.end() painter.end()
@@ -751,7 +746,7 @@ class _HoverLiftButton(QPushButton):
shadow = QGraphicsDropShadowEffect(self) shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(12) shadow.setBlurRadius(12)
shadow.setOffset(0, 4) shadow.setOffset(0, 4)
shadow.setColor(QColor(102, 117, 245, 72)) shadow.setColor(QColor(26, 28, 31, 72))
self.setGraphicsEffect(shadow) self.setGraphicsEffect(shadow)
self._lifted = True self._lifted = True
super().enterEvent(event) super().enterEvent(event)
@@ -784,6 +779,15 @@ class _SlotCard(_HoverLiftButton):
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
layout.addWidget(self.status_label) 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): class AppointmentDrawer(QDialog):
@@ -1705,7 +1709,7 @@ class AppointmentDrawer(QDialog):
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
painter = QPainter(self) painter = QPainter(self)
painter.fillRect(self.rect(), QColor(8, 11, 20, 196)) painter.fillRect(self.rect(), QColor(26, 28, 31, 196))
painter.end() painter.end()
super().paintEvent(event) super().paintEvent(event)
@@ -46,51 +46,51 @@ QFrame#ChatNotifyCard {
border: 1px solid #BBF0CE; border: 1px solid #BBF0CE;
border-radius: 12px; border-radius: 12px;
} }
QFrame#ChatNotifyCard[kind="left"] { border-color: #D8DEEE; } QFrame#ChatNotifyCard[kind="left"] { border-color: #E4E4E5; }
QFrame#ChatNotifyCard[kind="complete"] { border-color: #C3D6FF; } QFrame#ChatNotifyCard[kind="complete"] { border-color: #8B9AD9; }
QLabel#ChatNotifyBadge { QLabel#ChatNotifyBadge {
min-width: 34px; min-width: 34px;
max-width: 34px; max-width: 34px;
min-height: 34px; min-height: 34px;
max-height: 34px; max-height: 34px;
color: #FFFFFF; color: #FFFFFF;
background-color: #22C55E; background-color: #287B65;
border-radius: 9px; border-radius: 9px;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
} }
QLabel#ChatNotifyBadge[kind="left"] { background-color: #8A94B3; } QLabel#ChatNotifyBadge[kind="left"] { background-color: #6A6B6D; }
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #3B82F6; } QLabel#ChatNotifyBadge[kind="complete"] { background-color: #4F63D9; }
QLabel#ChatNotifyTitle { color: #1F2A44; font-size: 13px; font-weight: 700; } QLabel#ChatNotifyTitle { color: #1A1C1F; font-size: 13px; font-weight: 700; }
QLabel#ChatNotifyDesc { color: #4A5878; font-size: 12px; } QLabel#ChatNotifyDesc { color: #1A1C1F; font-size: 12px; }
QLabel#ChatNotifyTime { color: #8A94B3; font-size: 11px; } QLabel#ChatNotifyTime { color: #6A6B6D; font-size: 11px; }
QPushButton#ChatNotifyOpen { QPushButton#ChatNotifyOpen {
min-height: 26px; min-height: 26px;
padding: 0 10px; padding: 0 10px;
color: #3F4E75; color: #4F63D9;
background-color: #F4F6FC; background-color: #EEF1FA;
border: 1px solid #DDE3F2; border: 1px solid #EDEDEE;
border-radius: 7px; border-radius: 7px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
QPushButton#ChatNotifyOpen:hover { QPushButton#ChatNotifyOpen:hover {
color: #4451E2; color: #4156C4;
background-color: #EEF1FF; background-color: #EEF1FA;
border-color: #8D9BFF; border-color: #8B9AD9;
} }
QPushButton#ChatNotifyClose { QPushButton#ChatNotifyClose {
min-width: 22px; min-width: 22px;
max-width: 22px; max-width: 22px;
min-height: 22px; min-height: 22px;
max-height: 22px; max-height: 22px;
color: #8A94B3; color: #6A6B6D;
background-color: transparent; background-color: transparent;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: 14px;
} }
QPushButton#ChatNotifyClose:hover { color: #4A5878; background-color: #EDF0F7; } QPushButton#ChatNotifyClose:hover { color: #1A1C1F; background-color: #F7F7F7; }
""" """
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 = """ _INLINE_PLAYER_QSS = """
QWidget#DiagnosisInlineRecordingPlayer { QWidget#DiagnosisInlineRecordingPlayer {
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #E6EAF5; border: 1px solid #EDEDEE;
border-radius: 9px; border-radius: 9px;
} }
QFrame#DiagnosisInlineRecordingSurface { QFrame#DiagnosisInlineRecordingSurface {
background: #11182E; background: #1A1C1F;
border: 0; border: 0;
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
} }
QLabel#DiagnosisInlineRecordingPlaceholder { QLabel#DiagnosisInlineRecordingPlaceholder {
color: #C7D0E8; color: #E4E4E5;
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
} }
QLabel#DiagnosisInlineRecordingTime { QLabel#DiagnosisInlineRecordingTime {
color: #64739A; color: #6A6B6D;
font-size: 11px; font-size: 11px;
} }
QPushButton[recordingControl="true"] { QPushButton[recordingControl="true"] {
min-height: 24px; min-height: 24px;
max-height: 24px; max-height: 24px;
padding: 0 8px; padding: 0 8px;
color: #3F4E75; color: #1A1C1F;
background: #FAFBFE; background: #F7F7F7;
border: 1px solid #D8DEEE; border: 1px solid #E4E4E5;
border-radius: 6px; border-radius: 6px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
QPushButton[recordingControl="true"]:hover, QPushButton[recordingControl="true"]:hover,
QPushButton[recordingControl="true"]:focus { QPushButton[recordingControl="true"]:focus {
color: #4451E2; color: #4156C4;
background: #F0F2FF; background: #EEF1FA;
border-color: #8D9BFF; border-color: #8B9AD9;
} }
QPushButton#DiagnosisInlineRecordingPlay { QPushButton#DiagnosisInlineRecordingPlay {
color: #FFFFFF; color: #FFFFFF;
background: #5761F4; background: #4F63D9;
border-color: #5761F4; border-color: #4F63D9;
} }
QPushButton#DiagnosisInlineRecordingPlay:hover, QPushButton#DiagnosisInlineRecordingPlay:hover,
QPushButton#DiagnosisInlineRecordingPlay:focus { QPushButton#DiagnosisInlineRecordingPlay:focus {
color: #FFFFFF; color: #FFFFFF;
background: #4C57E9; background: #4156C4;
border-color: #4C57E9; border-color: #4156C4;
} }
QPushButton[recordingControl="true"]:disabled { QPushButton[recordingControl="true"]:disabled {
color: #A4ADC3; color: #8E8F90;
background: #F0F2F8; background: #F7F7F7;
border-color: #E6EAF5; border-color: #EDEDEE;
} }
QSlider::groove:horizontal { height: 3px; background: #D8DEEE; border-radius: 1px; } QSlider::groove:horizontal { height: 3px; background: #E4E4E5; border-radius: 1px; }
QSlider::sub-page:horizontal { background: #5761F4; border-radius: 1px; } QSlider::sub-page:horizontal { background: #4F63D9; border-radius: 1px; }
QSlider::handle:horizontal { QSlider::handle:horizontal {
width: 10px; width: 10px;
margin: -4px 0; margin: -4px 0;
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #5761F4; border: 1px solid #4F63D9;
border-radius: 5px; border-radius: 5px;
} }
""" """
@@ -466,11 +466,11 @@ class RecordingPlaybackCell(QWidget):
separator = QFrame() separator = QFrame()
separator.setObjectName("DiagnosisRecordingAlternateSeparator") separator.setObjectName("DiagnosisRecordingAlternateSeparator")
separator.setFrameShape(QFrame.Shape.HLine) separator.setFrameShape(QFrame.Shape.HLine)
separator.setStyleSheet("color:#E6EAF5;") separator.setStyleSheet("color:#EDEDEE;")
layout.addWidget(separator) layout.addWidget(separator)
label = QLabel("备用地址") label = QLabel("备用地址")
label.setObjectName("DiagnosisRecordingAlternateLabel") label.setObjectName("DiagnosisRecordingAlternateLabel")
label.setStyleSheet("color:#7886AA; font-size:12px;") label.setStyleSheet("color:#606163; font-size:12px;")
layout.addWidget(label) layout.addWidget(label)
links = QHBoxLayout() links = QHBoxLayout()
links.setContentsMargins(0, 0, 0, 0) links.setContentsMargins(0, 0, 0, 0)
@@ -490,7 +490,7 @@ class RecordingPlaybackCell(QWidget):
layout.addLayout(links) layout.addLayout(links)
self.link_status = QLabel("") self.link_status = QLabel("")
self.link_status.setObjectName("DiagnosisRecordingLinkStatus") 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.setWordWrap(True)
self.link_status.hide() self.link_status.hide()
layout.addWidget(self.link_status) layout.addWidget(self.link_status)
@@ -722,41 +722,41 @@ def image_display_name(target: str, ordinal: int) -> str:
_IMAGE_PREVIEW_QSS = """ _IMAGE_PREVIEW_QSS = """
QDialog#DiagnosisImagePreview { background: #FFFFFF; } QDialog#DiagnosisImagePreview { background: #FFFFFF; }
QLabel#DiagnosisImagePreviewName { color: #1F2A44; font-size: 14px; font-weight: 600; } QLabel#DiagnosisImagePreviewName { color: #1A1C1F; font-size: 14px; font-weight: 600; }
QLabel#DiagnosisImagePreviewCounter { color: #64739A; font-size: 12px; } QLabel#DiagnosisImagePreviewCounter { color: #6A6B6D; font-size: 12px; }
QLabel#DiagnosisImagePreviewStatus { color: #64739A; font-size: 12px; } QLabel#DiagnosisImagePreviewStatus { color: #6A6B6D; font-size: 12px; }
QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #C0392B; } QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #BE4B58; }
QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #9A650F; } QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #A9691D; }
QScrollArea#DiagnosisImagePreviewViewport { QScrollArea#DiagnosisImagePreviewViewport {
background: #11182E; background: #1A1C1F;
border: 1px solid #E6EAF5; border: 1px solid #EDEDEE;
border-radius: 9px; border-radius: 9px;
} }
QLabel#DiagnosisImagePreviewCanvas { QLabel#DiagnosisImagePreviewCanvas {
background: #11182E; background: #1A1C1F;
color: #C7D0E8; color: #E4E4E5;
font-size: 12px; font-size: 12px;
} }
QPushButton[imagePreviewControl="true"] { QPushButton[imagePreviewControl="true"] {
min-height: 28px; min-height: 28px;
padding: 0 12px; padding: 0 12px;
color: #3F4E75; color: #1A1C1F;
background: #FAFBFE; background: #F7F7F7;
border: 1px solid #D8DEEE; border: 1px solid #E4E4E5;
border-radius: 7px; border-radius: 7px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
QPushButton[imagePreviewControl="true"]:hover, QPushButton[imagePreviewControl="true"]:hover,
QPushButton[imagePreviewControl="true"]:focus { QPushButton[imagePreviewControl="true"]:focus {
color: #4451E2; color: #4156C4;
background: #F0F2FF; background: #EEF1FA;
border-color: #8D9BFF; border-color: #8B9AD9;
} }
QPushButton[imagePreviewControl="true"]:disabled { QPushButton[imagePreviewControl="true"]:disabled {
color: #A4ADC3; color: #8E8F90;
background: #F0F2F8; background: #F7F7F7;
border-color: #E6EAF5; border-color: #EDEDEE;
} }
""" """
File diff suppressed because it is too large Load Diff
@@ -18,13 +18,13 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ..infinite_list import InfiniteList
from ..theme import mark_business_dialog from ..theme import mark_business_dialog
from ..widgets import ( from ..widgets import (
BusyOverlay, BusyOverlay,
EmptyState, EmptyState,
MessageBanner, MessageBanner,
OverlayHost, OverlayHost,
Pager,
SortableTable, SortableTable,
TableColumn, TableColumn,
first_value, first_value,
@@ -33,7 +33,6 @@ from ..widgets import (
get_value, get_value,
invoke, invoke,
page_items, page_items,
page_total,
run_async, run_async,
) )
from .ai_consult import can_open_ai_consult, present_ai_consult 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 self.body.busy_overlay = self.busy_overlay
root.addWidget(self.body, 1) root.addWidget(self.body, 1)
self.pager = Pager(self.PAGE_SIZE, self) self.pager = InfiniteList(self.PAGE_SIZE, self)
self.pager.page_changed.connect(self._change_page) self.pager.bind(self.table)
root.addWidget(self.pager) root.addWidget(self.pager)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self) self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
@@ -266,42 +265,41 @@ class AiConsultTargetDialog(QDialog):
self.load(1) self.load(1)
def retry(self) -> None: def retry(self) -> None:
self.load(self._page) self.load(1)
def _change_page(self, page: int) -> None:
self.load(page)
def load(self, page: int) -> None: def load(self, page: int) -> None:
if not self._active: if not self._active:
return return
self._page = max(1, int(page))
self._generation += 1 self._generation += 1
generation = self._generation generation = self._generation
page_snapshot = self._page
keyword_snapshot = self.search_edit.text().strip() keyword_snapshot = self.search_edit.text().strip()
self._invalidate_selection() if keyword_snapshot != getattr(self, "_loaded_keyword", None):
self._set_loading(True) self._invalidate_selection()
self._loaded_keyword = keyword_snapshot
self._set_loading(not self.pager.rows)
self.banner.clear() self.banner.clear()
run_async( self.pager.reload(
lambda: invoke( lambda requested_page: invoke(
self.repository, self.repository,
"list_ai_patient_options", "list_ai_patient_options",
page_no=page_snapshot, page_no=requested_page,
page_size=self.PAGE_SIZE, page_size=self.PAGE_SIZE,
keyword=keyword_snapshot, keyword=keyword_snapshot,
), ),
on_success=lambda result: self._apply_result( apply=lambda result: self._apply_result(
result, generation, page_snapshot, keyword_snapshot result, generation, keyword_snapshot
), ),
on_error=lambda error: self._apply_error(error, generation), 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( def _apply_result(
self, self,
result: Any, result: Any,
generation: int, generation: int,
page_snapshot: int,
keyword_snapshot: str, keyword_snapshot: str,
) -> None: ) -> None:
if not self._is_current(generation): 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)) for target in (AiConsultTarget.from_row(row) for row in page_items(result))
if target is not None 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.set_rows(targets)
self.table.setSortingEnabled(False) self.table.setSortingEnabled(False)
self.table.clearSelection() self._page = self.pager.page
self.pager.update_state(page_snapshot, total) self.empty_state.setVisible(not targets and not self.pager.has_more)
self.empty_state.setVisible(not targets) self.table.setVisible(bool(targets) or self.pager.has_more)
self.table.setVisible(bool(targets))
self.banner.clear() self.banner.clear()
self._set_loading(False) self._set_loading(False)
def _apply_error(self, error: Exception, generation: int) -> None: def _apply_error(self, error: Exception, generation: int) -> None:
if not self._is_current(generation): if not self._is_current(generation):
return return
self.table.set_rows(()) if not self.pager.rows:
self.table.setSortingEnabled(False) self.table.set_rows(())
self.table.clearSelection() self.table.setSortingEnabled(False)
self.table.hide() self.table.clearSelection()
self.empty_state.show() self.table.hide()
self.pager.update_state(1, 0) self.empty_state.show()
self.banner.show_message( self.banner.show_message(
f"患者诊单加载失败:{friendly_error(error)}", "danger" f"患者诊单加载失败:{friendly_error(error)}", "danger"
) )
self._set_loading(False) 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: def _is_current(self, generation: int) -> bool:
return self._active and generation == self._generation return self._active and generation == self._generation
@@ -365,7 +359,6 @@ class AiConsultTargetDialog(QDialog):
def _set_loading(self, loading: bool) -> None: def _set_loading(self, loading: bool) -> None:
self._loading = loading self._loading = loading
self.table.setEnabled(not loading) self.table.setEnabled(not loading)
self.pager.setEnabled(not loading)
self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0) self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0)
self.busy_overlay.setVisible(loading) self.busy_overlay.setVisible(loading)
if loading: if loading:
@@ -383,6 +376,7 @@ class AiConsultTargetDialog(QDialog):
def done(self, result: int) -> None: def done(self, result: int) -> None:
self._active = False self._active = False
self._generation += 1 self._generation += 1
self.pager.invalidate()
self._search_timer.stop() self._search_timer.stop()
super().done(result) super().done(result)
@@ -130,9 +130,9 @@ class AppUpdateDialog(QDialog):
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本") self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
self.badge.setObjectName("UpdateBadge") self.badge.setObjectName("UpdateBadge")
self.badge.setStyleSheet( 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 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) root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
@@ -146,7 +146,7 @@ class AppUpdateDialog(QDialog):
latest = offer.latest_version or "新版本" latest = offer.latest_version or "新版本"
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}") self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
self.version_label.setObjectName("UpdateVersionLabel") self.version_label.setObjectName("UpdateVersionLabel")
self.version_label.setStyleSheet("color:#7886AA;") self.version_label.setStyleSheet("color:#606163;")
root.addWidget(self.version_label) root.addWidget(self.version_label)
self.notes = QTextEdit() self.notes = QTextEdit()
@@ -160,7 +160,7 @@ class AppUpdateDialog(QDialog):
self.status_label = QLabel("") self.status_label = QLabel("")
self.status_label.setObjectName("UpdateStatus") self.status_label.setObjectName("UpdateStatus")
self.status_label.setWordWrap(True) self.status_label.setWordWrap(True)
self.status_label.setStyleSheet("color:#3F4E75;") self.status_label.setStyleSheet("color:#1A1C1F;")
self.status_label.hide() self.status_label.hide()
root.addWidget(self.status_label) root.addWidget(self.status_label)
@@ -175,7 +175,7 @@ class AppUpdateDialog(QDialog):
self.progress_text = QLabel("") self.progress_text = QLabel("")
self.progress_text.setObjectName("UpdateProgressText") 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() self.progress_text.hide()
root.addWidget(self.progress_text) root.addWidget(self.progress_text)
@@ -225,7 +225,7 @@ class AppUpdateDialog(QDialog):
def show_download_progress(self, received: int, total: int) -> None: def show_download_progress(self, received: int, total: int) -> None:
self.progress.show() self.progress.show()
self.progress_text.show() self.progress_text.show()
self.status_label.setStyleSheet("color:#3F4E75;") self.status_label.setStyleSheet("color:#1A1C1F;")
self.status_label.setText("正在下载安装包…") self.status_label.setText("正在下载安装包…")
self.status_label.show() self.status_label.show()
if total > 0: if total > 0:
@@ -237,7 +237,7 @@ class AppUpdateDialog(QDialog):
self.progress_text.setText(_format_bytes(received)) self.progress_text.setText(_format_bytes(received))
def show_status(self, message: str, *, determinate: bool = False) -> None: 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.setText(message)
self.status_label.show() self.status_label.show()
self.progress.show() self.progress.show()
@@ -251,7 +251,7 @@ class AppUpdateDialog(QDialog):
def show_error(self, message: str) -> None: def show_error(self, message: str) -> None:
self._busy = False self._busy = False
self.status_label.setText(message) self.status_label.setText(message)
self.status_label.setStyleSheet("color:#F15B67;") self.status_label.setStyleSheet("color:#BE4B58;")
self.status_label.show() self.status_label.show()
self.progress.hide() self.progress.hide()
self.progress_text.hide() self.progress_text.hide()
@@ -60,6 +60,7 @@ from ..diagnosis_drawer import (
) )
from ..diagnosis_editors import DailyRecordEditorDialog from ..diagnosis_editors import DailyRecordEditorDialog
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
from ..infinite_list import InfiniteList
from ..widgets import ( from ..widgets import (
display_text, display_text,
first_value, first_value,
@@ -190,74 +191,74 @@ _ORDER_OFFSET_HELP = (
_ORDER_DETAIL_QSS = """ _ORDER_DETAIL_QSS = """
QDialog#DiagnosisOrderDetailOverlay { background: transparent; } 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 { QFrame#DiagnosisOrderDetailDrawer {
background: #F7F9FE; background: #F7F7F7;
border: 0; border: 0;
border-left: 1px solid #DDE7FF; border-left: 1px solid #F0F0F0;
} }
QFrame#DiagnosisOrderDetailHeader { QFrame#DiagnosisOrderDetailHeader {
background: #FFFFFF; background: #FFFFFF;
border: 0; border: 0;
border-bottom: 1px solid #DDE7FF; border-bottom: 1px solid #F0F0F0;
} }
QLabel#DiagnosisOrderDetailTitle { color: #15224A; font-size: 19px; font-weight: 650; } QLabel#DiagnosisOrderDetailTitle { color: #1A1C1F; font-size: 19px; font-weight: 650; }
QLabel#DiagnosisOrderDetailMeta { color: #7481A3; font-size: 12px; } QLabel#DiagnosisOrderDetailMeta { color: #606163; font-size: 12px; }
QLabel#DiagnosisOrderReadonlyBadge { QLabel#DiagnosisOrderReadonlyBadge {
color: #3F4E75; color: #1A1C1F;
background: #F7F9FE; background: #F7F7F7;
border: 1px solid #E2E7F4; border: 1px solid #EDEDEE;
border-radius: 4px; border-radius: 4px;
padding: 3px 7px; padding: 3px 7px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F9FE; } QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F7F7; }
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F9FE; } QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F7F7; }
QFrame[orderAmountCard="true"] { QFrame[orderAmountCard="true"] {
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #DDE7FF; border: 1px solid #F0F0F0;
border-radius: 9px; 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"] { QLabel[orderAmountValue="true"] {
color: #15224A; color: #1A1C1F;
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
} }
QLabel[orderAmountTone="danger"] { color: #C43E55; } QLabel[orderAmountTone="danger"] { color: #BE4B58; }
QLabel[orderAmountTone="success"] { color: #16876C; } QLabel[orderAmountTone="success"] { color: #287B65; }
QLabel[orderAmountTone="warning"] { color: #9A6813; } QLabel[orderAmountTone="warning"] { color: #A9691D; }
QFrame[orderDetailSection="true"] { QFrame[orderDetailSection="true"] {
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #E2E7F4; border: 1px solid #EDEDEE;
border-radius: 10px; border-radius: 10px;
} }
QLabel[orderSectionTitle="true"] { color: #15224A; font-size: 15px; font-weight: 650; } QLabel[orderSectionTitle="true"] { color: #1A1C1F; font-size: 15px; font-weight: 650; }
QLabel[orderSectionHint="true"] { color: #7481A3; font-size: 11px; } QLabel[orderSectionHint="true"] { color: #606163; font-size: 11px; }
QFrame[orderField="true"] { QFrame[orderField="true"] {
background: #F2F6FE; background: #F7F7F7;
border: 1px solid #E2E7F4; border: 1px solid #EDEDEE;
border-radius: 7px; border-radius: 7px;
} }
QLabel[orderFieldLabel="true"] { color: #7481A3; font-size: 11px; } QLabel[orderFieldLabel="true"] { color: #6A6B6D; font-size: 11px; }
QLabel[orderFieldValue="true"] { color: #15224A; font-size: 13px; } QLabel[orderFieldValue="true"] { color: #1A1C1F; font-size: 13px; }
QLabel[orderEmptyState="true"] { QLabel[orderEmptyState="true"] {
color: #7481A3; color: #606163;
background: #F2F6FE; background: #F7F7F7;
border: 1px dashed #C9D8F2; border: 1px dashed #E4E4E5;
border-radius: 5px; border-radius: 5px;
padding: 18px 12px; padding: 18px 12px;
font-size: 12px; font-size: 12px;
} }
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #93B4F4; } QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #8B9AD9; }
QLabel#DiagnosisOrderTimelineTime { color: #7481A3; font-size: 11px; } QLabel#DiagnosisOrderTimelineTime { color: #606163; font-size: 11px; }
QLabel#DiagnosisOrderTimelineTitle { color: #15224A; font-size: 12px; font-weight: 600; } QLabel#DiagnosisOrderTimelineTitle { color: #1A1C1F; font-size: 12px; font-weight: 600; }
QLabel#DiagnosisOrderTimelineBody { color: #7481A3; font-size: 12px; } QLabel#DiagnosisOrderTimelineBody { color: #606163; font-size: 12px; }
QFrame#DiagnosisOrderDetailFooter { QFrame#DiagnosisOrderDetailFooter {
background: #FFFFFF; background: #FFFFFF;
border: 0; 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.setCursor(Qt.CursorShape.PointingHandCursor)
self.readonly_back_button.setStyleSheet( self.readonly_back_button.setStyleSheet(
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;" "QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
"color:#5265F6;font-size:13px;font-weight:500;}" "color:#1A1C1F;font-size:13px;font-weight:500;}"
"QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}" "QPushButton:hover,QPushButton:focus{background:#EEF1FA;border-radius:6px;}"
) )
self.readonly_back_button.clicked.connect(self.reject) self.readonly_back_button.clicked.connect(self.reject)
left_layout.addWidget(self.readonly_back_button) left_layout.addWidget(self.readonly_back_button)
@@ -1532,20 +1533,11 @@ class DiagnosisDialog(QDialog):
layout.addWidget(toolbar) layout.addWidget(toolbar)
self.orders_table = self._new_table("orders", "DiagnosisTableOrders") self.orders_table = self._new_table("orders", "DiagnosisTableOrders")
layout.addWidget(self.orders_table, 1) layout.addWidget(self.orders_table, 1)
footer = QHBoxLayout() self.orders_list = InfiniteList(self._orders_page_size, page)
self.orders_summary = QLabel("共 0 条") for table in self._table_registry["orders"]:
self.orders_summary.setObjectName("DiagnosisOrdersSummary") self.orders_list.bind(table)
footer.addWidget(self.orders_summary) self._orders_footer_layout = layout
footer.addStretch(1) layout.addWidget(self.orders_list)
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)
return page return page
def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea: def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea:
@@ -2021,6 +2013,13 @@ class DiagnosisDialog(QDialog):
self._daily_todo_status = None self._daily_todo_status = None
self._orders_page = 1 self._orders_page = 1
self._orders_total = 0 self._orders_total = 0
self.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._detail = authoritative_detail if authoritative_detail is not None else seed
self.save_button.set_state("idle") self.save_button.set_state("idle")
self.refresh_permissions() self.refresh_permissions()
@@ -2315,6 +2314,7 @@ class DiagnosisDialog(QDialog):
self._generation += 1 self._generation += 1
self._save_generation += 1 self._save_generation += 1
self._orders_generation += 1 self._orders_generation += 1
self.orders_list.invalidate()
self._order_detail_generation += 1 self._order_detail_generation += 1
self._daily_mutation_generation += 1 self._daily_mutation_generation += 1
self._notes_mutation_generation += 1 self._notes_mutation_generation += 1
@@ -2639,6 +2639,9 @@ class DiagnosisDialog(QDialog):
return return
if not force and (key in self._loaded_tabs or key in self._loading_tabs): if not force and (key in self._loaded_tabs or key in self._loading_tabs):
return return
if key == "orders":
self._load_orders()
return
self._tab_generations[key] += 1 self._tab_generations[key] += 1
generation = self._tab_generations[key] generation = self._tab_generations[key]
diagnosis_id = self._diagnosis_id diagnosis_id = self._diagnosis_id
@@ -2732,10 +2735,9 @@ class DiagnosisDialog(QDialog):
self._fill_prescriptions(page_items(result)) self._fill_prescriptions(page_items(result))
elif key == "orders": elif key == "orders":
rows = page_items(result) rows = page_items(result)
self._orders_page = 1 self._orders_page = self.orders_list.page
self._orders_total = page_total(result, len(rows)) self._orders_total = page_total(result, len(rows))
self._fill_orders(rows) self._fill_orders(rows)
self._update_orders_pager()
elif key == "assign": elif key == "assign":
self._fill_assignments(page_items(result)) self._fill_assignments(page_items(result))
elif key == "appointment": elif key == "appointment":
@@ -3448,8 +3450,7 @@ class DiagnosisDialog(QDialog):
panel.set_unavailable("切换到聊天记录后加载归档数据。") panel.set_unavailable("切换到聊天记录后加载归档数据。")
for panel in self._daily_panels: for panel in self._daily_panels:
panel.clear() panel.clear()
self.orders_summary.setText("共 0 条") self.orders_list.update_state(1, 0)
self.orders_page_label.setText("1 / 1")
@staticmethod @staticmethod
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None: 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): for row_index, row in enumerate(rows):
order_id = _int(first_value(row, "id", "order_id"), 0) 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 ( if (
self._can_order_detail self._can_order_detail
and order_id > 0 and order_id > 0
@@ -4738,52 +4742,44 @@ class DiagnosisDialog(QDialog):
if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id: if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id:
self._show_message(friendly_error(error), "danger") self._show_message(friendly_error(error), "danger")
def _update_orders_pager(self) -> None: def _load_orders(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:
if not self._can_patient_orders or self._diagnosis_id <= 0: if not self._can_patient_orders or self._diagnosis_id <= 0:
return return
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size) self._tab_generations["orders"] += 1
target_page = self._orders_page + offset generation = self._tab_generations["orders"]
if target_page < 1 or target_page > pages:
return
self._orders_generation += 1
generation = self._orders_generation
diagnosis_id = self._diagnosis_id diagnosis_id = self._diagnosis_id
patient_id = self._patient_id patient_id = self._patient_id
self._show_message("正在加载患者订单…", "info") self._loading_tabs.add("orders")
run_async( if not self.orders_list.rows:
lambda: self._query_orders(diagnosis_id, patient_id, target_page), self._set_tab_loading("orders")
on_success=lambda result: self._apply_orders_page( self.orders_list.reload(
result, diagnosis_id, target_page, generation 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, self,
result: Any, result: Any,
diagnosis_id: int, diagnosis_id: int,
page: int,
generation: int, generation: int,
) -> None: ) -> 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 return
rows = page_items(result) if self.orders_list.page == 0:
self._orders_page = page self._fill_orders([])
self._orders_total = page_total(result, len(rows)) return
self._fill_orders(rows) self._apply_tab_result("orders", result, diagnosis_id, generation)
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")
def _save(self) -> None: def _save(self) -> None:
if ( if (
@@ -40,76 +40,76 @@ _STATUS_LABELS = {
"invalid": "无效录音", "invalid": "无效录音",
} }
_STATUS_COLORS = { _STATUS_COLORS = {
"recording": "#5364F5", "recording": "#1A1C1F",
"pending": "#B26A00", "pending": "#A9691D",
"uploading": "#2F6FEB", "uploading": "#1A1C1F",
"uploaded": "#07966B", "uploaded": "#287B65",
"failed": "#DC4054", "failed": "#BE4B58",
"invalid": "#7886AA", "invalid": "#606163",
} }
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8)) _BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
_LOCAL_AUDIO_QSS = """ _LOCAL_AUDIO_QSS = """
QDialog#LocalAudioQueueDialog { QDialog#LocalAudioQueueDialog {
background: #F6F8FD; background: #F7F7F7;
color: #111F46; color: #1A1C1F;
} }
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary, QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter { QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
background: #FFFFFF; background: #FFFFFF;
border: 1px solid #E2E7F4; border: 1px solid #EDEDEE;
border-radius: 14px; border-radius: 14px;
} }
QLabel#LocalAudioQueueTitle { QLabel#LocalAudioQueueTitle {
color: #111F46; color: #1A1C1F;
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
} }
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint { QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
color: #6E7C9F; color: #6A6B6D;
font-size: 13px; font-size: 13px;
} }
QLabel[queueSummary="true"] { QLabel[queueSummary="true"] {
background: #F3F5FB; background: #F7F7F7;
border: 1px solid #E6EAF5; border: 1px solid #EDEDEE;
border-radius: 10px; border-radius: 10px;
color: #3F4E75; color: #1A1C1F;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
padding: 8px 12px; padding: 8px 12px;
} }
QPushButton { QPushButton {
min-height: 34px; min-height: 34px;
border: 1px solid #D9E0F2; border: 1px solid #EDEDEE;
border-radius: 9px; border-radius: 9px;
background: #FFFFFF; background: #FFFFFF;
color: #354365; color: #1A1C1F;
padding: 0 14px; padding: 0 14px;
font-weight: 600; font-weight: 600;
} }
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; } QPushButton:hover { background: #EEF1FA; border-color: #8B9AD9; }
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; } QPushButton:disabled { color: #8E8F90; background: #F7F7F7; }
QPushButton[variant="primary"] { QPushButton[variant="primary"] {
color: #FFFFFF; color: #FFFFFF;
background: #5661F4; background: #4F63D9;
border-color: #5661F4; border-color: #4F63D9;
} }
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; } QPushButton[variant="danger"] { color: #BE4B58; background: #FFF6F7; }
QTableWidget#LocalAudioQueueTable { QTableWidget#LocalAudioQueueTable {
background: #FFFFFF; background: #FFFFFF;
alternate-background-color: #FAFBFE; alternate-background-color: #F7F7F7;
border: 0; border: 0;
gridline-color: #E8ECF5; gridline-color: #EDEDEE;
color: #263452; color: #1A1C1F;
selection-background-color: #EEF1FF; selection-background-color: #EEF1FA;
selection-color: #111F46; selection-color: #1A1C1F;
} }
QTableWidget#LocalAudioQueueTable::item { padding: 8px; } QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
QHeaderView::section { QHeaderView::section {
background: #F5F7FC; background: #F7F7F7;
color: #53617F; color: #6A6B6D;
border: 0; border: 0;
border-bottom: 1px solid #E1E6F1; border-bottom: 1px solid #EDEDEE;
padding: 10px 8px; padding: 10px 8px;
font-weight: 700; font-weight: 700;
} }
@@ -371,7 +371,7 @@ class LocalAudioQueueDialog(QDialog):
self._status_column, self._status_column,
_STATUS_LABELS.get(record.status, record.status), _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( status_item.setToolTip(
f"已尝试 {record.attempts} 次" f"已尝试 {record.attempts} 次"
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "") + (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 = """ PRESCRIPTION_AI_QSS = """
QDialog#PrescriptionAiDialog { QDialog#PrescriptionAiDialog {
color: #17203F; color: #1A1C1F;
background-color: #F7F9FE; background-color: #F7F7F7;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px; font-size: 13px;
} }
QDialog#PrescriptionAiDialog QPushButton { QDialog#PrescriptionAiDialog QPushButton {
min-height: 34px; min-height: 34px;
padding: 0 16px; padding: 0 16px;
color: #4F5B75; color: #1A1C1F;
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #DCE3F2; border: 1px solid #EDEDEE;
border-radius: 7px; border-radius: 7px;
font-weight: 500; font-weight: 500;
} }
QDialog#PrescriptionAiDialog QPushButton:hover { QDialog#PrescriptionAiDialog QPushButton:hover {
color: #4D57D8; color: #4156C4;
background-color: #F0F2FF; background-color: #EEF1FA;
border-color: #D8DCFF; border-color: #8B9AD9;
} }
QDialog#PrescriptionAiDialog QPushButton:pressed { QDialog#PrescriptionAiDialog QPushButton:pressed {
color: #FFFFFF; color: #FFFFFF;
background-color: #4D57D8; background-color: #354BB4;
border-color: #4D57D8; border-color: #354BB4;
} }
QDialog#PrescriptionAiDialog QPushButton[variant="primary"] { QDialog#PrescriptionAiDialog QPushButton[variant="primary"] {
color: #FFFFFF; color: #FFFFFF;
background-color: #5761F4; background-color: #4F63D9;
border-color: #5761F4; border-color: #4F63D9;
} }
QDialog#PrescriptionAiDialog QPushButton[variant="primary"]:hover { QDialog#PrescriptionAiDialog QPushButton[variant="primary"]:hover {
background-color: #6871F6; background-color: #4156C4;
border-color: #6871F6; border-color: #4156C4;
} }
QDialog#PrescriptionAiDialog QPushButton[variant="link"] { QDialog#PrescriptionAiDialog QPushButton[variant="link"] {
color: #4D57D8; color: #4F63D9;
background-color: transparent; background-color: transparent;
border-color: transparent; border-color: transparent;
} }
QDialog#PrescriptionAiDialog QPushButton[variant="link"]:hover { QDialog#PrescriptionAiDialog QPushButton[variant="link"]:hover {
background-color: #F0F2FF; background-color: #EEF1FA;
} }
QLabel#PrescriptionAiTitle { QLabel#PrescriptionAiTitle {
color: #17203F; color: #1A1C1F;
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
} }
QLabel#PrescriptionAiSubtitle { QLabel#PrescriptionAiSubtitle {
color: #78849D; color: #606163;
font-size: 13px; font-size: 13px;
} }
QFrame#PrescriptionAiSnapshot { QFrame#PrescriptionAiSnapshot {
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #DCE3F2; border: 1px solid #EDEDEE;
border-radius: 10px; border-radius: 10px;
} }
QLabel#PrescriptionAiSnapshotLabel { QLabel#PrescriptionAiSnapshotLabel {
color: #4D57D8; color: #1A1C1F;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
QLabel#PrescriptionAiSnapshotBody { QLabel#PrescriptionAiSnapshotBody {
color: #26304F; color: #1A1C1F;
font-size: 13px; font-size: 13px;
line-height: 1.65; line-height: 1.65;
} }
QFrame#PrescriptionAiSummary { QFrame#PrescriptionAiSummary {
background-color: #F0F2FF; background-color: #F0F0F0;
border: 0; border: 0;
border-left: 3px solid #5761F4; border-left: 3px solid #4F63D9;
border-radius: 0 9px 9px 0; border-radius: 0 9px 9px 0;
} }
QFrame#PrescriptionAiCaution { QFrame#PrescriptionAiCaution {
@@ -220,56 +220,56 @@ QFrame#PrescriptionAiCaution {
border-radius: 9px; border-radius: 9px;
} }
QLabel#PrescriptionAiSectionTitle { QLabel#PrescriptionAiSectionTitle {
color: #17203F; color: #1A1C1F;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
} }
QLabel#PrescriptionAiBody { QLabel#PrescriptionAiBody {
color: #37415E; color: #1A1C1F;
font-size: 13px; font-size: 13px;
line-height: 1.75; line-height: 1.75;
} }
QTextBrowser#PrescriptionAiAnswer { QTextBrowser#PrescriptionAiAnswer {
color: #34436B; color: #1A1C1F;
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #E3E8F4; border: 1px solid #EDEDEE;
border-radius: 10px; border-radius: 10px;
padding: 12px 14px; padding: 12px 14px;
selection-color: #17203F; selection-color: #1A1C1F;
selection-background-color: #DDE2FF; selection-background-color: #EEF1FA;
} }
QLabel#PrescriptionAiMuted { QLabel#PrescriptionAiMuted {
color: #8A94AA; color: #6A6B6D;
font-size: 12px; font-size: 12px;
line-height: 1.65; line-height: 1.65;
} }
QFrame#PrescriptionAiMeta { QFrame#PrescriptionAiMeta {
background: transparent; background: transparent;
border: 0; border: 0;
border-bottom: 1px solid #E3E8F4; border-bottom: 1px solid #EDEDEE;
} }
QDialog#PrescriptionAiDialog QTabWidget::pane { QDialog#PrescriptionAiDialog QTabWidget::pane {
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #DCE3F2; border: 1px solid #EDEDEE;
border-radius: 10px; border-radius: 10px;
top: -1px; top: -1px;
} }
QDialog#PrescriptionAiDialog QTabBar::tab { QDialog#PrescriptionAiDialog QTabBar::tab {
min-height: 36px; min-height: 36px;
padding: 0 18px; padding: 0 18px;
color: #78849D; color: #606163;
background-color: transparent; background-color: transparent;
border: 0; border: 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
} }
QDialog#PrescriptionAiDialog QTabBar::tab:hover { QDialog#PrescriptionAiDialog QTabBar::tab:hover {
color: #4D57D8; color: #4156C4;
background-color: #F5F7FC; background-color: #F7F7F7;
} }
QDialog#PrescriptionAiDialog QTabBar::tab:selected { QDialog#PrescriptionAiDialog QTabBar::tab:selected {
color: #4D57D8; color: #4F63D9;
background-color: #F0F2FF; background-color: #EEF1FA;
border-bottom: 2px solid #5761F4; border-bottom: 2px solid #4F63D9;
font-weight: 600; font-weight: 600;
} }
QDialog#PrescriptionAiDialog QScrollArea, QDialog#PrescriptionAiDialog QScrollArea,
@@ -278,15 +278,15 @@ QDialog#PrescriptionAiDialog QScrollArea > QWidget > QWidget {
border: 0; border: 0;
} }
QDialog#PrescriptionAiDialog QTextEdit { QDialog#PrescriptionAiDialog QTextEdit {
color: #17203F; color: #1A1C1F;
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #DCE3F2; border: 1px solid #EDEDEE;
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 10px;
selection-background-color: #E3E6FF; selection-background-color: #EEF1FA;
} }
QDialog#PrescriptionAiDialog QTextEdit:focus { QDialog#PrescriptionAiDialog QTextEdit:focus {
border: 1px solid #5761F4; border: 1px solid #8B9AD9;
} }
QDialog#PrescriptionAiDialog QScrollBar:vertical { QDialog#PrescriptionAiDialog QScrollBar:vertical {
width: 10px; width: 10px;
@@ -295,7 +295,7 @@ QDialog#PrescriptionAiDialog QScrollBar:vertical {
} }
QDialog#PrescriptionAiDialog QScrollBar::handle:vertical { QDialog#PrescriptionAiDialog QScrollBar::handle:vertical {
min-height: 32px; min-height: 32px;
background-color: #C8D0E0; background-color: #D2D2D3;
border-radius: 4px; border-radius: 4px;
} }
QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical, QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
@@ -305,20 +305,20 @@ QDialog#PrescriptionAiDialog QScrollBar::sub-line:vertical {
""" """
_AI_ANSWER_DOCUMENT_CSS = ( _AI_ANSWER_DOCUMENT_CSS = (
"body { color:#34436B; font-size:14px; line-height:1.72; } " "body { color:#1A1C1F; font-size:14px; line-height:1.72; } "
"h1 { color:#15224A; font-size:20px; margin:14px 0 8px; line-height:1.4; } " "h1 { color:#1A1C1F; 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; } " "h2 { color:#1A1C1F; 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; } " "h3,h4 { color:#1A1C1F; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
"p { margin:6px 0; line-height:1.72; } " "p { margin:6px 0; line-height:1.72; } "
"ul,ol { margin:7px 0 8px 22px; } li { margin:4px 0; line-height:1.65; } " "ul,ol { margin:7px 0 8px 22px; } li { margin:4px 0; line-height:1.65; } "
"strong { color:#15224A; font-weight:700; } " "strong { color:#1A1C1F; font-weight:700; } "
"blockquote { color:#596788; background:#F5F7FC; border-left:3px solid #7B84F7; " "blockquote { color:#606163; background:#F7F7F7; border-left:3px solid #8B9AD9; "
"margin:9px 0; padding:7px 10px; } " "margin:9px 0; padding:7px 10px; } "
"code { color:#33406B; background:#EEF1FF; } " "code { color:#1A1C1F; background:#F0F0F0; } "
"pre { color:#33406B; background:#F0F3FA; margin:8px 0; padding:9px; } " "pre { color:#1A1C1F; background:#F7F7F7; margin:8px 0; padding:9px; } "
"table { border-collapse:collapse; margin:8px 0; } " "table { border-collapse:collapse; margin:8px 0; } "
"th,td { border:1px solid #DCE3F2; padding:6px 8px; } " "th,td { border:1px solid #EDEDEE; padding:6px 8px; } "
"th { color:#15224A; background:#F5F7FC; font-weight:700; }" "th { color:#1A1C1F; background:#F7F7F7; font-weight:700; }"
) )
_AI_ANSWER_MARKDOWN_FEATURES = ( _AI_ANSWER_MARKDOWN_FEATURES = (
QTextDocument.MarkdownFeature.MarkdownDialectGitHub QTextDocument.MarkdownFeature.MarkdownDialectGitHub
@@ -1181,7 +1181,7 @@ class PrescriptionAiReportDialog(QDialog):
def _list_html(self, items: Any, empty: str) -> str: def _list_html(self, items: Any, empty: str) -> str:
values = [str(item).strip() for item in (items or []) if str(item).strip()] values = [str(item).strip() for item in (items or []) if str(item).strip()]
if not values: 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) bullets = "".join(f"<li>{html.escape(item)}</li>" for item in values)
return f'<ul style="margin:0;padding-left:18px;">{bullets}</ul>' return f'<ul style="margin:0;padding-left:18px;">{bullets}</ul>'
@@ -1398,7 +1398,7 @@ class PrescriptionAiReportDialog(QDialog):
content.setTextFormat(Qt.TextFormat.RichText) content.setTextFormat(Qt.TextFormat.RichText)
cell_layout.addWidget(heading) cell_layout.addWidget(heading)
cell_layout.addWidget(content) 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) self.host_layout.addWidget(grid_host)
if report.get("compatibility_analysis"): if report.get("compatibility_analysis"):
self._section("配伍分析", str(report.get("compatibility_analysis") or "")) self._section("配伍分析", str(report.get("compatibility_analysis") or ""))
+76 -95
View File
@@ -21,6 +21,7 @@ from PySide6.QtGui import (
QResizeEvent, QResizeEvent,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QCheckBox, QCheckBox,
QFrame, QFrame,
QGraphicsDropShadowEffect, QGraphicsDropShadowEffect,
@@ -43,7 +44,7 @@ from PySide6.QtWidgets import (
from doctor_workstation import __version__ from doctor_workstation import __version__
from doctor_workstation.resources import app_icon_path, brand_lockup_path 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 from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
@@ -71,7 +72,7 @@ class _VisibleCheckBox(QCheckBox):
) )
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) 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.setPen(QPen(color, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.drawLine( painter.drawLine(
QPoint(indicator.left() + 4, indicator.center().y()), QPoint(indicator.left() + 4, indicator.center().y()),
@@ -90,7 +91,7 @@ class _AccountLineEdit(QLineEdit):
super().paintEvent(event) super().paintEvent(event)
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) 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.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(QRectF(24, 14.5, 8, 8)) painter.drawEllipse(QRectF(24, 14.5, 8, 8))
painter.drawRoundedRect(QRectF(19, 27, 18, 9), 4.5, 4.5) painter.drawRoundedRect(QRectF(19, 27, 18, 9), 4.5, 4.5)
@@ -104,7 +105,7 @@ class _DemoCheckBox(_VisibleCheckBox):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
center = QPointF(self.width() - 9, self.height() / 2) 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.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(center, 7, 7) painter.drawEllipse(center, 7, 7)
painter.drawLine(center + QPointF(0, -1), center + QPointF(0, 4)) 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: 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.setPixelSize(pixel_size)
font.setWeight(weight) font.setWeight(weight)
font.setHintingPreference(QFont.HintingPreference.PreferFullHinting)
return font return font
@@ -291,10 +291,10 @@ class _BrandPanel(QWidget):
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight()) background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
background.setColorAt(0.0, QColor("#FFFFFF")) background.setColorAt(0.0, QColor("#FFFFFF"))
background.setColorAt(0.7, QColor("#FEFEFF")) background.setColorAt(0.7, QColor("#FFFFFF"))
background.setColorAt(1.0, QColor("#F9FBFF")) background.setColorAt(1.0, QColor("#F7F7F7"))
painter.setBrush(background) painter.setBrush(background)
painter.setPen(QPen(QColor("#E4E9F4"), 1)) painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.drawRoundedRect(bounds, 24, 24) painter.drawRoundedRect(bounds, 24, 24)
width, height = float(self.width()), float(self.height()) width, height = float(self.width()), float(self.height())
@@ -315,15 +315,15 @@ class _BrandPanel(QWidget):
tag_rect = QRectF(left, 238, 119, 40) tag_rect = QRectF(left, 238, 119, 40)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#F0F2FF")) painter.setBrush(QColor("#EEF1FA"))
painter.drawRoundedRect(tag_rect, 11, 11) painter.drawRoundedRect(tag_rect, 11, 11)
painter.setFont(_font(17, QFont.Weight.DemiBold)) painter.setFont(_font(17, QFont.Weight.Medium))
painter.setPen(QColor("#5265F6")) painter.setPen(QColor("#4F63D9"))
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站") painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
copy_left = left + 4 copy_left = left + 4
painter.setFont(_font(51, QFont.Weight.Bold)) painter.setFont(_font(48, QFont.Weight.Medium))
painter.setPen(QColor("#14224A")) painter.setPen(QColor("#1A1C1F"))
painter.drawText(QPointF(copy_left, 354), "把诊间工作,") painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
painter.drawText(QPointF(copy_left, 424), "留在一个") painter.drawText(QPointF(copy_left, 424), "留在一个")
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个") prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
@@ -333,8 +333,8 @@ class _BrandPanel(QWidget):
copy_left + prefix_width + 264, copy_left + prefix_width + 264,
0, 0,
) )
highlight.setColorAt(0.0, QColor("#4258EC")) highlight.setColorAt(0.0, QColor("#4F63D9"))
highlight.setColorAt(1.0, QColor("#6975FF")) highlight.setColorAt(1.0, QColor("#4F63D9"))
painter.setPen(QPen(QBrush(highlight), 1)) painter.setPen(QPen(QBrush(highlight), 1))
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里") painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
suffix_x = ( suffix_x = (
@@ -342,19 +342,19 @@ class _BrandPanel(QWidget):
+ prefix_width + prefix_width
+ painter.fontMetrics().horizontalAdvance("安静的界面里") + painter.fontMetrics().horizontalAdvance("安静的界面里")
) )
painter.setPen(QColor("#14224A")) painter.setPen(QColor("#1A1C1F"))
painter.drawText(QPointF(suffix_x, 424), "。") painter.drawText(QPointF(suffix_x, 424), "。")
body_left = left + 6 body_left = left + 6
painter.setFont(_font(20)) painter.setFont(_font(20))
painter.setPen(QColor("#7181A7")) painter.setPen(QColor("#606163"))
painter.drawText( painter.drawText(
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现," QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
) )
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。") painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
painter.setFont(_font(16)) painter.setFont(_font(16))
painter.setPen(QColor("#7484A9")) painter.setPen(QColor("#6A6B6D"))
painter.drawText( painter.drawText(
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用" QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
) )
@@ -366,7 +366,7 @@ class _RevealButton(QToolButton):
del event del event
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) 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.setPen(_round_pen(color, 2))
painter.setBrush(Qt.BrushStyle.NoBrush) painter.setBrush(Qt.BrushStyle.NoBrush)
eye = QPainterPath(QPointF(7, self.height() / 2)) eye = QPainterPath(QPointF(7, self.height() / 2))
@@ -392,11 +392,11 @@ class _ServerButton(QPushButton):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75) rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
painter.setBrush(QColor("#F8FAFF") if self.underMouse() else QColor("#FFFFFF")) painter.setBrush(QColor("#FFFFFF") if self.underMouse() else QColor("#FFFFFF"))
painter.setPen(QPen(QColor("#D8DFEE"), 1.5)) painter.setPen(QPen(QColor("#E4E4E5"), 1.5))
painter.drawRoundedRect(rect, 12, 12) painter.drawRoundedRect(rect, 12, 12)
color = QColor("#17264B" if self.isEnabled() else "#A2ABC0") color = QColor("#1A1C1F" if self.isEnabled() else "#8E8F90")
painter.setPen(_round_pen("#7C8DB2", 1.8)) painter.setPen(_round_pen("#6A6B6D", 1.8))
center = QPointF(29, self.height() / 2) center = QPointF(29, self.height() / 2)
painter.drawEllipse(center, 8, 8) painter.drawEllipse(center, 8, 8)
painter.drawEllipse(center, 2.8, 2.8) painter.drawEllipse(center, 2.8, 2.8)
@@ -420,7 +420,7 @@ class _ServerButton(QPushButton):
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, 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 x, y = self.width() - 28, self.height() / 2
if self.isChecked(): if self.isChecked():
painter.drawLine(QPointF(x - 5, y + 3), QPointF(x, y - 3)) painter.drawLine(QPointF(x - 5, y + 3), QPointF(x, y - 3))
@@ -482,12 +482,8 @@ class LoginWindow(QMainWindow):
canvas.setStyleSheet( canvas.setStyleSheet(
""" """
QWidget#LoginCanvas { QWidget#LoginCanvas {
color: #17264B; color: #1A1C1F;
background: qlineargradient( background-color: #F4F6FA;
x1:0, y1:0, x2:1, y2:1,
stop:0 #F8FAFF, stop:0.58 #FBFCFF, stop:1 #F1F5FF
);
font-family: "Microsoft YaHei UI";
font-size: 16px; font-size: 16px;
} }
QWidget#LoginBrandPanel { QWidget#LoginBrandPanel {
@@ -496,28 +492,28 @@ class LoginWindow(QMainWindow):
} }
QFrame#LoginCard { QFrame#LoginCard {
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #E1E6F0; border: 1px solid #EDEDEE;
border-radius: 20px; border-radius: 20px;
} }
QFrame#LoginCard QFrame#SubtleCard { QFrame#LoginCard QFrame#SubtleCard {
background-color: #F8FAFF; background-color: #FFFFFF;
border: 1px solid #DCE2EF; border: 1px solid #E4E4E5;
border-radius: 12px; border-radius: 12px;
} }
QFrame#LoginCard QLabel { color: #17264B; background: transparent; } QFrame#LoginCard QLabel { color: #1A1C1F; background: transparent; }
QFrame#LoginCard QLabel[role="muted"] { color: #7382A5; } QFrame#LoginCard QLabel[role="muted"] { color: #606163; }
QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; } QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; }
QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; } QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; }
QFrame#LoginCard QLineEdit, QFrame#LoginCard QLineEdit,
QFrame#LoginCard QSpinBox { QFrame#LoginCard QSpinBox {
color: #17264B; color: #1A1C1F;
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #D6DEED; border: 1px solid #E4E4E5;
border-radius: 12px; border-radius: 12px;
padding: 0 16px; padding: 0 16px;
font-size: 17px; font-size: 17px;
selection-background-color: #E5E9FF; selection-background-color: #EEF1FA;
selection-color: #17264B; selection-color: #1A1C1F;
} }
QFrame#LoginCard QLineEdit#AccountEdit { QFrame#LoginCard QLineEdit#AccountEdit {
min-height: 52px; min-height: 52px;
@@ -525,9 +521,9 @@ class LoginWindow(QMainWindow):
padding-left: 52px; padding-left: 52px;
} }
QFrame#LoginCard QLineEdit:hover, QFrame#LoginCard QLineEdit:hover,
QFrame#LoginCard QSpinBox:hover { border-color: #9AA8FF; } QFrame#LoginCard QSpinBox:hover { border-color: #8B9AD9; }
QFrame#LoginCard QLineEdit:focus, 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 { QFrame#LoginCard QLineEdit#PasswordEdit {
border: 0; border: 0;
border-radius: 0; border-radius: 0;
@@ -537,31 +533,31 @@ class LoginWindow(QMainWindow):
QFrame#LoginCard QCheckBox#DemoModeCheck { spacing: 10px; } QFrame#LoginCard QCheckBox#DemoModeCheck { spacing: 10px; }
QFrame#PasswordField { QFrame#PasswordField {
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #D6DEED; border: 1px solid #E4E4E5;
border-radius: 12px; border-radius: 12px;
} }
QFrame#PasswordField:focus-within { border-color: #7080F7; } QFrame#PasswordField:focus-within { border-color: #8B9AD9; }
QFrame#LoginCard QCheckBox { QFrame#LoginCard QCheckBox {
color: #6F7FA3; color: #606163;
spacing: 13px; spacing: 13px;
font-size: 16px; font-size: 16px;
} }
QFrame#LoginCard QCheckBox::indicator { QFrame#LoginCard QCheckBox::indicator {
width: 22px; width: 22px;
height: 22px; height: 22px;
border: 1px solid #CFD8EB; border: 1px solid #E4E4E5;
border-radius: 6px; border-radius: 6px;
background-color: #FFFFFF; background-color: #FFFFFF;
} }
QFrame#LoginCard QCheckBox::indicator:hover { border-color: #7A8AF8; } QFrame#LoginCard QCheckBox::indicator:hover { border-color: #8B9AD9; }
QFrame#LoginCard QCheckBox::indicator:checked { QFrame#LoginCard QCheckBox::indicator:checked {
border-color: #6475F5; border-color: #4F63D9;
background-color: #6475F5; background-color: #4F63D9;
} }
QFrame#LoginCard QToolButton#PasswordReveal { QFrame#LoginCard QToolButton#PasswordReveal {
min-width: 87px; min-width: 87px;
max-width: 87px; max-width: 87px;
color: #8290B0; color: #6A6B6D;
background: transparent; background: transparent;
border: 0; border: 0;
padding: 0; padding: 0;
@@ -570,14 +566,11 @@ class LoginWindow(QMainWindow):
min-height: 58px; min-height: 58px;
max-height: 58px; max-height: 58px;
color: #FFFFFF; color: #FFFFFF;
background: qlineargradient( background-color: #4F63D9;
x1:0, y1:0, x2:1, y2:0,
stop:0 #5B6BF1, stop:0.55 #6675FA, stop:1 #5865F2
);
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 500;
} }
QFrame#LoginCard QPushButton#ServerSettingsToggle { QFrame#LoginCard QPushButton#ServerSettingsToggle {
min-height: 56px; min-height: 56px;
@@ -585,17 +578,17 @@ class LoginWindow(QMainWindow):
padding: 0; padding: 0;
} }
QFrame#LoginCard QPushButton[variant="primary"]:hover { QFrame#LoginCard QPushButton[variant="primary"]:hover {
background-color: #5262ED; background-color: #4156C4;
} }
QFrame#LoginCard QPushButton[variant="secondary"] { QFrame#LoginCard QPushButton[variant="secondary"] {
color: #4353BD; color: #1A1C1F;
background-color: #EDF0FF; background-color: #F0F0F0;
border: 1px solid #D3DAFC; border: 1px solid #E4E4E5;
border-radius: 9px; border-radius: 9px;
} }
QFrame#LoginCard QPushButton[variant="secondary"]:hover { QFrame#LoginCard QPushButton[variant="secondary"]:hover {
background-color: #DCE3FF; background-color: #E4E4E5;
border-color: #8B98F8; border-color: #8B9AD9;
} }
QScrollArea#LoginAreaScroll, QScrollArea#LoginAreaScroll,
QScrollArea#LoginAreaScroll > QWidget > QWidget { QScrollArea#LoginAreaScroll > QWidget > QWidget {
@@ -629,6 +622,12 @@ class LoginWindow(QMainWindow):
spacing = 20 spacing = 20
self.login_root.setContentsMargins(*margins) self.login_root.setContentsMargins(*margins)
self.login_root.setSpacing(spacing) 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) super().resizeEvent(event)
def _build_brand_panel(self) -> QWidget: def _build_brand_panel(self) -> QWidget:
@@ -647,20 +646,19 @@ class LoginWindow(QMainWindow):
area.setFrameShape(QFrame.Shape.NoFrame) area.setFrameShape(QFrame.Shape.NoFrame)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
area.setMinimumWidth(492)
content = QWidget() content = QWidget()
content.setObjectName("LoginAreaContent") content.setObjectName("LoginAreaContent")
area.setWidget(content) area.setWidget(content)
self.login_scroll = area self.login_scroll = area
outer = QVBoxLayout(content) outer = QVBoxLayout(content)
# The supplied 1536×1024 capture contains a 60 px native title bar. self.login_area_layout = outer
# Its card begins at y=198, i.e. y=138 in the 1536×964 client area. outer.setContentsMargins(0, 40, 0, 12)
# The root starts at y=34, so the deterministic lead inset is 104 px.
outer.setContentsMargins(0, 104, 0, 0)
self.card = QFrame() self.card = QFrame()
self.card.setObjectName("LoginCard") self.card.setObjectName("LoginCard")
self.card.setFixedWidth(480) self.card.setFixedWidth(480)
self.card.setMinimumHeight(694) self.card.setMinimumHeight(620)
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum) self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
card_shadow = QGraphicsDropShadowEffect(self.card) card_shadow = QGraphicsDropShadowEffect(self.card)
card_shadow.setBlurRadius(38) card_shadow.setBlurRadius(38)
@@ -675,13 +673,13 @@ class LoginWindow(QMainWindow):
title = QLabel("欢迎回来") title = QLabel("欢迎回来")
title.setObjectName("LoginTitle") 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.setContentsMargins(1, -3, 0, 3)
title.setFixedHeight(46) title.setFixedHeight(46)
card_layout.addWidget(title) card_layout.addWidget(title)
subtitle = QLabel("使用医生账号登录工作站") subtitle = QLabel("使用医生账号登录工作站")
subtitle.setProperty("role", "muted") 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.setContentsMargins(1, 8, 0, 0)
subtitle.setFixedHeight(27) subtitle.setFixedHeight(27)
card_layout.addWidget(subtitle) card_layout.addWidget(subtitle)
@@ -691,7 +689,7 @@ class LoginWindow(QMainWindow):
card_layout.addWidget(self.error_banner) card_layout.addWidget(self.error_banner)
account_label = QLabel("账号") 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.setContentsMargins(0, -2, 0, 2)
account_label.setFixedHeight(24) account_label.setFixedHeight(24)
card_layout.addWidget(account_label) card_layout.addWidget(account_label)
@@ -707,7 +705,7 @@ class LoginWindow(QMainWindow):
card_layout.addSpacing(21) card_layout.addSpacing(21)
password_label = QLabel("密码") 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.setContentsMargins(0, -3, 0, 3)
password_label.setFixedHeight(24) password_label.setFixedHeight(24)
card_layout.addWidget(password_label) card_layout.addWidget(password_label)
@@ -777,16 +775,16 @@ class LoginWindow(QMainWindow):
divider.setSpacing(18) divider.setSpacing(18)
line_left = QFrame() line_left = QFrame()
line_left.setFrameShape(QFrame.Shape.HLine) 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.addWidget(line_left, 1)
divider_text = QLabel("或") divider_text = QLabel("或")
divider_text.setAlignment(Qt.AlignmentFlag.AlignCenter) 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_text.setFixedSize(38, 22)
divider.addWidget(divider_text) divider.addWidget(divider_text)
line_right = QFrame() line_right = QFrame()
line_right.setFrameShape(QFrame.Shape.HLine) line_right.setFrameShape(QFrame.Shape.HLine)
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;") line_right.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
divider.addWidget(line_right, 1) divider.addWidget(line_right, 1)
debug_settings_layout.addLayout(divider) debug_settings_layout.addLayout(divider)
debug_settings_layout.addSpacing(20) debug_settings_layout.addSpacing(20)
@@ -873,7 +871,7 @@ class LoginWindow(QMainWindow):
footnote_row.addWidget(lock) footnote_row.addWidget(lock)
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。") footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
footnote.setProperty("role", "muted") 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.setContentsMargins(0, -4, 0, 4)
footnote.setWordWrap(True) footnote.setWordWrap(True)
footnote.setFixedHeight(40) footnote.setFixedHeight(40)
@@ -882,12 +880,12 @@ class LoginWindow(QMainWindow):
self.version_label = QLabel(f"当前版本 {__version__}") self.version_label = QLabel(f"当前版本 {__version__}")
self.version_label.setObjectName("LoginVersionLabel") self.version_label.setObjectName("LoginVersionLabel")
self.version_label.setProperty("role", "muted") 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) self.version_label.setContentsMargins(0, 8, 0, 0)
card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight) card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight)
outer.addWidget( outer.addWidget(
self.card, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop self.card, 0, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
) )
outer.addStretch(1) outer.addStretch(1)
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…") self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
@@ -899,28 +897,11 @@ class LoginWindow(QMainWindow):
@staticmethod @staticmethod
def _account_icon() -> QIcon: def _account_icon() -> QIcon:
pixmap = crisp_pixmap(24) return icons.icon("user", "muted", 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)
@staticmethod @staticmethod
def _lock_icon() -> QIcon: def _lock_icon() -> QIcon:
pixmap = crisp_pixmap(20) return icons.icon("lock", "muted", 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)
def _restore_settings(self) -> None: def _restore_settings(self) -> None:
configured_account = getattr(self.config, "remembered_account", "") configured_account = getattr(self.config, "remembered_account", "")
@@ -10,7 +10,7 @@ from types import MappingProxyType
from typing import Any from typing import Any
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPixmap from PySide6.QtGui import QBrush, QColor, QDesktopServices, QFont, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QButtonGroup, QButtonGroup,
@@ -27,12 +27,16 @@ from PySide6.QtWidgets import (
QMenu, QMenu,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QStyle,
QStyledItemDelegate,
QStyleOptionViewItem,
QTabBar, QTabBar,
QTextEdit, QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from ..appointments_style import appointments_stylesheet
from ..dialogs import DiagnosisDialog from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
from ..dialogs.prescription import ( from ..dialogs.prescription import (
@@ -45,11 +49,14 @@ from ..dialogs.prescription_ai import (
can_open_diagnosis_ai_report, can_open_diagnosis_ai_report,
present_diagnosis_ai_report, present_diagnosis_ai_report,
) )
from ..filter_disclosure import FilterDisclosure
from ..icons import icon
from ..infinite_list import InfiniteList
from ..reception_style import heading_family
from ..theme import mark_business_dialog from ..theme import mark_business_dialog
from ..widgets import ( from ..widgets import (
MessageBanner, MessageBanner,
PageHeader, PageHeader,
Pager,
SortableTable, SortableTable,
TableColumn, TableColumn,
display_text, display_text,
@@ -84,219 +91,13 @@ NOTE_LIMIT = 500
TABLE_CELL_VERTICAL_PADDING = 10 TABLE_CELL_VERTICAL_PADDING = 10
_SEMANTIC_COLORS = { _SEMANTIC_COLORS = {
"success": "#159C79", "success": "#273244",
"warning": "#C17A16", "warning": "#273244",
"danger": "#EC5266", "danger": "#BE4B58",
"info": "#4776EE", "info": "#1555B6",
"muted": "#7481A3", "muted": "#5D6B80",
} }
APPOINTMENTS_LIGHT_QSS = """
/* 页头此前被压到只剩面包屑(26px),与其余列表页的“面包屑+标题+副标题”
骨架不一致。这里给它与 PageHeader 自然高度相符的空间。 */
#AppointmentsPage QWidget#PageHeader {
min-height: 62px;
max-height: 62px;
}
#AppointmentsPage QFrame#AppointmentFilterPanel {
min-height: 80px;
max-height: 80px;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
}
#AppointmentsPage QFrame#AppointmentMainCard {
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
}
#AppointmentsPage QPushButton[appointmentStat="true"] {
min-height: 30px;
max-height: 30px;
padding: 0 7px;
color: #59698E;
background-color: #F8F9FD;
border: 0;
border-radius: 8px;
font-size: 12px;
}
#AppointmentsPage QPushButton[appointmentStat="true"]:hover {
color: #5265F6;
background-color: #F0F2FF;
}
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
font-weight: 600;
}
/* 这一排全是筛选片,此前按业务语义分别染成紫/绿/琥珀,一行出现四种底色,
而且颜色和“是否选中”这一真正需要区分的状态互相打架。筛选片一律保持中性,
只有“待分配医助”在确有待办时才提示为琥珀色。 */
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"] {
color: #C17A16;
background-color: #FFF8ED;
border-color: #F0DCB6;
}
#AppointmentsPage QLineEdit#AppointmentPatientSearch {
min-height: 30px;
max-height: 30px;
background-color: #FFFFFF;
border: 1px solid #DDE3F0;
border-radius: 7px;
}
#AppointmentsPage QLabel#FilterRowLabel {
color: #405074;
font-size: 12px;
font-weight: 600;
}
#AppointmentsPage QLabel#FilterDivider {
color: #E2E7F4;
padding: 0 2px;
}
#AppointmentsPage QTableWidget#AppointmentTable::item {
padding: 5px 7px;
}
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
min-height: 34px;
background-color: #F8FAFF;
}
#AppointmentsPage QTableWidget#AppointmentTable {
gridline-color: #E9EDF5;
selection-background-color: #FFFFFF;
selection-color: #15224A;
}
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
width: 13px;
height: 13px;
background-color: #FFFFFF;
border: 1px solid #CBD3E7;
border-radius: 2px;
}
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked {
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
min-height: 18px;
max-height: 18px;
padding: 0 6px;
color: #5265F6;
background-color: #EEF1FF;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
}
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] {
color: #B97715;
background-color: #FFF4DF;
}
#AppointmentsPage QLabel[tableAppointmentMeta="true"] {
color: #59698E;
font-size: 10px;
}
#AppointmentsPage QPushButton[tableCancelAction="true"] {
min-height: 18px;
max-height: 18px;
padding: 0 5px;
color: #EC5266;
background-color: #FFF4F6;
border: 0;
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QWidget[appointmentImHost="true"] {
background-color: transparent;
}
#AppointmentsPage QPushButton[appointmentImAction="true"] {
min-width: 74px;
min-height: 26px;
max-height: 26px;
padding: 0 9px;
color: #5265F6;
background-color: #F0F2FF;
border: 1px solid #D7DEFF;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:focus {
border-color: #8D9BFF;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled {
color: #98A3BC;
background-color: #F7F8FC;
border-color: #E4E8F2;
}
#AppointmentsPage QWidget[appointmentInfoHost="true"] {
background-color: #FFFFFF;
}
#AppointmentsPage QPushButton[compactAction="true"] {
min-height: 28px;
max-height: 28px;
padding: 0 8px;
border-radius: 7px;
font-size: 11px;
}
#AppointmentsPage QPushButton[variant="chip"] {
min-height: 34px;
max-height: 34px;
padding: 0 12px;
color: #7481A3;
background-color: #F8FAFF;
border: 1px solid #E2E7F4;
border-radius: 8px;
}
#AppointmentsPage QPushButton[variant="chip"]:hover {
color: #15224A;
background-color: #F0F3FC;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[variant="chip"]:checked {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[variant="chip"]:focus { border-color: #8D9BFF; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
min-width: 62px;
min-height: 28px;
padding: 0 7px;
color: #7481A3;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
}
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover {
color: #15224A;
background-color: #F0F3FC;
}
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
color: #3C4FD9;
background-color: #EEF1FF;
border-bottom-color: #5265F6;
}
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 28px;
max-height: 28px;
padding: 0 10px;
color: #405074;
background-color: transparent;
border: 1px solid transparent;
border-radius: 7px;
}
#AppointmentsPage QPushButton[filterChoice="true"]:checked {
color: #5265F6;
background-color: #F0F1FF;
border-color: #E0E4FF;
}
"""
def _as_int(value: Any, default: int = 0) -> int: def _as_int(value: Any, default: int = 0) -> int:
try: try:
@@ -623,6 +424,20 @@ def _appointment_result_signature(rows: Sequence[Any], total: int, extend: Any)
) )
class _AppointmentInfoDelegate(QStyledItemDelegate):
"""Paint the row surface beneath the real appointment detail controls."""
def paint(self, painter: Any, option: Any, index: Any) -> None:
prepared = QStyleOptionViewItem(option)
self.initStyleOption(prepared, index)
# Preserve the item text for chronological sorting and tooltips while
# preventing it from showing through the transparent cell widget.
prepared.text = ""
prepared.widget.style().drawControl(
QStyle.ControlElement.CE_ItemViewItem, prepared, painter, prepared.widget
)
class AppointmentsPage(QWidget): class AppointmentsPage(QWidget):
"""Desktop appointment list with status tabs, filters, call and prescription.""" """Desktop appointment list with status tabs, filters, call and prescription."""
@@ -637,7 +452,8 @@ class AppointmentsPage(QWidget):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("AppointmentsPage") self.setObjectName("AppointmentsPage")
self.setStyleSheet(APPOINTMENTS_LIGHT_QSS) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(appointments_stylesheet())
self.repository = repository self.repository = repository
self.permissions = permissions self.permissions = permissions
self.current_user = current_user self.current_user = current_user
@@ -667,14 +483,25 @@ class AppointmentsPage(QWidget):
self._is_admin = _is_admin_user(current_user) self._is_admin = _is_admin_user(current_user)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(18, 3, 6, 8) root.setContentsMargins(30, 18, 27, 8)
root.setSpacing(4) root.setSpacing(12)
# 其余列表页都有“面包屑 + 标题 + 副标题”,这一页此前把标题隐藏了,
# 导致同一套列表页有两种页头形态。保留页头以对齐全局页面骨架。
self.header = PageHeader("挂号列表", "管理当日与近期挂号,确认到号、指派医助并进入接诊。") self.header = PageHeader("挂号列表", "管理当日与近期挂号,确认到号、指派医助并进入接诊。")
self.header.setMinimumHeight(101)
self.header.layout().setSpacing(18)
self.header.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
breadcrumb = self.header.layout().itemAt(0).layout()
for index in range(breadcrumb.count()):
label = breadcrumb.itemAt(index).widget()
if label is not None:
label.setFixedHeight(18)
self.header.actions.setAlignment(Qt.AlignmentFlag.AlignTop)
self.header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(10)
self.header.set_compact(True)
root.addWidget(self.header) root.addWidget(self.header)
self.filter_panel = self._build_filter_panel() self.filter_panel = self._build_filter_panel()
root.addWidget(self.filter_panel) root.addWidget(self.filter_panel)
self.filter_disclosure = FilterDisclosure(self, [self.filter_panel])
self.header.add_action(self.filter_disclosure.button)
self.banner = MessageBanner() self.banner = MessageBanner()
root.addWidget(self.banner) root.addWidget(self.banner)
self.content_host = self._build_content() self.content_host = self._build_content()
@@ -682,7 +509,7 @@ class AppointmentsPage(QWidget):
self.poll_timer = QTimer(self) self.poll_timer = QTimer(self)
self.poll_timer.setInterval(LIST_POLL_MS) self.poll_timer.setInterval(LIST_POLL_MS)
self.poll_timer.timeout.connect(lambda: self.refresh(silent=True)) self.poll_timer.timeout.connect(self._poll_refresh)
self._apply_responsive_layout() self._apply_responsive_layout()
def _diagnosis_dialog(self) -> DiagnosisDialog: def _diagnosis_dialog(self) -> DiagnosisDialog:
@@ -697,11 +524,11 @@ class AppointmentsPage(QWidget):
frame = QFrame() frame = QFrame()
frame.setObjectName("AppointmentFilterPanel") frame.setObjectName("AppointmentFilterPanel")
layout = QVBoxLayout(frame) layout = QVBoxLayout(frame)
layout.setContentsMargins(6, 4, 8, 6) layout.setContentsMargins(16, 16, 16, 14)
layout.setSpacing(4) layout.setSpacing(18)
date_row = QHBoxLayout() date_row = QHBoxLayout()
date_row.setSpacing(4) date_row.setSpacing(16)
self.date_buttons: dict[str, QPushButton] = {} self.date_buttons: dict[str, QPushButton] = {}
self._date_stat_labels: dict[str, str] = {} self._date_stat_labels: dict[str, str] = {}
for source_label, preset in DATE_PRESETS: for source_label, preset in DATE_PRESETS:
@@ -730,6 +557,10 @@ class AppointmentsPage(QWidget):
self.date_overflow_button.setMenu(date_menu) self.date_overflow_button.setMenu(date_menu)
date_row.addWidget(self.date_overflow_button) date_row.addWidget(self.date_overflow_button)
date_divider = QLabel("│")
date_divider.setObjectName("FilterDivider")
date_row.addWidget(date_divider)
self.pending_stat_button = QPushButton("待预约 0") self.pending_stat_button = QPushButton("待预约 0")
self.pending_stat_button.setProperty("appointmentStat", True) self.pending_stat_button.setProperty("appointmentStat", True)
self.pending_stat_button.setMinimumWidth(0) self.pending_stat_button.setMinimumWidth(0)
@@ -746,23 +577,31 @@ class AppointmentsPage(QWidget):
self.unassigned_stat_button.setProperty("appointmentStatKind", "warning") self.unassigned_stat_button.setProperty("appointmentStatKind", "warning")
self.unassigned_stat_button.setMinimumWidth(0) self.unassigned_stat_button.setMinimumWidth(0)
self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter) self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter)
date_row.addWidget(self.unassigned_stat_button, 1) date_row.addWidget(self.unassigned_stat_button)
date_row.addSpacing(52)
self.patient_input = QLineEdit() search_row = QHBoxLayout()
search_row.setSpacing(8)
search_row.addStretch(1)
self.patient_input = QLineEdit(frame)
self.patient_input.setObjectName("AppointmentPatientSearch") self.patient_input.setObjectName("AppointmentPatientSearch")
self.patient_input.setPlaceholderText("患者姓名 / 手机号") self.patient_input.setPlaceholderText("患者姓名 / 手机号")
self.patient_input.setClearButtonEnabled(True) self.patient_input.setClearButtonEnabled(True)
self.patient_input.addAction(icon("search", "#5D6B80", 16), QLineEdit.ActionPosition.LeadingPosition)
self.patient_input.returnPressed.connect(self._search) self.patient_input.returnPressed.connect(self._search)
date_row.addWidget(self.patient_input, 2) self.patient_input.setFixedWidth(270)
search_row.addWidget(self.patient_input)
search = QPushButton("查询") search = QPushButton("查询")
search.setObjectName("AppointmentSearchButton")
search.setProperty("variant", "primary") search.setProperty("variant", "primary")
search.clicked.connect(self._search) search.clicked.connect(self._search)
date_row.addWidget(search) search_row.addWidget(search)
layout.addLayout(search_row)
layout.addLayout(date_row) layout.addLayout(date_row)
filter_row = QHBoxLayout() filter_row = QHBoxLayout()
filter_row.setSpacing(5) filter_row.setSpacing(8)
self.status_filter_label = QLabel("挂号状态:") self.status_filter_label = QLabel("挂号状态:")
self.status_filter_label.setObjectName("FilterRowLabel") self.status_filter_label.setObjectName("FilterRowLabel")
filter_row.addWidget(self.status_filter_label) filter_row.addWidget(self.status_filter_label)
@@ -806,23 +645,26 @@ class AppointmentsPage(QWidget):
filter_row.addWidget(button) filter_row.addWidget(button)
self.confirmed_buttons[""].setChecked(True) self.confirmed_buttons[""].setChecked(True)
divider = QLabel("│") filter_row.addStretch(1)
divider.setObjectName("FilterDivider")
self.filter_dividers.append(divider)
filter_row.addWidget(divider)
self.more_filters_button = QPushButton("更多筛选") self.more_filters_button = QPushButton("更多筛选")
self.more_filters_button.setCheckable(True) self.more_filters_button.setCheckable(True)
self.more_filters_button.setProperty("filterChoice", True) self.more_filters_button.setProperty("filterChoice", True)
self.more_filters_button.clicked.connect(self._toggle_advanced_filters) self.more_filters_button.clicked.connect(self._toggle_advanced_filters)
filter_row.addWidget(self.more_filters_button) filter_row.addWidget(self.more_filters_button)
layout.addLayout(filter_row)
self.advanced_filters = QWidget(frame)
advanced_row = QHBoxLayout(self.advanced_filters)
advanced_row.setContentsMargins(0, 0, 0, 0)
advanced_row.setSpacing(10)
self.advanced_filters.hide()
self.dept_filter = QComboBox() self.dept_filter = QComboBox()
self.dept_filter.addItem("全部部门", "") self.dept_filter.addItem("全部部门", "")
self.dept_filter.setMinimumWidth(150) self.dept_filter.setMinimumWidth(150)
self.dept_filter.currentIndexChanged.connect(self._search) self.dept_filter.currentIndexChanged.connect(self._search)
self.dept_filter.hide() self.dept_filter.hide()
filter_row.addWidget(self.dept_filter) advanced_row.addWidget(self.dept_filter)
self.doctor_input = QLineEdit(frame) self.doctor_input = QLineEdit(frame)
self.doctor_input.setPlaceholderText("医生") self.doctor_input.setPlaceholderText("医生")
@@ -830,23 +672,23 @@ class AppointmentsPage(QWidget):
self.doctor_input.setMaximumWidth(120) self.doctor_input.setMaximumWidth(120)
self.doctor_input.hide() self.doctor_input.hide()
self.doctor_input.returnPressed.connect(self._search) self.doctor_input.returnPressed.connect(self._search)
filter_row.addWidget(self.doctor_input) advanced_row.addWidget(self.doctor_input)
filter_row.addStretch(1) advanced_row.addStretch(1)
custom = QPushButton("自定义日期") custom = QPushButton("自定义日期")
custom.setProperty("variant", "ghost") custom.setProperty("variant", "ghost")
custom.clicked.connect(self._open_custom_date) custom.clicked.connect(self._open_custom_date)
custom.hide() custom.hide()
self.custom_date_button = custom self.custom_date_button = custom
filter_row.addWidget(custom) advanced_row.addWidget(custom)
reset = QPushButton("重置") reset = QPushButton("重置")
reset.setProperty("variant", "ghost") reset.setProperty("variant", "ghost")
reset.clicked.connect(self._reset_filters) reset.clicked.connect(self._reset_filters)
reset.hide() reset.hide()
self.reset_filter_button = reset self.reset_filter_button = reset
filter_row.addWidget(reset) advanced_row.addWidget(reset)
layout.addLayout(filter_row) layout.addWidget(self.advanced_filters)
return frame return frame
def _build_content(self) -> QWidget: def _build_content(self) -> QWidget:
@@ -865,8 +707,8 @@ class AppointmentsPage(QWidget):
card.setObjectName("AppointmentMainCard") card.setObjectName("AppointmentMainCard")
card.setMinimumHeight(0) card.setMinimumHeight(0)
layout = QVBoxLayout(card) layout = QVBoxLayout(card)
layout.setContentsMargins(4, 8, 8, 8) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8) layout.setSpacing(0)
compatibility_host = QWidget(card) compatibility_host = QWidget(card)
compatibility_host.hide() compatibility_host.hide()
@@ -910,10 +752,14 @@ class AppointmentsPage(QWidget):
if isinstance(widget, QPushButton): if isinstance(widget, QPushButton):
widget.setProperty("compactAction", True) widget.setProperty("compactAction", True)
actions = QHBoxLayout() self.toolbar = QFrame(card)
actions.setSpacing(6) self.toolbar.setObjectName("AppointmentToolbar")
self.toolbar.setFixedHeight(58)
actions = QHBoxLayout(self.toolbar)
actions.setContentsMargins(10, 4, 18, 14)
actions.setSpacing(16)
self.toolbar_edit_button = QPushButton("编辑患者", card) self.toolbar_edit_button = QPushButton("编辑患者", card)
self.toolbar_edit_button.setProperty("variant", "primary") self.toolbar_edit_button.setProperty("variant", "secondary")
self.toolbar_edit_button.setProperty("compactAction", True) self.toolbar_edit_button.setProperty("compactAction", True)
self.toolbar_edit_button.setVisible( self.toolbar_edit_button.setVisible(
_canonical_allowed(self.permissions, "tcm.diagnosis/edit", default=False) _canonical_allowed(self.permissions, "tcm.diagnosis/edit", default=False)
@@ -950,30 +796,32 @@ class AppointmentsPage(QWidget):
refresh = QPushButton("刷新", card) refresh = QPushButton("刷新", card)
refresh.setProperty("variant", "ghost") refresh.setProperty("variant", "ghost")
refresh.setProperty("compactAction", True) refresh.setProperty("compactAction", True)
refresh.setIcon(icon("refresh", "#5D6B80", 16))
refresh.clicked.connect(lambda: self.refresh()) refresh.clicked.connect(lambda: self.refresh())
actions.addWidget(refresh) actions.addWidget(refresh)
layout.addLayout(actions) layout.addWidget(self.toolbar)
self.table = SortableTable( self.table = SortableTable(
[ [
TableColumn("_selected", "", 36, alignment=Qt.AlignmentFlag.AlignCenter), TableColumn("_selected", "", 48, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("id", "ID", 66), TableColumn("id", "ID", 56, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn( TableColumn(
"patient_name", "patient_name",
"患者", "患者",
100, 114,
alignment=Qt.AlignmentFlag.AlignCenter,
), ),
TableColumn("gender", "性别 / 年龄", 98, formatter=_gender_age_cell), TableColumn("gender", "性别 / 年龄", 122, formatter=_gender_age_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("appointment_date", "挂号信息", 202, formatter=_appointment_info_cell), TableColumn("appointment_date", "挂号信息", 296, formatter=_appointment_info_cell),
TableColumn("diagnosis_confirmed", "确认", 90, formatter=_confirmed_cell), TableColumn("diagnosis_confirmed", "确认", 100, formatter=_confirmed_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("revisit_time", "复诊", 100, formatter=_revisit_cell), TableColumn("revisit_time", "复诊", 80, formatter=_revisit_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("assistant_name", "助理", 110), TableColumn("assistant_name", "助理", 109, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("has_prescription", "开方", 108, formatter=_prescription_cell), TableColumn("has_prescription", "开方", 107, formatter=_prescription_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("unserved_days", "未服务天数", 112, formatter=_unserved_cell), TableColumn("unserved_days", "未服务天数", 108, formatter=_unserved_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn( TableColumn(
"_im_consult", "_im_consult",
"IM 问诊", "IM 问诊",
104, 129,
alignment=Qt.AlignmentFlag.AlignCenter, alignment=Qt.AlignmentFlag.AlignCenter,
), ),
] ]
@@ -981,24 +829,26 @@ class AppointmentsPage(QWidget):
self.table.setObjectName("AppointmentTable") self.table.setObjectName("AppointmentTable")
self.table.setMinimumHeight(0) self.table.setMinimumHeight(0)
self.table.setWordWrap(True) self.table.setWordWrap(True)
self.table.setItemDelegateForColumn(4, _AppointmentInfoDelegate(self.table))
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
header = self.table.horizontalHeader() header = self.table.horizontalHeader()
header.setFixedHeight(34) header.setFixedHeight(41)
header.setMinimumSectionSize(28)
header.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
header.setStretchLastSection(False) header.setStretchLastSection(False)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed) header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed)
self.table.verticalHeader().setDefaultSectionSize(60) self.table.verticalHeader().setDefaultSectionSize(88)
self.table.itemSelectionChanged.connect(self._selection_changed) self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail()) self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.table, 1) layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size) self.pager = InfiniteList(self._page_size)
self.pager.setMaximumHeight(38) self.pager.bind(self.table)
self.pager.page_changed.connect(self._page_changed)
layout.addWidget(self.pager) layout.addWidget(self.pager)
return card return card
def _apply_responsive_layout(self) -> None: def _apply_responsive_layout(self) -> None:
"""Collapse only the date filters when horizontal space is limited.""" """Keep the approved spacing, with denser controls on smaller desktops."""
narrow = self.width() < 1120 narrow = self.width() < 1120
for preset in ("yesterday", "day_before", "tomorrow", "day_after"): for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
@@ -1006,8 +856,32 @@ class AppointmentsPage(QWidget):
self.date_overflow_button.setVisible(narrow) self.date_overflow_button.setVisible(narrow)
self.status_filter_label.setVisible(not narrow) self.status_filter_label.setVisible(not narrow)
self.confirm_filter_label.setVisible(not narrow) self.confirm_filter_label.setVisible(not narrow)
compact = self.height() < 800
self.layout().setContentsMargins(30 if not narrow else 16, 18 if not compact else 12,
27 if not narrow else 16, 8)
self.patient_input.setFixedWidth(270 if self.width() >= 1180 else 200)
self.filter_panel.layout().setContentsMargins(16, 16 if not compact else 8, 16, 14 if not compact else 8)
self.filter_panel.layout().setSpacing(18 if not compact else 8)
self.toolbar.setFixedHeight(58 if not compact else 48)
self.toolbar.layout().setContentsMargins(10, 4, 18, 14 if not compact else 6)
self._compact_rows = compact
self._fit_table_columns()
self._fit_table_rows()
self._responsive_narrow = narrow self._responsive_narrow = narrow
def _fit_table_columns(self) -> None:
# Preserve all 11 columns; below the readable minimum the table scrolls.
widths = (48, 56, 114, 122, 296, 100, 80, 109, 107, 108, 129)
scale = min(1.0, max(0.85, (self.width() - 58) / sum(widths)))
for column, width in enumerate(widths):
if column != 4:
self.table.setColumnWidth(column, round(width * scale))
self.table.horizontalHeader().setSectionResizeMode(
4, QHeaderView.ResizeMode.Interactive if self.width() < 1080 else QHeaderView.ResizeMode.Stretch
)
if self.width() < 1080:
self.table.setColumnWidth(4, 270)
def resizeEvent(self, event: Any) -> None: def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event) super().resizeEvent(event)
self._apply_responsive_layout() self._apply_responsive_layout()
@@ -1068,6 +942,7 @@ class AppointmentsPage(QWidget):
self._search() self._search()
def _toggle_advanced_filters(self, checked: bool) -> None: def _toggle_advanced_filters(self, checked: bool) -> None:
self.advanced_filters.setVisible(checked)
self.dept_filter.setVisible(checked) self.dept_filter.setVisible(checked)
self.doctor_input.setVisible(checked and self._is_admin) self.doctor_input.setVisible(checked and self._is_admin)
self.custom_date_button.setVisible(checked) self.custom_date_button.setVisible(checked)
@@ -1171,10 +1046,6 @@ class AppointmentsPage(QWidget):
self.tab_bar.blockSignals(False) self.tab_bar.blockSignals(False)
self._set_date_preset("today") self._set_date_preset("today")
def _page_changed(self, page: int) -> None:
self._page = max(1, page)
self.refresh()
def _query_filters(self) -> dict[str, Any]: def _query_filters(self) -> dict[str, Any]:
filters: dict[str, Any] = { filters: dict[str, Any] = {
"include_status_counts": 1, "include_status_counts": 1,
@@ -1203,26 +1074,26 @@ class AppointmentsPage(QWidget):
return filters return filters
def refresh(self, *, silent: bool = False) -> None: def refresh(self, *, silent: bool = False) -> None:
if self._loading and not silent:
return
self._generation += 1 self._generation += 1
generation = self._generation generation = self._generation
self._loading = True self._loading = True
if not silent: # 列表加载不再挂横幅。横幅占布局空间,弹出与收起各触发一次重排,
self.banner.show_message("正在加载挂号列表…", "info") # 每刷新一次表格就上下跳一次——而轮询定时器每 5 秒就刷新一次。
filters = self._query_filters() # 已有数据时保持旧行可见、静默替换;失败仍然照常报错。
page = self._page filters = MappingProxyType(self._query_filters())
page_size = self._page_size page_size = self._page_size
run_async( self.pager.reload(
lambda: invoke( lambda page: invoke(
self.repository, self.repository,
"list_appointments", "list_appointments",
page_no=page, page_no=page,
page_size=page_size, page_size=page_size,
**filters, **filters,
), ),
on_success=lambda result: self._loaded(result, generation, silent), apply=lambda result: self._loaded(result, generation, silent),
on_error=lambda error: self._load_error(error, generation, silent), on_error=lambda error: self._load_error(error, generation, silent),
runner=run_async,
query_key=filters,
on_finished=lambda: self._load_finished(generation), on_finished=lambda: self._load_finished(generation),
) )
@@ -1231,6 +1102,7 @@ class AppointmentsPage(QWidget):
return return
rows = page_items(result) rows = page_items(result)
total = page_total(result) total = page_total(result)
self._page = self.pager.page
extend = get_value(result, "extend", {}) or {} extend = get_value(result, "extend", {}) or {}
signature = ( signature = (
self._page, self._page,
@@ -1335,23 +1207,28 @@ class AppointmentsPage(QWidget):
def _install_table_selectors(self) -> None: def _install_table_selectors(self) -> None:
for row_index in range(self.table.rowCount()): for row_index in range(self.table.rowCount()):
host = QWidget(self.table) host = QWidget(self.table)
host.setProperty("appointmentSelectionHost", True)
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QHBoxLayout(host) layout = QHBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
selector = QCheckBox(host) selector = QCheckBox(host)
selector.setProperty("appointmentSelector", True) selector.setProperty("appointmentSelector", True)
selector.setToolTip("选择患者") selector.setToolTip("选择患者")
selector.clicked.connect( selector.clicked.connect(lambda _checked=False, cell=host: self._select_patient(cell))
lambda checked=False, index=row_index: (
self.table.selectRow(index) if checked else None
)
)
layout.addWidget(selector) layout.addWidget(selector)
self.table.setCellWidget(row_index, 0, host) self.table.setCellWidget(row_index, 0, host)
item = self.table.item(row_index, 0) item = self.table.item(row_index, 0)
if item is not None: if item is not None:
item.setText("") item.setText("")
def _select_patient(self, cell: QWidget) -> None:
# Sorting moves cell widgets: resolve the visible row at click time.
row_index = self.table.indexAt(cell.pos()).row()
if row_index >= 0:
self.table.selectRow(row_index)
self._selection_changed()
def _install_appointment_info_cells(self) -> None: def _install_appointment_info_cells(self) -> None:
for row_index in range(self.table.rowCount()): for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0) source_item = self.table.item(row_index, 0)
@@ -1360,8 +1237,8 @@ class AppointmentsPage(QWidget):
host.setProperty("appointmentInfoHost", True) host.setProperty("appointmentInfoHost", True)
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QVBoxLayout(host) layout = QVBoxLayout(host)
layout.setContentsMargins(7, 4, 5, 3) layout.setContentsMargins(15, 8, 15, 8)
layout.setSpacing(1) layout.setSpacing(4)
heading = QHBoxLayout() heading = QHBoxLayout()
heading.setContentsMargins(0, 0, 0, 0) heading.setContentsMargins(0, 0, 0, 0)
heading.setSpacing(5) heading.setSpacing(5)
@@ -1370,10 +1247,12 @@ class AppointmentsPage(QWidget):
status.setProperty("tableAppointmentStatus", True) status.setProperty("tableAppointmentStatus", True)
status.setProperty( status.setProperty(
"tableAppointmentStatusKind", "tableAppointmentStatusKind",
"warning" if "挂号" in status_text or _status_value(row) == 1 else "primary", {1: "warning", 4: "warning", 2: "muted"}.get(_status_value(row), "primary"),
) )
heading.addWidget(status) heading.addWidget(status)
doctor = QLabel(display_text(first_value(row, "doctor_name"), "未分配"), host) doctor = QLabel(display_text(first_value(row, "doctor_name"), "未分配"), host)
doctor.setToolTip(doctor.text())
doctor.setMinimumWidth(0)
heading.addWidget(doctor) heading.addWidget(doctor)
heading.addStretch(1) heading.addStretch(1)
if _status_value(row) == 1 and _canonical_allowed( if _status_value(row) == 1 and _canonical_allowed(
@@ -1401,12 +1280,15 @@ class AppointmentsPage(QWidget):
) )
channel_label = QLabel(f"最近渠道:{channel}", host) channel_label = QLabel(f"最近渠道:{channel}", host)
channel_label.setProperty("tableAppointmentMeta", True) channel_label.setProperty("tableAppointmentMeta", True)
channel_label.setToolTip(channel_label.text())
channel_label.setMinimumWidth(0)
layout.addWidget(channel_label) layout.addWidget(channel_label)
self.table.setCellWidget(row_index, 4, host) self.table.setCellWidget(row_index, 4, host)
patient = self.table.item(row_index, 2) patient = self.table.item(row_index, 2)
if patient is not None: if patient is not None:
font = patient.font() font = QFont(heading_family())
font.setBold(True) font.setPixelSize(14)
font.setWeight(QFont.Weight.Medium)
patient.setFont(font) patient.setFont(font)
def _install_im_consult_actions(self) -> None: def _install_im_consult_actions(self) -> None:
@@ -1484,7 +1366,18 @@ class AppointmentsPage(QWidget):
default=1, default=1,
) )
height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING
self.table.setRowHeight(row_index, max(60, min(66, height))) widget_height = max(
(widget.minimumSizeHint().height()
for column in range(self.table.columnCount())
if (widget := self.table.cellWidget(row_index, column)) is not None),
default=0,
)
# Cell padding is owned by the layouts, so long/multiline records
# can grow without shrinking the approved body font.
self.table.setRowHeight(
row_index, max(80 if getattr(self, "_compact_rows", False) else 88,
height, widget_height + 1)
)
def _load_error(self, error: Exception, generation: int, silent: bool) -> None: def _load_error(self, error: Exception, generation: int, silent: bool) -> None:
if generation != self._generation: if generation != self._generation:
@@ -1497,6 +1390,10 @@ class AppointmentsPage(QWidget):
if generation == self._generation: if generation == self._generation:
self._loading = False self._loading = False
def _poll_refresh(self) -> None:
if self.isVisible() and not self.pager.loading:
self.refresh(silent=True)
def _update_tab_badges(self) -> None: def _update_tab_badges(self) -> None:
for label, value in STATUS_TABS: for label, value in STATUS_TABS:
index = self._tab_indexes.get(value) index = self._tab_indexes.get(value)
@@ -1510,6 +1407,19 @@ class AppointmentsPage(QWidget):
self.tab_bar.setTabText(index, text) self.tab_bar.setTabText(index, text)
def _selection_changed(self) -> None: def _selection_changed(self) -> None:
for row_index in range(self.table.rowCount()):
host = self.table.cellWidget(row_index, 0)
if host is None:
continue
selected = self.table.selectionModel().isRowSelected(row_index)
selector = host.findChild(QCheckBox)
if selector is not None:
selector.setChecked(selected)
if host.property("selected") != selected:
host.setProperty("selected", selected)
host.style().unpolish(host)
host.style().polish(host)
host.update()
row = self.table.current_data() row = self.table.current_data()
has_row = row is not None has_row = row is not None
status = _status_value(row) if has_row else 0 status = _status_value(row) if has_row else 0
@@ -2276,7 +2186,7 @@ class AppointmentsPage(QWidget):
def _detail_html(self, detail: Mapping[str, Any]) -> str: def _detail_html(self, detail: Mapping[str, Any]) -> str:
def cell(label: str, value: Any) -> str: def cell(label: str, value: Any) -> str:
return ( return (
f"<tr><th style='text-align:left;color:#667085;padding:4px 12px 4px 0;'>" f"<tr><th style='text-align:left;color:#606163;padding:4px 12px 4px 0;'>"
f"{escape(label)}</th>" f"{escape(label)}</th>"
f"<td style='padding:4px 0;'>{escape(display_text(value))}</td></tr>" f"<td style='padding:4px 0;'>{escape(display_text(value))}</td></tr>"
) )
@@ -10,7 +10,7 @@ from html import escape
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QPixmap from PySide6.QtGui import QDesktopServices, QPixmap
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -22,7 +22,6 @@ from PySide6.QtWidgets import (
QFormLayout, QFormLayout,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QInputDialog, QInputDialog,
QLabel, QLabel,
QLineEdit, QLineEdit,
@@ -38,11 +37,11 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from .. import icons
from ..consultations_style import consultations_stylesheet
from ..diagnosis_index_widgets import ( from ..diagnosis_index_widgets import (
DIAGNOSIS_INDEX_QSS,
DiagnosisChip, DiagnosisChip,
DiagnosisLoadingOverlay, DiagnosisLoadingOverlay,
DiagnosisPager,
DiagnosisTableHost, DiagnosisTableHost,
FlowWidget, FlowWidget,
prescription_action, prescription_action,
@@ -56,6 +55,8 @@ from ..dialogs.prescription import (
build_prescription_clinical_diagnosis, build_prescription_clinical_diagnosis,
build_prescription_visit_no, build_prescription_visit_no,
) )
from ..filter_disclosure import FilterDisclosure
from ..infinite_list import InfiniteList
from ..theme import mark_business_dialog from ..theme import mark_business_dialog
from ..widgets import ( from ..widgets import (
MessageBanner, MessageBanner,
@@ -72,118 +73,9 @@ from ..widgets import (
show_toast, show_toast,
) )
_PAGE_HEADER_HEIGHT = 62 _STATUS_CARD_HEIGHT = 54
_STATUS_CARD_HEIGHT = 50 _FILTERS_BASE_HEIGHT = 236
_FILTERS_COLLAPSED_HEIGHT = 90
CONSULTATIONS_REFERENCE_QSS = """
#DiagnosisIndex QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#DiagnosisIndex QLabel[role="pageTitle"] {
color: #15224A; font-size: 22px; font-weight: 700;
}
#DiagnosisIndex QLabel[role="muted"] { color: #7481A3; font-size: 12px; }
#DiagnosisIndex QLabel[role="breadcrumb"],
#DiagnosisIndex QLabel[role="breadcrumbCurrent"] { font-size: 12px; }
#DiagnosisIndex QFrame#DiagnosisStatusCard,
#DiagnosisIndex QFrame#DiagnosisFilterCard,
#DiagnosisIndex QFrame#DiagnosisListCard {
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard {
min-height: 50px; max-height: 50px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"] {
min-height: 34px; max-height: 34px; min-width: 56px;
padding: 0 8px; border: 0; border-radius: 8px;
background: transparent; color: #25345E; font-size: 13px; font-weight: 600;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"]:hover {
background: #F7F8FF; color: #5265F6;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"]:checked {
background: #F0F1FF; color: #5265F6;
border-bottom: 2px solid #6573F7;
}
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit {
min-width: 78px; max-width: 138px;
}
#DiagnosisIndex QDateEdit#DiagnosisCustomDate {
min-width: 116px; max-width: 116px;
}
#DiagnosisIndex QFrame#DiagnosisFilterCard {
min-height: 88px;
}
#DiagnosisIndex QWidget#DiagnosisDateFilters,
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters {
background: transparent; border: 0;
}
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters QLineEdit#DiagnosisKeyword {
min-width: 260px; max-width: 380px;
}
#DiagnosisIndex QComboBox#DiagnosisConfirmationFilter { min-width: 136px; max-width: 136px; }
#DiagnosisIndex QComboBox#DiagnosisDepartmentFilter { min-width: 210px; max-width: 230px; }
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
min-height: 20px; padding: 6px 12px; border-radius: 8px;
background: #F7F9FE; color: #405074; font-size: 12px;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked {
background: #EEF1FF; color: #5265F6; border-color: #C9D0FF;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="primary"],
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="info"] {
background: #EEF1FF; color: #5265F6; border-color: #C9D0FF;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="success"] {
background: #EAF9F4; color: #159C79; border-color: #BFE9DC;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="warning"] {
background: #FFF5E4; color: #C17A16; border-color: #F2D49D;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"] {
min-height: 18px; padding: 4px 10px; font-size: 12px;
}
#DiagnosisIndex QLineEdit,
#DiagnosisIndex QComboBox,
#DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox {
min-height: 32px; max-height: 32px; border-radius: 8px;
background: #FFFFFF; border-color: #E2E7F4; color: #15224A;
font-size: 12px;
}
#DiagnosisIndex QPushButton {
min-height: 34px; max-height: 34px; padding: 0 14px;
border-radius: 8px; font-size: 12px;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar {
min-height: 44px; max-height: 44px; background: #FFFFFF;
border-bottom: 1px solid #E7EBF5;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton {
padding: 0 13px;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton[consultationTool="true"] {
color: #5265F6; background: #F7F8FF; border: 1px solid #E0E5F8;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton[consultationDanger="true"] {
color: #F15B67; background: #FFF7F8; border: 1px solid #FFD6DB;
}
#DiagnosisIndex QFrame#DiagnosisListCard { border-radius: 12px; }
#DiagnosisIndex QHeaderView::section {
min-height: 38px; max-height: 38px; padding: 0 8px;
background: #F7F9FE; color: #7481A3;
border-bottom: 1px solid #E7EBF5; font-size: 12px;
}
#DiagnosisIndex QTableView { background: #FFFFFF; alternate-background-color: #FBFCFF; }
#DiagnosisIndex QToolButton[rowLink] { font-size: 11px; padding: 2px; }
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 42px; max-height: 42px; }
#DiagnosisIndex QToolButton[pagerButton="true"] {
min-width: 32px; min-height: 32px; max-height: 32px;
border: 1px solid #E2E7F4; border-radius: 7px; background: #FFFFFF;
}
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
"""
APPOINTMENT_STATUS = { APPOINTMENT_STATUS = {
1: ("已预约", "warning"), 1: ("已预约", "warning"),
@@ -829,8 +721,8 @@ class _QrImagePreview(QLabel):
self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setWordWrap(True) self.setWordWrap(True)
self.setStyleSheet( self.setStyleSheet(
"QLabel#DiagnosisOrderQrPreview{background:#FFFFFF;border:1px solid #E1E6F2;" "QLabel#DiagnosisOrderQrPreview{background:#FFFFFF;border:1px solid #EDEDEE;"
"border-radius:12px;color:#7481A3;padding:8px;}" "border-radius:12px;color:#606163;padding:8px;}"
) )
self._manager = QNetworkAccessManager(self) self._manager = QNetworkAccessManager(self)
self._reply: QNetworkReply | None = None self._reply: QNetworkReply | None = None
@@ -973,7 +865,7 @@ class _DiagnosisOrderQrDialog(QDialog):
self.qrcode_url = "" self.qrcode_url = ""
self.url_edit.clear() self.url_edit.clear()
self.status_label.setText("正在生成付款二维码...") self.status_label.setText("正在生成付款二维码...")
self.status_label.setStyleSheet("color:#7481A3;") self.status_label.setStyleSheet("color:#606163;")
self.preview.show_loading() self.preview.show_loading()
self.open_button.setEnabled(False) self.open_button.setEnabled(False)
self.retry_button.setEnabled(False) self.retry_button.setEnabled(False)
@@ -982,7 +874,7 @@ class _DiagnosisOrderQrDialog(QDialog):
self.qrcode_url = "" self.qrcode_url = ""
self.url_edit.clear() self.url_edit.clear()
self.status_label.setText(message) self.status_label.setText(message)
self.status_label.setStyleSheet("color:#EC5266;") self.status_label.setStyleSheet("color:#BE4B58;")
self.preview.show_failure("付款二维码生成失败") self.preview.show_failure("付款二维码生成失败")
self.open_button.setEnabled(False) self.open_button.setEnabled(False)
self.retry_button.setEnabled(retryable) self.retry_button.setEnabled(retryable)
@@ -993,7 +885,7 @@ class _DiagnosisOrderQrDialog(QDialog):
self.url_edit.setCursorPosition(0) self.url_edit.setCursorPosition(0)
self.url_edit.setToolTip(qrcode_url) self.url_edit.setToolTip(qrcode_url)
self.status_label.setText("付款二维码已生成") self.status_label.setText("付款二维码已生成")
self.status_label.setStyleSheet("color:#159C79;") self.status_label.setStyleSheet("color:#287B65;")
self.preview.load_url(qrcode_url) self.preview.load_url(qrcode_url)
self.open_button.setEnabled(True) self.open_button.setEnabled(True)
self.retry_button.setEnabled(True) self.retry_button.setEnabled(True)
@@ -1003,6 +895,19 @@ class _DiagnosisOrderQrDialog(QDialog):
QDesktopServices.openUrl(QUrl.fromUserInput(self.qrcode_url)) QDesktopServices.openUrl(QUrl.fromUserInput(self.qrcode_url))
def _tool_icon(button: QPushButton, kind: str, role: str = "soft") -> QPushButton:
"""Put a shared glyph on a toolbar button.
This page's toolbar was the only one in the product with no icons at all -
seven bare text buttons in a row - and it faked the one it did want with a
full-width "+" character in the label.
"""
button.setIcon(icons.icon(kind, role, 15))
button.setIconSize(QSize(15, 15))
return button
class ConsultationsPage(QWidget): class ConsultationsPage(QWidget):
"""Diagnosis workspace with canonical filters and guarded row actions.""" """Diagnosis workspace with canonical filters and guarded row actions."""
@@ -1017,7 +922,7 @@ class ConsultationsPage(QWidget):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("DiagnosisIndex") self.setObjectName("DiagnosisIndex")
self.setStyleSheet(DIAGNOSIS_INDEX_QSS + CONSULTATIONS_REFERENCE_QSS) self.setStyleSheet(consultations_stylesheet())
self.repository = repository self.repository = repository
self.permissions = permissions self.permissions = permissions
self.current_user = current_user self.current_user = current_user
@@ -1076,15 +981,20 @@ class ConsultationsPage(QWidget):
content.setAutoFillBackground(False) content.setAutoFillBackground(False)
self.page_scroll.setWidget(content) self.page_scroll.setWidget(content)
page_layout = QVBoxLayout(content) page_layout = QVBoxLayout(content)
page_layout.setContentsMargins(18, 10, 18, 10) page_layout.setContentsMargins(27, 24, 26, 8)
page_layout.setSpacing(8) page_layout.setSpacing(10)
self.page_header = PageHeader( self.page_header = PageHeader(
"问诊列表", "问诊列表",
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。", "按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
content, content,
) )
self.page_header.setFixedHeight(_PAGE_HEADER_HEIGHT) self.page_header.setFixedHeight(90)
self.page_header.layout().setContentsMargins(0, 0, 0, 6)
self.page_header.layout().setSpacing(12)
self.page_header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(10)
self.page_header.actions.setAlignment(Qt.AlignmentFlag.AlignTop)
self.page_header.set_compact(True)
page_layout.addWidget(self.page_header) page_layout.addWidget(self.page_header)
status_card = QFrame() status_card = QFrame()
@@ -1092,8 +1002,11 @@ class ConsultationsPage(QWidget):
status_card.setFixedHeight(_STATUS_CARD_HEIGHT) status_card.setFixedHeight(_STATUS_CARD_HEIGHT)
self.status_card = status_card self.status_card = status_card
status_card_layout = QHBoxLayout(status_card) status_card_layout = QHBoxLayout(status_card)
status_card_layout.setContentsMargins(12, 5, 12, 5) status_card_layout.setContentsMargins(0, 0, 0, 0)
status_card_layout.setSpacing(10) status_card_layout.setSpacing(8)
status_label = self._filter_label("问诊状态:")
status_label.setFixedWidth(80)
status_card_layout.addWidget(status_label)
status_tabs = QWidget(status_card) status_tabs = QWidget(status_card)
status_tabs.setObjectName("DiagnosisStatusTabs") status_tabs.setObjectName("DiagnosisStatusTabs")
@@ -1114,31 +1027,36 @@ class ConsultationsPage(QWidget):
button.clicked.connect( button.clicked.connect(
lambda _checked=False, selected=value: self._choose_status(selected) lambda _checked=False, selected=value: self._choose_status(selected)
) )
status_tabs_layout.addWidget(button, 1) status_tabs_layout.addWidget(button)
self.status_buttons[value] = button self.status_buttons[value] = button
self.completed_button = self.status_buttons["3"] self.completed_button = self.status_buttons["3"]
status_card_layout.addWidget(status_tabs, 3) status_card_layout.addWidget(status_tabs)
status_card_layout.addStretch(1)
status_search = QWidget(status_card) status_search = QWidget(status_card)
status_search.setObjectName("DiagnosisStatusSearch") status_search.setObjectName("DiagnosisStatusSearch")
status_search.setFixedWidth(518)
status_search_layout = QHBoxLayout(status_search) status_search_layout = QHBoxLayout(status_search)
status_search_layout.setContentsMargins(0, 0, 0, 0) status_search_layout.setContentsMargins(0, 0, 0, 0)
status_search_layout.setSpacing(6) status_search_layout.setSpacing(16)
self.patient_name_edit = QLineEdit(status_search) self.patient_name_edit = QLineEdit(status_search)
self.patient_name_edit.setObjectName("DiagnosisPatientName") self.patient_name_edit.setObjectName("DiagnosisPatientName")
self.patient_name_edit.setFixedWidth(172)
self.patient_name_edit.addAction(icons.icon("search", "muted", 16), QLineEdit.ActionPosition.LeadingPosition)
self.patient_name_edit.setPlaceholderText("患者姓名") self.patient_name_edit.setPlaceholderText("患者姓名")
self.patient_name_edit.setClearButtonEnabled(True) self.patient_name_edit.setClearButtonEnabled(True)
self.patient_name_edit.returnPressed.connect(self._search) self.patient_name_edit.returnPressed.connect(self._search)
status_search_layout.addWidget(self.patient_name_edit, 1) status_search_layout.addWidget(self.patient_name_edit, 1)
self.doctor_edit = QLineEdit(status_search) self.doctor_edit = QLineEdit(status_search)
self.doctor_edit.setObjectName("DiagnosisDoctorName") self.doctor_edit.setObjectName("DiagnosisDoctorName")
self.doctor_edit.setFixedWidth(153)
self.doctor_edit.setPlaceholderText("医生") self.doctor_edit.setPlaceholderText("医生")
self.doctor_edit.setClearButtonEnabled(True) self.doctor_edit.setClearButtonEnabled(True)
self.doctor_edit.returnPressed.connect(self._search) self.doctor_edit.returnPressed.connect(self._search)
status_search_layout.addWidget(self.doctor_edit, 1) status_search_layout.addWidget(self.doctor_edit, 1)
self.search_button = QPushButton("查询", status_search) self.search_button = QPushButton("查询", status_search)
self.search_button.setProperty("variant", "primary") self.search_button.setProperty("variant", "primary")
self.search_button.setFixedWidth(62) self.search_button.setFixedWidth(73)
self.search_button.clicked.connect(self._search) self.search_button.clicked.connect(self._search)
status_search_layout.addWidget(self.search_button) status_search_layout.addWidget(self.search_button)
self.custom_date_edit = QDateEdit(status_search) self.custom_date_edit = QDateEdit(status_search)
@@ -1150,29 +1068,38 @@ class ConsultationsPage(QWidget):
self.custom_date_edit.setDate(_OPTIONAL_DATE_MINIMUM) self.custom_date_edit.setDate(_OPTIONAL_DATE_MINIMUM)
self.custom_date_edit.setToolTip("自定义挂号日期") self.custom_date_edit.setToolTip("自定义挂号日期")
self.custom_date_edit.dateChanged.connect(self._custom_date_changed) self.custom_date_edit.dateChanged.connect(self._custom_date_changed)
status_search_layout.addWidget(self.custom_date_edit) self.custom_date_edit.setFixedWidth(202)
self.reset_button = QPushButton("重置", status_search) self.reset_button = QPushButton("重置", status_search)
self.reset_button.setFixedWidth(58) self.reset_button.setFixedWidth(72)
self.reset_button.clicked.connect(self._reset) self.reset_button.clicked.connect(self._reset)
status_search_layout.addWidget(self.reset_button) status_search_layout.addWidget(self.reset_button)
status_card_layout.addWidget(status_search, 2)
page_layout.addWidget(status_card)
filters = QFrame() filters = QFrame()
filters.setObjectName("DiagnosisFilterCard") filters.setObjectName("DiagnosisFilterCard")
self.filters_card = filters self.filters_card = filters
filter_layout = QVBoxLayout(filters) filter_layout = QVBoxLayout(filters)
filter_layout.setContentsMargins(12, 6, 12, 6) filter_layout.setContentsMargins(18, 8, 18, 12)
filter_layout.setSpacing(4) filter_layout.setSpacing(8)
search_row = QHBoxLayout()
search_row.setContentsMargins(0, 0, 0, 0)
search_row.addStretch(1)
search_row.addWidget(status_search)
status_search.setFixedHeight(44)
filter_layout.addLayout(search_row)
filter_layout.addWidget(status_card)
date_filters = QWidget(filters) date_filters = QWidget(filters)
date_filters.setObjectName("DiagnosisDateFilters") date_filters.setObjectName("DiagnosisDateFilters")
date_filters.setMinimumHeight(48)
self.date_filters = date_filters
main_filters = QHBoxLayout(date_filters) main_filters = QHBoxLayout(date_filters)
main_filters.setContentsMargins(0, 0, 0, 0) main_filters.setContentsMargins(0, 0, 0, 0)
main_filters.setSpacing(8) main_filters.setSpacing(8)
main_filters.setAlignment(Qt.AlignmentFlag.AlignVCenter) main_filters.setAlignment(Qt.AlignmentFlag.AlignVCenter)
main_filters.addWidget(self._filter_label("日期:")) date_label = self._filter_label("日期:")
self.main_chip_flow = FlowWidget(horizontal_spacing=6, vertical_spacing=6) date_label.setFixedWidth(80)
main_filters.addWidget(date_label)
self.main_chip_flow = FlowWidget(horizontal_spacing=24, vertical_spacing=6)
self.date_buttons: dict[str, DiagnosisChip] = {} self.date_buttons: dict[str, DiagnosisChip] = {}
self._date_button_labels: dict[str, str] = {} self._date_button_labels: dict[str, str] = {}
for label, offset in ( for label, offset in (
@@ -1184,6 +1111,7 @@ class ConsultationsPage(QWidget):
): ):
value = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd") value = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
button = DiagnosisChip(label) button = DiagnosisChip(label)
button.setProperty("dateChoice", True)
button.clicked.connect( button.clicked.connect(
lambda _checked=False, date_value=value: self._choose_date(date_value) lambda _checked=False, date_value=value: self._choose_date(date_value)
) )
@@ -1191,6 +1119,7 @@ class ConsultationsPage(QWidget):
self._date_button_labels[value] = label self._date_button_labels[value] = label
self.main_chip_flow.flow.addWidget(button) self.main_chip_flow.flow.addWidget(button)
all_button = DiagnosisChip("全部") all_button = DiagnosisChip("全部")
all_button.setProperty("dateChoice", True)
all_button.clicked.connect(lambda _checked=False: self._choose_date("")) all_button.clicked.connect(lambda _checked=False: self._choose_date(""))
self.date_buttons[""] = all_button self.date_buttons[""] = all_button
self._date_button_labels[""] = "全部" self._date_button_labels[""] = "全部"
@@ -1229,6 +1158,7 @@ class ConsultationsPage(QWidget):
self.pending_assign_filters.hide() self.pending_assign_filters.hide()
self.pending_assign_wrap.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign")) self.pending_assign_wrap.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
main_filters.addWidget(self.main_chip_flow, 1) main_filters.addWidget(self.main_chip_flow, 1)
main_filters.addWidget(self.custom_date_edit)
filter_layout.addWidget(date_filters) filter_layout.addWidget(date_filters)
self.has_appointment_combo = self._fixed_combo( self.has_appointment_combo = self._fixed_combo(
@@ -1261,26 +1191,39 @@ class ConsultationsPage(QWidget):
secondary_filters = QWidget(filters) secondary_filters = QWidget(filters)
secondary_filters.setObjectName("DiagnosisSecondaryFilters") secondary_filters.setObjectName("DiagnosisSecondaryFilters")
secondary_filters.setMinimumHeight(46)
self.secondary_filters = secondary_filters
secondary_layout = QHBoxLayout(secondary_filters) secondary_layout = QHBoxLayout(secondary_filters)
secondary_layout.setContentsMargins(0, 0, 0, 0) secondary_layout.setContentsMargins(0, 0, 0, 0)
secondary_layout.setSpacing(8) secondary_layout.setSpacing(8)
secondary_layout.addWidget(self._filter_label("确认诊单:")) self.diagnosis_confirmed_combo.setFixedWidth(126)
secondary_layout.addWidget(self.diagnosis_confirmed_combo) self.department_combo.setFixedWidth(178)
secondary_layout.addWidget(self._filter_label("部门:")) self.secondary_filter_flow = FlowWidget(horizontal_spacing=40, vertical_spacing=8)
secondary_layout.addWidget(self.department_combo) self.secondary_filter_flow.flow.addWidget(
self._filter_field("确认诊单:", self.diagnosis_confirmed_combo)
)
self.secondary_filter_flow.flow.addWidget(
self._filter_field("部门:", self.department_combo)
)
self.keyword_edit = QLineEdit(secondary_filters) self.keyword_edit = QLineEdit(secondary_filters)
self.keyword_edit.setObjectName("DiagnosisKeyword") self.keyword_edit.setObjectName("DiagnosisKeyword")
self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号") self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号")
self.keyword_edit.setClearButtonEnabled(True) self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.setMinimumWidth(240) self.keyword_edit.addAction(icons.icon("search", "muted", 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.setMaximumWidth(380) self.keyword_edit.setMinimumWidth(185)
self.keyword_edit.setMaximumWidth(340)
self.keyword_edit.returnPressed.connect(self._search) self.keyword_edit.returnPressed.connect(self._search)
secondary_layout.addWidget(self.keyword_edit, 1) self.keyword_edit.setFixedWidth(340)
secondary_layout.addStretch(1) self.secondary_filter_flow.flow.addWidget(self.keyword_edit)
secondary_layout.addWidget(self.secondary_filter_flow, 1)
self.more_filter_button = QToolButton(secondary_filters) self.more_filter_button = QToolButton(secondary_filters)
self.more_filter_button.setObjectName("DiagnosisMoreFilter") self.more_filter_button.setObjectName("DiagnosisMoreFilter")
self.more_filter_button.setText("更多筛选") self.more_filter_button.setText("更多筛选")
self.more_filter_button.setArrowType(Qt.ArrowType.DownArrow) # The same Fusion solid triangle the row overflow used - a filled mark in
# an all-stroke set. A disclosure caret does belong before its label, so
# only the glyph changes here, not the side.
self.more_filter_button.setIcon(icons.icon("down", "muted", 14))
self.more_filter_button.setIconSize(QSize(14, 14))
self.more_filter_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) self.more_filter_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.more_filter_button.setCheckable(True) self.more_filter_button.setCheckable(True)
self.more_filter_button.clicked.connect(self._toggle_advanced_filters) self.more_filter_button.clicked.connect(self._toggle_advanced_filters)
@@ -1367,8 +1310,10 @@ class ConsultationsPage(QWidget):
advanced_layout.addWidget(self.advanced_filter_flow) advanced_layout.addWidget(self.advanced_filter_flow)
self.advanced_filters.hide() self.advanced_filters.hide()
filter_layout.addWidget(self.advanced_filters) filter_layout.addWidget(self.advanced_filters)
filters.setFixedHeight(_FILTERS_COLLAPSED_HEIGHT) filters.setFixedHeight(_FILTERS_BASE_HEIGHT)
page_layout.addWidget(filters) page_layout.addWidget(filters)
self.filter_disclosure = FilterDisclosure(self, [filters])
self.page_header.add_action(self.filter_disclosure.button)
card = QFrame() card = QFrame()
card.setObjectName("DiagnosisListCard") card.setObjectName("DiagnosisListCard")
@@ -1380,14 +1325,17 @@ class ConsultationsPage(QWidget):
toolbar = QFrame() toolbar = QFrame()
toolbar.setObjectName("DiagnosisListToolbar") toolbar.setObjectName("DiagnosisListToolbar")
toolbar_layout = QHBoxLayout(toolbar) toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(12, 4, 12, 4) toolbar_layout.setContentsMargins(18, 12, 18, 12)
toolbar_layout.setSpacing(6) toolbar_layout.setSpacing(8)
self.add_button = QPushButton("+ 新增患者", toolbar) self.list_toolbar = toolbar
self.add_button = _tool_icon(QPushButton("新增患者", toolbar), "plus", "inverse")
self.add_button.setProperty("variant", "primary") self.add_button.setProperty("variant", "primary")
self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add")) self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add"))
self.add_button.clicked.connect(self._add_diagnosis) self.add_button.clicked.connect(self._add_diagnosis)
toolbar_layout.addWidget(self.add_button) toolbar_layout.addWidget(self.add_button)
self.video_qr_toolbar_button = QPushButton("视频二维码", toolbar) self.video_qr_toolbar_button = _tool_icon(
QPushButton("视频二维码", toolbar), "qr"
)
self.video_qr_toolbar_button.setProperty("consultationTool", True) self.video_qr_toolbar_button.setProperty("consultationTool", True)
self.video_qr_toolbar_button.setVisible( self.video_qr_toolbar_button.setVisible(
bool( bool(
@@ -1397,28 +1345,34 @@ class ConsultationsPage(QWidget):
) )
self.video_qr_toolbar_button.clicked.connect(self._request_video_qr) self.video_qr_toolbar_button.clicked.connect(self._request_video_qr)
toolbar_layout.addWidget(self.video_qr_toolbar_button) toolbar_layout.addWidget(self.video_qr_toolbar_button)
self.call_toolbar_button = QPushButton("通话", toolbar) self.call_toolbar_button = _tool_icon(QPushButton("通话", toolbar), "video")
self.call_toolbar_button.setProperty("consultationTool", True) self.call_toolbar_button.setProperty("consultationTool", True)
self.call_toolbar_button.setVisible(self._native_video_capable) self.call_toolbar_button.setVisible(self._native_video_capable)
self.call_toolbar_button.clicked.connect(self._request_video) self.call_toolbar_button.clicked.connect(self._request_video)
toolbar_layout.addWidget(self.call_toolbar_button) toolbar_layout.addWidget(self.call_toolbar_button)
self.complete_toolbar_button = QPushButton("完成", toolbar) self.complete_toolbar_button = _tool_icon(
QPushButton("完成", toolbar), "check_circle"
)
self.complete_toolbar_button.setProperty("consultationTool", True) self.complete_toolbar_button.setProperty("consultationTool", True)
self.complete_toolbar_button.setToolTip("查看当前问诊详情并完成问诊") self.complete_toolbar_button.setToolTip("查看当前问诊详情并完成问诊")
self.complete_toolbar_button.clicked.connect(self._open_readonly) self.complete_toolbar_button.clicked.connect(self._open_readonly)
toolbar_layout.addWidget(self.complete_toolbar_button) toolbar_layout.addWidget(self.complete_toolbar_button)
self.prescription_toolbar_button = QPushButton("开方", toolbar) self.prescription_toolbar_button = _tool_icon(
QPushButton("开方", toolbar), "prescriptions"
)
self.prescription_toolbar_button.setProperty("consultationTool", True) self.prescription_toolbar_button.setProperty("consultationTool", True)
self.prescription_toolbar_button.setVisible( self.prescription_toolbar_button.setVisible(
_canonical_allowed(permissions, "tcm.diagnosis/kaifang") _canonical_allowed(permissions, "tcm.diagnosis/kaifang")
) )
self.prescription_toolbar_button.clicked.connect(self._open_prescription) self.prescription_toolbar_button.clicked.connect(self._open_prescription)
toolbar_layout.addWidget(self.prescription_toolbar_button) toolbar_layout.addWidget(self.prescription_toolbar_button)
self.case_toolbar_button = QPushButton("病历", toolbar) self.case_toolbar_button = _tool_icon(QPushButton("病历", toolbar), "document")
self.case_toolbar_button.setProperty("consultationTool", True) self.case_toolbar_button.setProperty("consultationTool", True)
self.case_toolbar_button.clicked.connect(self._open_readonly) self.case_toolbar_button.clicked.connect(self._open_readonly)
toolbar_layout.addWidget(self.case_toolbar_button) toolbar_layout.addWidget(self.case_toolbar_button)
self.cancel_toolbar_button = QPushButton("取消挂号", toolbar) self.cancel_toolbar_button = _tool_icon(
QPushButton("取消挂号", toolbar), "stop", "danger"
)
self.cancel_toolbar_button.setProperty("consultationDanger", True) self.cancel_toolbar_button.setProperty("consultationDanger", True)
self.cancel_toolbar_button.setVisible( self.cancel_toolbar_button.setVisible(
bool( bool(
@@ -1428,7 +1382,9 @@ class ConsultationsPage(QWidget):
) )
self.cancel_toolbar_button.clicked.connect(self._cancel_selected_appointment) self.cancel_toolbar_button.clicked.connect(self._cancel_selected_appointment)
toolbar_layout.addWidget(self.cancel_toolbar_button) toolbar_layout.addWidget(self.cancel_toolbar_button)
self.batch_assign_button = QPushButton("批量指派医助", toolbar) self.batch_assign_button = _tool_icon(
QPushButton("批量指派医助", toolbar), "users", "inverse"
)
self.batch_assign_button.setProperty("variant", "success") self.batch_assign_button.setProperty("variant", "success")
self.batch_assign_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign")) self.batch_assign_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
self.batch_assign_button.clicked.connect(self._batch_assign) self.batch_assign_button.clicked.connect(self._batch_assign)
@@ -1477,16 +1433,29 @@ class ConsultationsPage(QWidget):
self.delete_button = QPushButton("删除", self._compat_actions) self.delete_button = QPushButton("删除", self._compat_actions)
self.delete_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/delete")) self.delete_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/delete"))
self.delete_button.clicked.connect(self._delete_selected) self.delete_button.clicked.connect(self._delete_selected)
self.refresh_button = QPushButton("刷新", toolbar) self.refresh_button = _tool_icon(QPushButton("刷新", toolbar), "refresh")
self.refresh_button.clicked.connect(lambda: self.refresh()) self.refresh_button.clicked.connect(lambda: self.refresh())
toolbar_layout.addWidget(self.refresh_button) toolbar_layout.addWidget(self.refresh_button)
self.toolbar_flow = FlowWidget(horizontal_spacing=8, vertical_spacing=8)
for index in reversed(range(toolbar_layout.count())):
if toolbar_layout.itemAt(index).spacerItem() is not None:
toolbar_layout.takeAt(index)
for button in (
self.add_button, self.video_qr_toolbar_button, self.call_toolbar_button,
self.complete_toolbar_button, self.prescription_toolbar_button,
self.case_toolbar_button, self.cancel_toolbar_button,
):
toolbar_layout.removeWidget(button)
self.toolbar_flow.flow.addWidget(button)
toolbar_layout.insertWidget(0, self.toolbar_flow, 1)
table_wrap = QWidget() table_wrap = QWidget()
table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
table_wrap_layout = QVBoxLayout(table_wrap) table_wrap_layout = QVBoxLayout(table_wrap)
table_wrap_layout.setContentsMargins(12, 4, 12, 0) table_wrap_layout.setContentsMargins(0, 0, 0, 0)
table_wrap_layout.setSpacing(0) table_wrap_layout.setSpacing(0)
self.table_host = DiagnosisTableHost( self.table_host = DiagnosisTableHost(
tech_blue=True,
action_policy={ action_policy={
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"), "view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"), "edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
@@ -1520,7 +1489,6 @@ class ConsultationsPage(QWidget):
} }
) )
self.table = self.table_host.main self.table = self.table_host.main
self.table.horizontalHeader().setSectionResizeMode(9, QHeaderView.ResizeMode.Stretch)
# The shared host's gradient is useful on dark pages but renders as a # The shared host's gradient is useful on dark pages but renders as a
# heavy black strip in this light reference layout. # heavy black strip in this light reference layout.
self.table_host.fixed_shadow.hide() self.table_host.fixed_shadow.hide()
@@ -1534,9 +1502,8 @@ class ConsultationsPage(QWidget):
table_wrap_layout.addWidget(self.table_host, 1) table_wrap_layout.addWidget(self.table_host, 1)
self.loading_overlay = DiagnosisLoadingOverlay(self.table_host) self.loading_overlay = DiagnosisLoadingOverlay(self.table_host)
card_layout.addWidget(table_wrap, 1) card_layout.addWidget(table_wrap, 1)
self.pager = DiagnosisPager(self._page_size) self.pager = InfiniteList(self._page_size)
self.pager.page_changed.connect(self._change_page) self.pager.bind(self.table)
self.pager.page_size_changed.connect(self._change_page_size)
card_layout.addWidget(self.pager) card_layout.addWidget(self.pager)
page_layout.addWidget(card, 1) page_layout.addWidget(card, 1)
@@ -1586,7 +1553,9 @@ class ConsultationsPage(QWidget):
layout = QHBoxLayout(host) layout = QHBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5) layout.setSpacing(5)
layout.addWidget(self._filter_label(label_text)) label = self._filter_label(label_text)
label.setMinimumWidth(85 if label_text == "确认诊单:" else 46)
layout.addWidget(label)
layout.addWidget(control) layout.addWidget(control)
return host return host
@@ -1706,17 +1675,53 @@ class ConsultationsPage(QWidget):
def _toggle_advanced_filters(self, checked: bool) -> None: def _toggle_advanced_filters(self, checked: bool) -> None:
self.advanced_filters.setVisible(checked) self.advanced_filters.setVisible(checked)
self.filters_card.setFixedHeight(
_FILTERS_COLLAPSED_HEIGHT + self.advanced_filters.sizeHint().height() + 8
if checked
else _FILTERS_COLLAPSED_HEIGHT
)
self.more_filter_button.setText("收起" if checked else "更多筛选") self.more_filter_button.setText("收起" if checked else "更多筛选")
self.more_filter_button.setArrowType( self.more_filter_button.setIcon(
Qt.ArrowType.UpArrow if checked else Qt.ArrowType.DownArrow icons.icon("up" if checked else "down", "muted", 14)
) )
self.main_chip_flow.updateGeometry() self.main_chip_flow.updateGeometry()
self.quick_filter_flow.updateGeometry() self.quick_filter_flow.updateGeometry()
self._apply_responsive_layout()
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
if hasattr(self, "pager"):
self._apply_responsive_layout()
def _apply_responsive_layout(self) -> None:
"""Retain reference geometry while letting dense filters wrap on small windows."""
compact = self.height() < 800
narrow = self.width() < 1050
layout = self.page_scroll.widget().layout()
layout.setContentsMargins(20 if narrow else 27, 16 if compact else 24,
20 if narrow else 26, 8)
self.diagnosis_confirmed_combo.setFixedWidth(100 if narrow else 154)
self.department_combo.setFixedWidth(135 if narrow else 201)
self.keyword_edit.setFixedWidth(225 if narrow else 354)
self.secondary_filter_flow.flow._horizontal_spacing = 16 if narrow else 40
self.status_card.setFixedHeight(48 if compact else _STATUS_CARD_HEIGHT)
self.date_filters.setMinimumHeight(40 if compact else 48)
self.filters_card.layout().setSpacing(5 if compact else 8)
self.filters_card.layout().setContentsMargins(18, 6 if compact else 8,
18, 8 if compact else 12)
# Reserve the page scrollbar even before Qt has resolved its visibility.
# Otherwise the last field can wrap only after the height was measured.
usable = max(320, self.width() - (40 if narrow else 53) - 45)
date_height = max(40 if compact else 48,
self.main_chip_flow.flow.heightForWidth(usable - 298))
secondary_height = max(46, self.secondary_filter_flow.flow.heightForWidth(usable - 105))
self.date_filters.setFixedHeight(date_height)
self.secondary_filters.setFixedHeight(secondary_height)
filter_height = (44 + (5 if compact else 8)
+ (48 if compact else 54) + date_height + secondary_height
+ (24 if compact else 36))
if not self.advanced_filters.isHidden():
advanced_height = (self.quick_filter_flow.flow.heightForWidth(usable)
+ self.advanced_filter_flow.flow.heightForWidth(usable) + 25)
filter_height += advanced_height
self.filters_card.setFixedHeight(filter_height)
action_width = max(200, usable - self.refresh_button.sizeHint().width() - 8)
self.list_toolbar.setFixedHeight(self.toolbar_flow.flow.heightForWidth(action_width) + 24)
def _server_sort_changed(self, _index: int) -> None: def _server_sort_changed(self, _index: int) -> None:
if not hasattr(self, "table_host"): if not hasattr(self, "table_host"):
@@ -1812,6 +1817,8 @@ class ConsultationsPage(QWidget):
self.pending_assign_filters.setVisible(bool(self._pending_assign)) self.pending_assign_filters.setVisible(bool(self._pending_assign))
for flow in (self.main_chip_flow, self.quick_filter_flow): for flow in (self.main_chip_flow, self.quick_filter_flow):
flow.updateGeometry() flow.updateGeometry()
if hasattr(self, "pager") and self.isVisible():
self._apply_responsive_layout()
def _clear_latest_appointment_filters(self) -> None: def _clear_latest_appointment_filters(self) -> None:
self.latest_appointment_start_date.setDate(_OPTIONAL_DATE_MINIMUM) self.latest_appointment_start_date.setDate(_OPTIONAL_DATE_MINIMUM)
@@ -1906,17 +1913,6 @@ class ConsultationsPage(QWidget):
self._page = 1 self._page = 1
self.refresh() self.refresh()
def _change_page(self, page: int) -> None:
self._page = page
self.refresh(silent=True)
def _change_page_size(self, page_size: int) -> None:
if page_size not in {15, 20, 30, 40}:
return
self._page_size = page_size
self._page = 1
self.refresh(silent=True)
def _shared_filters(self) -> dict[str, Any]: def _shared_filters(self) -> dict[str, Any]:
return { return {
"keyword": self.keyword_edit.text().strip(), "keyword": self.keyword_edit.text().strip(),
@@ -1978,7 +1974,7 @@ class ConsultationsPage(QWidget):
self._loading = True self._loading = True
self.refresh_button.setEnabled(False) self.refresh_button.setEnabled(False)
filters = MappingProxyType(self._filters()) filters = MappingProxyType(self._filters())
page = self._page page_size = self._page_size
if not silent: if not silent:
self._visible_loading_generation = generation self._visible_loading_generation = generation
self.table_host.begin_loading() self.table_host.begin_loading()
@@ -1986,16 +1982,18 @@ class ConsultationsPage(QWidget):
self._refresh_counts() self._refresh_counts()
elif self.loading_overlay.isVisible(): elif self.loading_overlay.isVisible():
self._visible_loading_generation = generation self._visible_loading_generation = generation
run_async( self.pager.reload(
lambda: invoke( lambda page: invoke(
self.repository, self.repository,
"consultations", "consultations",
**filters, **filters,
page=page, page=page,
page_size=self._page_size, page_size=page_size,
), ),
on_success=lambda result: self._apply_result(result, generation), apply=lambda result: self._apply_result(result, generation),
on_error=lambda error: self._load_error(error, generation), on_error=lambda error: self._load_error(error, generation),
runner=run_async,
query_key=filters,
on_finished=lambda: self._load_finished(generation), on_finished=lambda: self._load_finished(generation),
) )
@@ -2004,16 +2002,12 @@ class ConsultationsPage(QWidget):
return return
rows = page_items(result) rows = page_items(result)
total = page_total(result, len(rows)) total = page_total(result, len(rows))
last_page = max(1, (total + self._page_size - 1) // self._page_size) self._page = self.pager.page
if self._page > last_page:
self._page = last_page
self.refresh(silent=True)
return
self.table_host.force_open_prescription = bool(self._pending_assign) self.table_host.force_open_prescription = bool(self._pending_assign)
self.table_host.set_rows(rows) self.table_host.set_rows(rows)
self.pager.update_state(self._page, total) self.pager.update_state(self._page, total)
self.summary_label.setText( self.summary_label.setText(
f"共 {total} 条 · 第 {self._page} 页 · 每页 {self._page_size} 条" f"共 {total} 条 · 已加载 {len(rows)} 条"
) )
self.banner.clear() self.banner.clear()
self._selection_changed() self._selection_changed()
@@ -2958,6 +2952,14 @@ class ConsultationsPage(QWidget):
and video_call_is_live(record) and video_call_is_live(record)
and valid_ids and valid_ids
) )
self.call_toolbar_button.setEnabled(self.video_button.isEnabled())
self.video_qr_toolbar_button.setEnabled(has_record)
self.complete_toolbar_button.setEnabled(has_record)
self.case_toolbar_button.setEnabled(has_record)
self.prescription_toolbar_button.setEnabled(has_record and not self._prescription_busy)
self.cancel_toolbar_button.setEnabled(
has_record and _single_cancellable_appointment(record) is not None
)
@property @property
def _diagnosis_dialog(self) -> DiagnosisDialog: def _diagnosis_dialog(self) -> DiagnosisDialog:
@@ -3412,7 +3414,7 @@ class ConsultationsPage(QWidget):
if ( if (
not self.isVisible() not self.isVisible()
or self._loading or self.pager.loading
or self._order_flow_generation is not None or self._order_flow_generation is not None
): ):
return return
File diff suppressed because it is too large Load Diff
@@ -8,29 +8,38 @@ from typing import Any
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor, QFont from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDialog, QDialog,
QFrame, QFrame,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QLabel, QLabel,
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QScrollArea,
QStackedWidget, QStackedWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from .. import motion
from ..dialogs.prescription import PrescriptionTemplateDialog from ..dialogs.prescription import PrescriptionTemplateDialog
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
from ..filter_disclosure import FilterDisclosure
from ..infinite_list import InfiniteList
from ..prescription_library_style import (
LibraryComboBox,
LibraryItemDelegate,
LibraryTable,
library_stylesheet,
)
from ..reception_style import body_family
from ..widgets import ( from ..widgets import (
BusinessPager,
EmptyState, EmptyState,
MessageBanner, MessageBanner,
MetricCard, MetricCard,
PageHeader, PageHeader,
SortableTable,
TableColumn, TableColumn,
display_text, display_text,
first_value, first_value,
@@ -45,131 +54,12 @@ from ..widgets import (
show_toast, show_toast,
) )
from .prescriptions import ( from .prescriptions import (
_cell_host, _ROLE_LEAD_ICON,
_ROLE_TAG_KIND,
_painted_icon, _painted_icon,
_row_action_button, _row_action_button,
_style_row_host,
_tag_label,
) )
PRESCRIPTION_LIBRARY_PAGE_QSS = """
#PrescriptionLibraryPage { background: #F8FAFF; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 14px; max-height: 14px;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px;
}
#PrescriptionLibraryPage QFrame#MetricCard {
min-height: 64px; max-height: 64px;
border: 1px solid #E2E7F4; border-radius: 12px; background: #FFFFFF;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
color: #405074; font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
color: #5265F6; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"] {
border: 1px solid #DCE3FF; border-radius: 11px; background: #EEF1FF;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="info"] {
border-color: #E5DFFF; background: #F2EEFF;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="success"] {
border-color: #CDEFE3; background: #E8F8F2;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="warning"] {
border-color: #F8DFC2; background: #FFF3E5;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar {
background: #FFFFFF; border: 0; border-bottom: 1px solid #E7EBF5;
border-top-left-radius: 13px; border-top-right-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 82px; min-height: 32px; max-height: 32px;
padding: 0 8px; margin: 0 4px 0 0;
color: #59698E; background: transparent; border: 0;
border-bottom: 2px solid transparent; border-radius: 0;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: #5265F6; background: transparent; border-bottom-color: #5265F6;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QLineEdit,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QComboBox {
min-height: 32px; max-height: 32px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QPushButton {
min-height: 34px; max-height: 34px; padding: 0 14px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton {
min-height: 32px; max-height: 32px; padding: 0 13px;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 27px; max-width: 27px; min-height: 27px; max-height: 27px;
padding: 0; border-radius: 7px; background: #FFFFFF;
border: 1px solid #DCE3F5;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover {
background: #F3F5FF; border-color: #AAB7FF;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"] {
background: #FFF9FA; border-color: #FFD9DE;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover {
background: #FFF1F3; border-color: #FFACB8;
}
#PrescriptionLibraryPage QTableWidget {
border: 0; border-radius: 0; background: #FFFFFF;
alternate-background-color: #FBFCFF; font-size: 12px;
}
#PrescriptionLibraryPage QTableWidget::item {
padding: 4px 8px; border-bottom: 1px solid #EDF0F7;
}
#PrescriptionLibraryPage QTableWidget::item:selected {
color: #26365F; background: #FCFDFF;
}
#PrescriptionLibraryPage QHeaderView::section {
min-height: 38px; max-height: 38px; padding: 0 8px;
background: #F7F9FE; color: #7481A3;
border: 0; border-bottom: 1px solid #E7EBF5;
font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QWidget#Pager { min-height: 42px; }
#PrescriptionLibraryPage QWidget#Pager QPushButton {
min-width: 34px; max-height: 32px; min-height: 32px; padding: 0 10px;
}
#PrescriptionLibraryPage QWidget#Pager QLabel#PagerActive {
min-width: 48px; min-height: 30px; border-radius: 7px;
background: #5265F6; color: #FFFFFF; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"] {
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton {
min-height: 32px; max-height: 32px;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionLibraryPage QWidget#BusinessPager QLabel[pagerSize="true"] {
min-width: 64px; color: #7481A3; font-size: 12px;
}
"""
def _formula_text(value: Any, _row: Any = None) -> str: def _formula_text(value: Any, _row: Any = None) -> str:
text = str(value or "").strip().lower() text = str(value or "").strip().lower()
@@ -197,7 +87,8 @@ def _herbs_detail(_value: Any, row: Any) -> str:
for herb in herbs: for herb in herbs:
name = first_value(herb, "name", "medicine_name", default="药材") name = first_value(herb, "name", "medicine_name", default="药材")
dosage = first_value(herb, "dosage", "amount", default="") dosage = first_value(herb, "dosage", "amount", default="")
pieces.append(f"{name} {dosage}g".strip()) unit = "" if str(dosage).strip().lower().endswith("g") else "g"
pieces.append(f"{name} {dosage}{unit}".strip())
return "、".join(pieces) if pieces else "暂无药材" return "、".join(pieces) if pieces else "暂无药材"
@@ -220,22 +111,16 @@ def _create_time_cell(value: Any, _row: Any) -> str:
return format_record_time(raw) return format_record_time(raw)
def _metric_card(title: str, kind: str = "accent") -> MetricCard: def _metric_card(title: str, kind: str = "accent", glyph: str = "layers") -> MetricCard:
card = MetricCard(title, "0", kind=kind, glyph="") card = MetricCard(title, "0", kind=kind, glyph="")
card.setFixedHeight(64) card.setFixedHeight(86)
card.layout().setContentsMargins(16, 8, 14, 8) card.layout().setContentsMargins(18, 14, 18, 14)
icon = QLabel(card) icon = QLabel(card)
icon.setProperty("metricIcon", True) icon.setProperty("metricIcon", True)
icon.setProperty("kind", kind)
icon.setAlignment(Qt.AlignmentFlag.AlignCenter) icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon.setFixedSize(36, 36) icon.setFixedSize(32, 32)
colors = { color = "#1769E8" if kind == "accent" else "#60789F"
"accent": "#5365F5", icon.setPixmap(_painted_icon(glyph, color, 25).pixmap(25, 25))
"info": "#8268E8",
"success": "#23A77D",
"warning": "#E6932C",
}
icon.setPixmap(_painted_icon("document", colors.get(kind, "#5365F5"), 18).pixmap(18, 18))
card.layout().addWidget(icon) card.layout().addWidget(icon)
return card return card
@@ -252,7 +137,11 @@ class PrescriptionLibraryPage(QWidget):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("PrescriptionLibraryPage") self.setObjectName("PrescriptionLibraryPage")
self.setStyleSheet(PRESCRIPTION_LIBRARY_PAGE_QSS) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(library_stylesheet())
font = QFont(body_family())
font.setPixelSize(14)
self.setFont(font)
self.repository = repository self.repository = repository
self.permissions = permissions self.permissions = permissions
self.current_user = current_user self.current_user = current_user
@@ -262,19 +151,35 @@ class PrescriptionLibraryPage(QWidget):
self._page = 1 self._page = 1
self._page_size = 15 self._page_size = 15
root = QVBoxLayout(self) outer = QVBoxLayout(self)
root.setContentsMargins(24, 19, 24, 14) outer.setContentsMargins(28, 16, 26, 8)
root.setSpacing(12) self.scroll = QScrollArea()
self.scroll.setObjectName("PrescriptionLibraryScroll")
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.content = QWidget()
self.content.setObjectName("PrescriptionLibraryContent")
root = QVBoxLayout(self.content)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(10)
self.scroll.setWidget(self.content)
outer.addWidget(self.scroll)
header = PageHeader( header = PageHeader(
"处方库", "处方库",
"管理常用处方模板,支持 AI 解析辅助开方。", "管理常用处方模板,支持 AI 解析辅助开方。",
) )
header.layout().setSpacing(4) self.header = header
header.setFixedHeight(88)
header.layout().setSpacing(12)
header.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(7)
header.actions.setSpacing(16) header.actions.setSpacing(16)
self.new_button = QPushButton("新增处方", header) self.new_button = QPushButton("新增处方", header)
self.new_button.setObjectName("PrescriptionLibraryAddButton")
self.new_button.setMinimumWidth(124) self.new_button.setMinimumWidth(124)
self.new_button.setProperty("variant", "primary") self.new_button.setProperty("variant", "primary")
self.new_button.setIcon(_painted_icon("plus", "#FFFFFF", 15)) self.new_button.setIcon(_painted_icon("plus", "inverse", 15))
self.new_button.setIconSize(QSize(15, 15)) self.new_button.setIconSize(QSize(15, 15))
self.new_button.setCursor(Qt.CursorShape.PointingHandCursor) self.new_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add")) self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add"))
@@ -282,18 +187,19 @@ class PrescriptionLibraryPage(QWidget):
header.add_action(self.new_button) header.add_action(self.new_button)
root.addWidget(header) root.addWidget(header)
metrics = QHBoxLayout() self.metrics_panel = QWidget(self.content)
metrics = QHBoxLayout(self.metrics_panel)
metrics.setContentsMargins(0, 0, 0, 0) metrics.setContentsMargins(0, 0, 0, 0)
metrics.setSpacing(16) metrics.setSpacing(16)
self.metric_cards = { self.metric_cards = {
"total": _metric_card("全部处方"), "total": _metric_card("全部处方", "accent", "layers"),
"private": _metric_card("仅自己", "info"), "private": _metric_card("仅自己", "info", "lock"),
"public": _metric_card("公开处方", "success"), "public": _metric_card("公开处方", "success", "users"),
"month": _metric_card("本月新增", "warning"), "month": _metric_card("本月新增", "warning", "calendar"),
} }
for metric in self.metric_cards.values(): for metric in self.metric_cards.values():
metrics.addWidget(metric) metrics.addWidget(metric)
root.addLayout(metrics) root.addWidget(self.metrics_panel)
self.hint_banner = MessageBanner(parent=self) self.hint_banner = MessageBanner(parent=self)
self.hint_banner.show_message( self.hint_banner.show_message(
@@ -304,43 +210,47 @@ class PrescriptionLibraryPage(QWidget):
filters = QFrame() filters = QFrame()
filters.setObjectName("PrescriptionLibraryFilterBar") filters.setObjectName("PrescriptionLibraryFilterBar")
filters.setFixedHeight(52) self.filter_card = filters
filters.setFixedHeight(90)
grid = QGridLayout(filters) grid = QGridLayout(filters)
grid.setContentsMargins(16, 9, 16, 9) self.filter_grid = grid
grid.setContentsMargins(18, 24, 18, 24)
grid.setVerticalSpacing(14)
grid.setHorizontalSpacing(12) grid.setHorizontalSpacing(12)
self.name_filter = QLineEdit() self.name_filter = QLineEdit()
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词") self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
self.name_filter.setClearButtonEnabled(True) self.name_filter.setClearButtonEnabled(True)
self.name_filter.returnPressed.connect(self._search) self.name_filter.returnPressed.connect(self._search)
grid.addWidget(self.name_filter, 0, 0) grid.addWidget(self.name_filter, 0, 0)
self.formula_filter = QComboBox() self.formula_filter = LibraryComboBox()
self.formula_filter.addItem("全部类型", "") self.formula_filter.addItem("全部类型", "")
self.formula_filter.addItem("主方", "主方") self.formula_filter.addItem("主方", "主方")
self.formula_filter.addItem("辅方", "辅方") self.formula_filter.addItem("辅方", "辅方")
grid.addWidget(self.formula_filter, 0, 1) grid.addWidget(self.formula_filter, 0, 1)
self.visibility_filter = QComboBox() self.visibility_filter = LibraryComboBox()
self.visibility_filter.addItem("全部公开范围", "") self.visibility_filter.addItem("全部公开范围", "")
self.visibility_filter.addItem("仅自己可见", 0) self.visibility_filter.addItem("仅自己可见", 0)
self.visibility_filter.addItem("所有人可见", 1) self.visibility_filter.addItem("所有人可见", 1)
grid.addWidget(self.visibility_filter, 0, 2) grid.addWidget(self.visibility_filter, 0, 2)
self.effect_filter = QComboBox() self.effect_filter = LibraryComboBox()
self.effect_filter.addItem("全部功效类型", "") self.effect_filter.addItem("全部功效类型", "")
self.effect_filter.addItem("益气养阴", "益气养阴") self.effect_filter.addItem("益气养阴", "益气养阴")
self.effect_filter.addItem("清热祛湿", "清热祛湿") self.effect_filter.addItem("清热祛湿", "清热祛湿")
self.effect_filter.addItem("滋阴补肾", "滋阴补肾") self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
grid.addWidget(self.effect_filter, 0, 3) grid.addWidget(self.effect_filter, 0, 3)
self.name_filter.setMinimumWidth(220) self.name_filter.setMinimumWidth(260)
self.formula_filter.setMinimumWidth(132) self.formula_filter.setMinimumWidth(140)
self.visibility_filter.setMinimumWidth(148) self.visibility_filter.setMinimumWidth(168)
self.effect_filter.setMinimumWidth(144) self.effect_filter.setMinimumWidth(162)
self.query_button = QPushButton("查询") self.query_button = QPushButton("查询")
self.query_button.setFixedWidth(66) self.query_button.setObjectName("PrescriptionLibraryQueryButton")
self.query_button.setFixedWidth(84)
self.query_button.setProperty("variant", "secondary") self.query_button.setProperty("variant", "secondary")
self.query_button.setCursor(Qt.CursorShape.PointingHandCursor) self.query_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.query_button.clicked.connect(self._search) self.query_button.clicked.connect(self._search)
grid.addWidget(self.query_button, 0, 4) grid.addWidget(self.query_button, 0, 4)
self.reset_button = QPushButton("重置") self.reset_button = QPushButton("重置")
self.reset_button.setFixedWidth(66) self.reset_button.setFixedWidth(84)
self.reset_button.setProperty("variant", "ghost") self.reset_button.setProperty("variant", "ghost")
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor) self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.reset_button.clicked.connect(self._reset_filters) self.reset_button.clicked.connect(self._reset_filters)
@@ -350,19 +260,31 @@ class PrescriptionLibraryPage(QWidget):
grid.setColumnStretch(2, 1) grid.setColumnStretch(2, 1)
grid.setColumnStretch(3, 1) grid.setColumnStretch(3, 1)
root.addWidget(filters) root.addWidget(filters)
self.filter_disclosure = FilterDisclosure(self, [self.metrics_panel, filters])
header.add_action(self.filter_disclosure.button)
header.set_compact()
self.banner = MessageBanner() self.banner = MessageBanner()
root.addWidget(self.banner) root.addWidget(self.banner)
card = QFrame() card = QFrame()
card.setObjectName("PrescriptionLibraryTableCard") card.setObjectName("PrescriptionLibraryTableCard")
self.table_card = card
card.setMinimumHeight(320)
card_layout = QVBoxLayout(card) card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(0, 0, 0, 0) card_layout.setContentsMargins(1, 0, 1, 1)
card_layout.setSpacing(0) card_layout.setSpacing(0)
toolbar_host = QFrame(card) toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionLibraryToolbar") toolbar_host.setObjectName("PrescriptionLibraryToolbar")
toolbar_host.setFixedHeight(46) toolbar_host.setFixedHeight(64)
toolbar_scroll = QScrollArea(card)
toolbar_scroll.setObjectName("PrescriptionLibraryToolbarScroll")
toolbar_scroll.setFrameShape(QFrame.Shape.NoFrame)
toolbar_scroll.setWidgetResizable(True)
toolbar_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
toolbar_scroll.setFixedHeight(64)
toolbar_scroll.setWidget(toolbar_host)
toolbar = QHBoxLayout(toolbar_host) toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 7, 16, 7) toolbar.setContentsMargins(18, 7, 18, 7)
toolbar.setSpacing(8) toolbar.setSpacing(8)
self.all_tab = QPushButton("处方列表", toolbar_host) self.all_tab = QPushButton("处方列表", toolbar_host)
self.all_tab.setProperty("toolbarTab", True) self.all_tab.setProperty("toolbarTab", True)
@@ -377,7 +299,7 @@ class PrescriptionLibraryPage(QWidget):
toolbar.addWidget(self.favorite_tab) toolbar.addWidget(self.favorite_tab)
toolbar.addStretch(1) toolbar.addStretch(1)
self.view_button = QPushButton("查看", card) self.view_button = QPushButton("查看", card)
self.view_button.setIcon(_painted_icon("eye", "#5265F6", 15)) self.view_button.setIcon(_painted_icon("eye", "accent", 15))
self.view_button.setIconSize(QSize(15, 15)) self.view_button.setIconSize(QSize(15, 15))
self.view_button.setCursor(Qt.CursorShape.PointingHandCursor) self.view_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read")) self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read"))
@@ -386,7 +308,7 @@ class PrescriptionLibraryPage(QWidget):
toolbar.addWidget(self.view_button) toolbar.addWidget(self.view_button)
self.ai_button = QPushButton("AI解释", card) self.ai_button = QPushButton("AI解释", card)
self.ai_button.setProperty("variant", "secondary") self.ai_button.setProperty("variant", "secondary")
self.ai_button.setIcon(_painted_icon("spark", "#5265F6", 15)) self.ai_button.setIcon(_painted_icon("spark", "accent", 15))
self.ai_button.setIconSize(QSize(15, 15)) self.ai_button.setIconSize(QSize(15, 15))
self.ai_button.setCursor(Qt.CursorShape.PointingHandCursor) self.ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.ai_button.setVisible(can_open_ai_explain(permissions)) self.ai_button.setVisible(can_open_ai_explain(permissions))
@@ -394,7 +316,7 @@ class PrescriptionLibraryPage(QWidget):
self.ai_button.clicked.connect(self._explain_selected) self.ai_button.clicked.connect(self._explain_selected)
toolbar.addWidget(self.ai_button) toolbar.addWidget(self.ai_button)
self.edit_button = QPushButton("编辑", card) self.edit_button = QPushButton("编辑", card)
self.edit_button.setIcon(_painted_icon("pencil", "#5265F6", 15)) self.edit_button.setIcon(_painted_icon("pencil", "accent", 15))
self.edit_button.setIconSize(QSize(15, 15)) self.edit_button.setIconSize(QSize(15, 15))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor) self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit")) self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit"))
@@ -403,7 +325,7 @@ class PrescriptionLibraryPage(QWidget):
toolbar.addWidget(self.edit_button) toolbar.addWidget(self.edit_button)
self.delete_button = QPushButton("删除", card) self.delete_button = QPushButton("删除", card)
self.delete_button.setProperty("variant", "danger") self.delete_button.setProperty("variant", "danger")
self.delete_button.setIcon(_painted_icon("trash", "#F34E64", 15)) self.delete_button.setIcon(_painted_icon("trash", "danger", 15))
self.delete_button.setIconSize(QSize(15, 15)) self.delete_button.setIconSize(QSize(15, 15))
self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete")) self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete"))
@@ -412,18 +334,19 @@ class PrescriptionLibraryPage(QWidget):
toolbar.addWidget(self.delete_button) toolbar.addWidget(self.delete_button)
refresh = QPushButton("刷新") refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost") refresh.setProperty("variant", "ghost")
refresh.setIcon(_painted_icon("refresh", "#5D6E96", 15)) refresh.setIcon(_painted_icon("refresh", "soft", 15))
refresh.setIconSize(QSize(15, 15)) refresh.setIconSize(QSize(15, 15))
refresh.setCursor(Qt.CursorShape.PointingHandCursor) refresh.setCursor(Qt.CursorShape.PointingHandCursor)
refresh.clicked.connect(self.refresh) refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh) toolbar.addWidget(refresh)
card_layout.addWidget(toolbar_host) card_layout.addWidget(toolbar_scroll)
self.stack = QStackedWidget() self.stack = QStackedWidget()
table_host = QWidget() table_host = QWidget()
table_layout = QVBoxLayout(table_host) table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0) table_layout.setContentsMargins(0, 0, 0, 0)
self.table = SortableTable( table_layout.setSpacing(0)
self.table = LibraryTable(
[ [
TableColumn("id", "ID", 62), TableColumn("id", "ID", 62),
TableColumn("prescription_name", "处方名称", 162), TableColumn("prescription_name", "处方名称", 162),
@@ -437,15 +360,27 @@ class PrescriptionLibraryPage(QWidget):
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""), TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
] ]
) )
self.table.verticalHeader().setDefaultSectionSize(36) self.table.setObjectName("PrescriptionLibraryTable")
self.table.horizontalHeader().setFixedHeight(38) self.table.setItemDelegate(LibraryItemDelegate(self.table))
self.table.setMouseTracking(True)
self.table.setAlternatingRowColors(False)
self.table.verticalHeader().setMinimumSectionSize(78)
self.table.verticalHeader().setDefaultSectionSize(78)
self.table.horizontalHeader().setStretchLastSection(False)
self.table.horizontalHeader().setFixedHeight(44)
self.table.horizontalHeader().setMinimumSectionSize(36)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
self.table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
for column, width in enumerate((54, 170, 76, 76, 260, 90, 134, 136, 122, 150)):
self.table.setColumnWidth(column, width)
for column in (0, 2, 3, 5, 9):
self.table.horizontalHeaderItem(column).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setWordWrap(False) self.table.setWordWrap(False)
self.table.itemSelectionChanged.connect(self._selection_changed) self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected()) self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1) table_layout.addWidget(self.table, 1)
self.pager = BusinessPager(self._page_size) self.pager = InfiniteList(self._page_size)
self.pager.page_changed.connect(self._change_page) self.pager.bind(self.table)
table_layout.addWidget(self.pager)
self.stack.addWidget(table_host) self.stack.addWidget(table_host)
empty = EmptyState( empty = EmptyState(
"还没有处方模板", "还没有处方模板",
@@ -454,10 +389,43 @@ class PrescriptionLibraryPage(QWidget):
) )
empty.action_button.setVisible(self.new_button.isVisible()) empty.action_button.setVisible(self.new_button.isVisible())
empty.action_requested.connect(self._new_template) empty.action_requested.connect(self._new_template)
empty.layout().itemAt(0).setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.stack.addWidget(empty) self.stack.addWidget(empty)
card_layout.addWidget(self.stack, 1) card_layout.addWidget(self.stack, 1)
card_layout.addWidget(self.pager)
root.addWidget(card, 1) root.addWidget(card, 1)
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
if hasattr(self, "filter_grid"):
compact_height = self.height() < 760
self.layout().setContentsMargins(28, 16, 26, 8)
self.content.layout().setSpacing(8 if compact_height else 10)
self._reflow_filters()
def _reflow_filters(self) -> None:
compact = self.width() < 1120
if getattr(self, "_compact_filters", None) == compact:
return
self._compact_filters = compact
controls = (self.name_filter, self.formula_filter, self.visibility_filter,
self.effect_filter, self.query_button, self.reset_button)
for control in controls:
self.filter_grid.removeWidget(control)
for column in range(6):
self.filter_grid.setColumnStretch(column, 0)
if compact:
positions = ((0, 0, 2), (0, 2, 1), (0, 3, 1), (1, 0, 2), (1, 2, 1), (1, 3, 1))
stretches = (2, 2, 1, 1)
else:
positions = tuple((0, column, 1) for column in range(6))
stretches = (4, 1, 1, 1, 0, 0)
for control, (row, column, span) in zip(controls, positions, strict=True):
self.filter_grid.addWidget(control, row, column, 1, span)
for column, stretch in enumerate(stretches):
self.filter_grid.setColumnStretch(column, stretch)
self.filter_card.setFixedHeight(144 if compact else 90)
def _search(self) -> None: def _search(self) -> None:
self._page = 1 self._page = 1
self.refresh() self.refresh()
@@ -481,14 +449,7 @@ class PrescriptionLibraryPage(QWidget):
self.effect_filter.setCurrentIndex(0) self.effect_filter.setCurrentIndex(0)
self._search() self._search()
def _change_page(self, page: int) -> None:
self._page = page
self.refresh()
def refresh(self) -> None: def refresh(self) -> None:
if self._loading:
self._refresh_pending = True
return
self._loading = True self._loading = True
self._refresh_pending = False self._refresh_pending = False
self._generation += 1 self._generation += 1
@@ -497,27 +458,26 @@ class PrescriptionLibraryPage(QWidget):
"prescription_name": self.name_filter.text().strip(), "prescription_name": self.name_filter.text().strip(),
"formula_type": self.formula_filter.currentData(), "formula_type": self.formula_filter.currentData(),
"is_public": self.visibility_filter.currentData(), "is_public": self.visibility_filter.currentData(),
"page": self._page,
"page_size": self._page_size, "page_size": self._page_size,
} }
requested_page = self._page # 列表加载不再挂横幅。横幅占布局空间,弹出与收起各触发一次重排,
self.banner.show_message("正在加载处方库…", "info") # 每刷新一次表格就上下跳一次——而轮询定时器每 5 秒就刷新一次。
run_async( # 已有数据时保持旧行可见、静默替换;失败仍然照常报错。
lambda: invoke( effect = str(self.effect_filter.currentData() or "").strip()
self.repository, self.pager.reload(
"prescription_library", lambda page: invoke(self.repository, "prescription_library", page=page, **query),
**query, lambda result: self._apply_result(result, generation, self.pager.page, effect),
), lambda error: self._load_error(error, generation),
on_success=lambda result: self._apply_result(result, generation, requested_page), runner=run_async,
on_error=lambda error: self._load_error(error, generation), query_key=(query, effect),
on_finished=lambda: self._load_finished(generation), on_finished=lambda: self._load_finished(generation),
) )
def _apply_result(self, result: Any, generation: int, requested_page: int) -> None: def _apply_result(self, result: Any, generation: int, requested_page: int, effect_filter: str | None = None) -> None:
if generation != self._generation: if generation != self._generation:
return return
rows = page_items(result) rows = page_items(result)
effect = str(self.effect_filter.currentData() or "").strip() effect = str(self.effect_filter.currentData() or "").strip() if effect_filter is None else effect_filter
if effect: if effect:
rows = [ rows = [
row row
@@ -554,11 +514,26 @@ class PrescriptionLibraryPage(QWidget):
self.metric_cards["private"].set_value(max(0, len(rows) - public_count)) self.metric_cards["private"].set_value(max(0, len(rows) - public_count))
self.metric_cards["public"].set_value(public_count) self.metric_cards["public"].set_value(public_count)
self.metric_cards["month"].set_value(month_count) self.metric_cards["month"].set_value(month_count)
self.stack.setCurrentIndex(0 if rows else 1) motion.switch_stack(self.stack, 0 if rows or self.pager.has_more else 1)
self.banner.clear() self.banner.clear()
self._selection_changed() self._selection_changed()
def _decorate_rows(self, rows: list[Any]) -> None: def _decorate_rows(self, rows: list[Any]) -> None:
# set_rows restores the active sort before decorating. Bind each action
# to the object now shown in that row, and keep rows still while tags
# are assigned (the type or visibility column may itself be sorted).
visible_rows = [
self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
for index in range(self.table.rowCount())
]
sorting = self.table.isSortingEnabled()
self.table.setSortingEnabled(False)
try:
self._decorate_rows_locked(visible_rows)
finally:
self.table.setSortingEnabled(sorting)
def _decorate_rows_locked(self, rows: list[Any]) -> None:
"""Install compact tags and permission-aware row actions from the comp.""" """Install compact tags and permission-aware row actions from the comp."""
for row_index, row in enumerate(rows): for row_index, row in enumerate(rows):
@@ -570,44 +545,32 @@ class PrescriptionLibraryPage(QWidget):
name_item = self.table.item(row_index, 1) name_item = self.table.item(row_index, 1)
if name_item is not None: if name_item is not None:
name_item.setForeground(QColor("#24355F")) name_item.setForeground(QColor("#1A1C1F"))
font = name_item.font() font = name_item.font()
font.setWeight(QFont.Weight.DemiBold) font.setWeight(QFont.Weight.Medium)
name_item.setFont(font) name_item.setFont(font)
# 处方类型与公开范围改由 _RowDecorationDelegate 绘制:原先每行为这两列
# 各挂一个 QWidget,setCellWidget 是刷新路径上最贵的调用,而且白色的
# 宿主控件会把整行的选中底色挖出两个缺口。
formula = _formula_text(first_value(row, "formula_type", default="主方")) formula = _formula_text(first_value(row, "formula_type", default="主方"))
formula_kind = "success" if formula == "主方" else "accent" formula_item = self.table.item(row_index, 2)
self.table.setCellWidget( if formula_item is not None:
row_index, formula_item.setText(formula)
2, formula_item.setData(
_style_row_host( _ROLE_TAG_KIND, "success" if formula == "主方" else "accent"
_cell_host( )
_tag_label(formula, formula_kind, self.table.viewport()) formula_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
),
row_index,
),
)
visibility_host = QWidget(self.table.viewport()) visibility_item = self.table.item(row_index, 6)
_style_row_host(visibility_host, row_index) if visibility_item is not None:
visibility_layout = QHBoxLayout(visibility_host) visibility_item.setText(
visibility_layout.setContentsMargins(8, 0, 6, 0) _visibility_text(first_value(row, "is_public", default=0))
visibility_layout.setSpacing(6) )
visibility_icon = QLabel(visibility_host) visibility_item.setData(_ROLE_LEAD_ICON, "lock")
visibility_icon.setFixedSize(15, 15)
visibility_icon.setPixmap(_painted_icon("lock", "#60709A", 14).pixmap(14, 14))
visibility_layout.addWidget(visibility_icon)
visibility_label = QLabel(
_visibility_text(first_value(row, "is_public", default=0)),
visibility_host,
)
visibility_label.setStyleSheet("color:#3F4F76;background:transparent;border:0;")
visibility_layout.addWidget(visibility_label)
visibility_layout.addStretch(1)
self.table.setCellWidget(row_index, 6, visibility_host)
# 保持宿主透明,让整行的斑马底色与选中底色透过操作列。
actions_host = QWidget(self.table.viewport()) actions_host = QWidget(self.table.viewport())
_style_row_host(actions_host, row_index)
actions = QHBoxLayout(actions_host) actions = QHBoxLayout(actions_host)
actions.setContentsMargins(6, 0, 6, 0) actions.setContentsMargins(6, 0, 6, 0)
actions.setSpacing(5) actions.setSpacing(5)
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
+363 -139
View File
@@ -1,13 +1,4 @@
"""Application-wide visual system for the AI consultation workstation. """Shared typography and neutral reading surfaces for the clinical workspace."""
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.
"""
from __future__ import annotations from __future__ import annotations
@@ -17,12 +8,15 @@ from pathlib import Path
from string import Template from string import Template
from typing import Any from typing import Any
from PySide6.QtCore import QEvent, QObject, Qt from PySide6.QtCore import QEvent, QObject, QPointF, Qt
from PySide6.QtGui import ( from PySide6.QtGui import (
QColor, QColor,
QFont, QFont,
QFontDatabase, QFontDatabase,
QPainter,
QPainterPath,
QPalette, QPalette,
QPen,
QPixmap, QPixmap,
QTextBlockFormat, QTextBlockFormat,
QTextCharFormat, QTextCharFormat,
@@ -35,50 +29,57 @@ from PySide6.QtWidgets import (
QFileDialog, QFileDialog,
QInputDialog, QInputDialog,
QMessageBox, QMessageBox,
QPlainTextEdit,
QScrollArea,
QTextBrowser, QTextBrowser,
QTextEdit,
) )
# Canonical semantic tokens. The legacy teal/ink aliases remain available to from doctor_workstation.resources import resource_path
# callers while the stylesheet itself is generated from this mapping.
# ``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 = { COLORS = {
# Sampled from the supplied 1710 x 920 product comps. The window edge is "canvas": "#F4F6FA",
# the only blue-tinted surface; the application workspace itself is an "canvas_mid": "#FFFFFF",
# almost-white #FCFDFE field. "canvas_glow": "#EEF1FA",
"canvas": "#EEF3FD",
"canvas_mid": "#F7F9FE",
"canvas_glow": "#E5ECFD",
"surface": "#FFFFFF", "surface": "#FFFFFF",
"surface_alt": "#FAFBFE", "surface_alt": "#F7F7F7",
"raised": "#F5F7FC", "raised": "#F7F7F7",
"glass": "rgba(255, 255, 255, 252)", "glass": "#FFFFFF",
"glass_alt": "rgba(248, 250, 255, 252)", "glass_alt": "#F7F7F7",
"line": "#E6EAF5", "line": "#EDEDEE",
"line_soft": "rgba(82, 97, 246, 40)", "line_soft": "#E4E4E5",
"text": "#111F46", "text": "#1A1C1F",
"text_soft": "#3F4E75", "text_soft": "#606163",
"muted": "#7886AA", "muted": "#6A6B6D",
"disabled_surface": "#F0F2F8", "disabled_surface": "#F2F2F2",
"disabled_text": "#A4ADC3", "disabled_text": "#8E8F90",
"indigo": "#5761F4", "indigo": "#4F63D9",
"indigo_hover": "#4C57E9", "indigo_hover": "#4156C4",
"indigo_pressed": "#4451E2", "indigo_pressed": "#354BB4",
"indigo_pale": "#F0F2FF", "indigo_pale": "#EEF1FA",
"focus": "#8D9BFF", "focus": "#8B9AD9",
"selection": "#EDF0FF", "selection": "#EEF1FA",
"success": "#17A77D", "success": "#287B65",
"success_pale": "#EAF9F3", "success_pale": "#EEF7F3",
"warning": "#D38625", "warning": "#A9691D",
"warning_pale": "#FFF5E6", "warning_pale": "#FCF5E9",
"danger": "#F15B67", "danger": "#BE4B58",
"danger_pale": "#FFF1F3", "danger_pale": "#FCF0F2",
"info": "#4D69ED", "info": "#4F63D9",
"info_pale": "#F0F4FF", "info_pale": "#EEF1FA",
# Backward-compatible names used by older UI code and integrations. # Backward-compatible names used by older UI code and integrations.
"ink": "#111F46", "ink": "#1A1C1F",
"ink_soft": "#3F4E75", "ink_soft": "#606163",
"teal": "#5761F4", "teal": "#4F63D9",
"teal_dark": "#4451E2", "teal_dark": "#354BB4",
"teal_pale": "#F0F2FF", "teal_pale": "#EEF1FA",
} }
@@ -87,32 +88,47 @@ COLORS = {
# more ink than Latin at the same pixel size, so the steps are spaced widely # more ink than Latin at the same pixel size, so the steps are spaced widely
# enough that two adjacent levels are always distinguishable. # enough that two adjacent levels are always distinguishable.
TYPE = { TYPE = {
"fs_caption": "12px", # table headers, hints, badges, timestamps "fs_caption": "13px", # table headers, hints, badges, timestamps
"fs_body": "13px", # default UI text "fs_body": "14px", # default UI text
"fs_strong": "14px", # emphasised body, dialog prompts "fs_strong": "15px", # emphasised body, dialog prompts
"fs_section": "16px", # card and section titles "fs_section": "16px", # card and section titles
"fs_title": "20px", # page titles, dialog titles "fs_title": "20px", # page titles, dialog titles
"fs_display": "26px", # metric values, empty-state glyphs "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 ------------------------------------------------------ # --- Control metrics ------------------------------------------------------
# Three interactive heights. ``h_default`` drops from the previous 36px: the # Controls leave room for the 14px reading size without increasing table density.
# old value made every toolbar, filter row and inline action read as heavy,
# which is the main reason the workspace felt clunky.
METRICS = { METRICS = {
"h_compact": "28px", # inline row actions, chips, links "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_cta": "38px", # primary dialog actions, sidebar navigation
"h_bar": "52px", # dialog header / footer bars "h_bar": "52px", # dialog header / footer bars
"r_sm": "6px", # Corner radii. These existed before but the pages invented their own, so
"r_md": "8px", # the same role - a card - shipped at 10, 11, 12, 13, 14 and 16 px across
"r_lg": "12px", # six pages. Nothing in a product is a "10px card"; it is either a card or
"r_xl": "16px", # 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_control": "12px", # horizontal padding inside default controls
"pad_compact": "9px", "pad_compact": "9px",
} }
_QSS_TOKENS = {**COLORS, **TYPE, **METRICS} _QSS_TOKENS = {**COLORS, **TYPE, **WEIGHTS, **METRICS}
def crisp_pixmap(width: int, height: int | None = None) -> QPixmap: def crisp_pixmap(width: int, height: int | None = None) -> QPixmap:
@@ -144,8 +160,8 @@ GLOBAL_QSS = Template(
QWidget { QWidget {
color: $text; color: $text;
background-color: transparent; background-color: transparent;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: $fs_body; font-size: $fs_body;
font-weight: $fw_body;
} }
QMainWindow, QDialog, QWidget#LoginCanvas { QMainWindow, QDialog, QWidget#LoginCanvas {
@@ -170,7 +186,7 @@ QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
QDialog[businessDialog="true"] QLabel[dialogRole="title"] { QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
color: $text; color: $text;
font-size: $fs_section; font-size: $fs_section;
font-weight: 700; font-weight: $fw_control;
} }
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] { QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
color: $muted; color: $muted;
@@ -214,48 +230,43 @@ QInputDialog QLabel {
font-size: $fs_strong; font-size: $fs_strong;
} }
QWidget#AppCanvas { QWidget#AppCanvas {
background-color: qlineargradient( background-color: $canvas;
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 $canvas,
stop: 0.58 $canvas_mid,
stop: 1 $canvas_glow
);
border-radius: 18px; border-radius: 18px;
} }
QWidget#ShellWorkspace, QStackedWidget#ShellPageStack { QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
background-color: #FCFDFE; background-color: $canvas_mid;
} }
QLabel[role="muted"] { color: $muted; } QLabel[role="muted"] { color: $muted; }
QLabel[role="danger"] { color: $danger; } QLabel[role="danger"] { color: $danger; }
QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; } QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; }
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: $fs_strong; } QLabel[role="breadcrumbSeparator"] { color: #8E8F90; font-size: $fs_strong; }
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: 600; } QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: $fw_control; }
QLabel[role="eyebrow"] { QLabel[role="eyebrow"] {
color: $indigo_hover; color: $indigo_hover;
font-size: $fs_caption; font-size: $fs_caption;
font-weight: 700; font-weight: $fw_control;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
QLabel[role="pageTitle"] { QLabel[role="pageTitle"] {
color: $text; color: $text;
font-size: $fs_title; font-size: $fs_title;
font-weight: 700; font-weight: $fw_heading;
} }
QLabel[role="sectionTitle"] { QLabel[role="sectionTitle"] {
color: $text; color: $text;
font-size: $fs_section; font-size: $fs_section;
font-weight: 700; font-weight: $fw_control;
} }
QLabel[role="display"] { QLabel[role="display"] {
color: $text; color: $text;
font-size: $fs_display; font-size: $fs_display;
font-weight: 700; font-weight: $fw_heading;
} }
QLabel[role="metric"] { QLabel[role="metric"] {
color: $text; color: $text;
font-size: $fs_title; font-size: $fs_title;
font-weight: 700; font-weight: $fw_heading;
} }
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel, 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:hover { border-color: $line_soft; background-color: $surface_alt; }
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; } 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#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; }
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; } QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
QFrame#ReceptionAiCard { QFrame#ReceptionAiCard {
min-height: 132px; min-height: 132px;
background-color: #F8FAFF; background-color: #FFFFFF;
border: 1px solid $line_soft; border: 1px solid $line_soft;
border-radius: 13px; border-radius: 13px;
} }
QLabel#ReceptionAiTitle { QLabel#ReceptionAiTitle {
color: $indigo_pressed; color: $indigo_pressed;
font-size: $fs_strong; font-size: $fs_strong;
font-weight: 700; font-weight: $fw_control;
} }
QFrame#ReceptionAiCard QPushButton[variant="secondary"] { QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
min-height: $h_compact; min-height: $h_compact;
@@ -302,7 +313,7 @@ QLabel#MetricGlyph {
border: 1px solid $line_soft; border: 1px solid $line_soft;
border-radius: 11px; border-radius: 11px;
font-size: $fs_section; font-size: $fs_section;
font-weight: 700; font-weight: $fw_heading;
} }
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; } QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; } QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; }
@@ -318,7 +329,7 @@ QGroupBox {
border: 1px solid $line; border: 1px solid $line;
border-radius: 12px; border-radius: 12px;
background-color: $glass; background-color: $glass;
font-weight: 600; font-weight: $fw_control;
} }
QGroupBox::title { QGroupBox::title {
subcontrol-origin: margin; subcontrol-origin: margin;
@@ -334,7 +345,7 @@ QPushButton {
border-radius: $r_md; border-radius: $r_md;
background-color: $surface; background-color: $surface;
color: $text; color: $text;
font-weight: 600; font-weight: $fw_control;
} }
QPushButton:hover { QPushButton:hover {
background-color: $raised; background-color: $raised;
@@ -345,7 +356,8 @@ QPushButton:pressed {
border-color: $indigo_pressed; border-color: $indigo_pressed;
} }
QPushButton:focus { QPushButton:focus {
border: 2px solid $focus; border: 1px solid $indigo;
background-color: $indigo_pale;
} }
QPushButton:checked { QPushButton:checked {
color: #FFFFFF; color: #FFFFFF;
@@ -361,10 +373,7 @@ QPushButton:disabled {
QPushButton[variant="primary"] { QPushButton[variant="primary"] {
color: #FFFFFF; color: #FFFFFF;
background-color: qlineargradient( background-color: $indigo;
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 $indigo, stop: 1 #7769F7
);
border-color: $indigo; border-color: $indigo;
} }
QPushButton[variant="primary"]:hover, QPushButton[variant="primary"]:hover,
@@ -377,7 +386,10 @@ QPushButton[variant="primary"]:checked {
background-color: $indigo_pressed; background-color: $indigo_pressed;
border-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 { QPushButton[variant="primary"]:disabled {
color: $disabled_text; color: $disabled_text;
background-color: $surface_alt; background-color: $surface_alt;
@@ -410,7 +422,7 @@ QWidget#RowActions QPushButton[rowAction="true"] {
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $r_sm; border-radius: $r_sm;
font-weight: 600; font-weight: $fw_control;
} }
QWidget#RowActions QPushButton[rowAction="true"]:hover { QWidget#RowActions QPushButton[rowAction="true"]:hover {
color: $indigo_pressed; color: $indigo_pressed;
@@ -436,7 +448,7 @@ QToolButton#RowActionsMore {
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $r_sm; border-radius: $r_sm;
font-weight: 600; font-weight: $fw_control;
} }
QToolButton#RowActionsMore:hover { QToolButton#RowActionsMore:hover {
color: $text; color: $text;
@@ -457,7 +469,7 @@ QPushButton#NoteAttachmentPreview {
background-color: $surface_alt; background-color: $surface_alt;
border: 1px solid $line; border: 1px solid $line;
border-radius: 8px; border-radius: 8px;
font-weight: 500; font-weight: $fw_control;
} }
QPushButton#NoteAttachmentPreview:hover { QPushButton#NoteAttachmentPreview:hover {
background-color: $indigo_pale; background-color: $indigo_pale;
@@ -503,15 +515,15 @@ QPushButton[variant="success"] {
background-color: $success; background-color: $success;
border-color: $success; border-color: $success;
} }
QPushButton[variant="success"]:hover { background-color: #65D4B7; border-color: #65D4B7; } QPushButton[variant="success"]:hover { background-color: #216A56; border-color: #216A56; }
QPushButton[variant="success"]:pressed { background-color: #319F84; border-color: #319F84; } QPushButton[variant="success"]:pressed { background-color: #1B5746; border-color: #1B5746; }
QPushButton[variant="warning"] { QPushButton[variant="warning"] {
color: #FFFFFF; color: #FFFFFF;
background-color: $warning; background-color: $warning;
border-color: $warning; border-color: $warning;
} }
QPushButton[variant="warning"]:hover { background-color: #F0C97C; border-color: #F0C97C; } QPushButton[variant="warning"]:hover { background-color: #925B19; border-color: #925B19; }
QPushButton[variant="warning"]:pressed { background-color: #B99045; border-color: #B99045; } QPushButton[variant="warning"]:pressed { background-color: #794B14; border-color: #794B14; }
QPushButton[variant="ghost"] { QPushButton[variant="ghost"] {
color: $text_soft; color: $text_soft;
@@ -536,7 +548,7 @@ QPushButton[variant="link"] {
background-color: transparent; background-color: transparent;
border-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="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
QPushButton[variant="chip"] { QPushButton[variant="chip"] {
min-height: $h_compact; min-height: $h_compact;
@@ -564,7 +576,7 @@ QPushButton[variant="nav"] {
background-color: transparent; background-color: transparent;
color: $muted; color: $muted;
text-align: left; text-align: left;
font-weight: 600; font-weight: $fw_control;
} }
QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; } QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; }
QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; } 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:hover { color: $text; background-color: $surface_alt; border-color: $line; }
QToolButton:pressed { background-color: $indigo_pale; border-color: $indigo_pressed; } 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:checked { color: #FFFFFF; background-color: $indigo_pressed; border-color: $indigo_hover; }
QToolButton:disabled { color: $disabled_text; background-color: transparent; border-color: transparent; } QToolButton:disabled { color: $disabled_text; background-color: transparent; border-color: transparent; }
QToolButton[diagnosisChip="true"] { QToolButton[diagnosisChip="true"] {
@@ -616,8 +628,8 @@ QDoubleSpinBox:hover, QKeySequenceEdit:hover { border-color: $indigo_hover; }
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus,
QDateEdit:focus, QDateTimeEdit:focus, QTimeEdit:focus, QSpinBox:focus, QDateEdit:focus, QDateTimeEdit:focus, QTimeEdit:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QKeySequenceEdit:focus { QDoubleSpinBox:focus, QKeySequenceEdit:focus {
border: 2px solid $focus; border: 1px solid $indigo;
background-color: $surface_alt; background-color: $surface;
} }
QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only { QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only {
color: $muted; color: $muted;
@@ -676,7 +688,7 @@ QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView { QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView {
color: $text; color: $text;
background-color: $surface; background-color: $surface;
alternate-background-color: #FBFCFF; alternate-background-color: $surface_alt;
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
gridline-color: $line; gridline-color: $line;
@@ -684,7 +696,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
selection-color: $text; selection-color: $text;
outline: 0; outline: 0;
} }
QAbstractItemView:focus { border: 1px solid $indigo_hover; } QAbstractItemView:focus { border: 0; }
QTableWidget::item, QTableView::item { QTableWidget::item, QTableView::item {
padding: 6px 8px; padding: 6px 8px;
border-bottom: 1px solid $line; border-bottom: 1px solid $line;
@@ -695,14 +707,14 @@ QTableWidget::item:selected, QTableView::item:selected {
background-color: $selection; background-color: $selection;
} }
QHeaderView::section { QHeaderView::section {
background-color: #F7F9FE; background-color: $raised;
color: $muted; color: $muted;
border: 0; border: 0;
border-right: 1px solid $line; border-right: 0;
border-bottom: 1px solid $line; border-bottom: 1px solid $line;
padding: 7px 8px; padding: 7px 8px;
font-size: $fs_caption; font-size: $fs_caption;
font-weight: 700; font-weight: $fw_control;
} }
QHeaderView::section:hover { color: $text; background-color: $raised; } QHeaderView::section:hover { color: $text; background-color: $raised; }
QTableCornerButton::section { background-color: $surface_alt; border: 0; } QTableCornerButton::section { background-color: $surface_alt; border: 0; }
@@ -736,7 +748,7 @@ QTabBar::tab {
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 9px; border-radius: 9px;
font-weight: 600; font-weight: $fw_control;
} }
QTabBar::tab:hover { color: $text; background-color: $surface_alt; } QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
QTabBar::tab:focus { border-color: $focus; } QTabBar::tab:focus { border-color: $focus; }
@@ -788,8 +800,8 @@ QMenu::item {
border-radius: $r_sm; border-radius: $r_sm;
background-color: transparent; background-color: transparent;
} }
QMenu::item:selected { color: #FFFFFF; background-color: $indigo_pressed; } QMenu::item:selected { color: $indigo_pressed; background-color: $indigo_pale; }
QMenu::item:pressed { background-color: $indigo; } QMenu::item:pressed { color: #FFFFFF; background-color: $indigo; }
QMenu::item:disabled { color: $disabled_text; background-color: transparent; } QMenu::item:disabled { color: $disabled_text; background-color: transparent; }
QMenu::item[danger="true"] { color: $danger; } QMenu::item[danger="true"] { color: $danger; }
QMenu::item[danger="true"]:selected { color: $danger; background-color: $danger_pale; } QMenu::item[danger="true"]:selected { color: $danger; background-color: $danger_pale; }
@@ -841,8 +853,8 @@ QScrollBar::handle:vertical {
min-height: $h_compact; min-height: $h_compact;
border-radius: 4px; border-radius: 4px;
} }
QScrollBar::handle:vertical:hover { background: $indigo_pressed; } QScrollBar::handle:vertical:hover { background: #A7B1C9; }
QScrollBar::handle:vertical:pressed { background: $indigo; } QScrollBar::handle:vertical:pressed { background: #8894B2; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
QScrollBar:horizontal { QScrollBar:horizontal {
@@ -855,8 +867,8 @@ QScrollBar::handle:horizontal {
min-width: 30px; min-width: 30px;
border-radius: 4px; border-radius: 4px;
} }
QScrollBar::handle:horizontal:hover { background: $indigo_pressed; } QScrollBar::handle:horizontal:hover { background: #A7B1C9; }
QScrollBar::handle:horizontal:pressed { background: $indigo; } QScrollBar::handle:horizontal:pressed { background: #8894B2; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; } QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
@@ -885,7 +897,7 @@ QLabel#StatusBadge {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $r_sm; border-radius: $r_sm;
font-size: $fs_caption; 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="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); } 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: 1px solid $indigo;
border-radius: 8px; border-radius: 8px;
font-size: $fs_caption; font-size: $fs_caption;
font-weight: 700; font-weight: $fw_control;
} }
QWidget#Pager QPushButton { QWidget#Pager QPushButton {
min-height: $h_compact; min-height: $h_compact;
@@ -918,7 +930,7 @@ QLabel#EmptyStateGlyph {
border: 1px solid $line_soft; border: 1px solid $line_soft;
border-radius: 22px; border-radius: 22px;
font-size: $fs_display; font-size: $fs_display;
font-weight: 500; font-weight: $fw_control;
} }
QFrame#MessageBanner { QFrame#MessageBanner {
@@ -927,7 +939,7 @@ QFrame#MessageBanner {
color: $text_soft; color: $text_soft;
} }
QFrame#MessageBanner QLabel { background-color: transparent; } 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="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="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); } 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: 1px solid $line_soft;
border-radius: 11px; border-radius: 11px;
padding: 11px 16px; 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="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); } 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: 1px solid $line_soft;
border-radius: 18px; border-radius: 18px;
font-size: $fs_strong; font-size: $fs_strong;
font-weight: 700; font-weight: $fw_heading;
} }
QWidget#LoginBrandPanel { QWidget#LoginBrandPanel {
background-color: qlineargradient( background-color: qlineargradient(
@@ -997,6 +1009,29 @@ QFrame#LoginCard {
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; } QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
QSplitter::handle:hover { background-color: $indigo_pressed; } 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 { QToolTip {
color: $text; color: $text;
background-color: $raised; 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: def _block_is_all_bold(block: Any) -> bool:
"""True when every visible run in the block is bold. """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 = QTextCharFormat()
char_format.setFont(heading_font) char_format.setFont(heading_font)
if role != "doctor": if role != "doctor":
char_format.setForeground(QColor("#111B3F")) char_format.setForeground(QColor("#1A1C1F"))
cursor.setPosition(block.position()) cursor.setPosition(block.position())
cursor.setPosition( cursor.setPosition(
block.position() + block.length() - 1, block.position() + block.length() - 1,
@@ -1123,14 +1318,21 @@ def _apply_group(
def _register_preferred_cjk_fonts() -> str: 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 app = QApplication.instance()
collection. Registering the already-installed YaHei collection only registered = getattr(app, "_doctor_bundled_font_family", None)
when it is missing prevents Chinese text from degrading to tofu boxes in if registered:
packaged captures and headless visual checks. Other platforms continue return registered
to use their native PingFang/Noto fallback. 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 = { platform_families = {
"win32": ("Microsoft YaHei UI", "Microsoft YaHei"), "win32": ("Microsoft YaHei UI", "Microsoft YaHei"),
@@ -1259,12 +1461,27 @@ class _BusinessDialogStyleFilter(QObject):
or dialog.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) 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 def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
event_type = event.type() event_type = event.type()
if isinstance(watched, QDialogButtonBox) and event_type in { if event_type not in self._WATCHED:
QEvent.Type.Polish, return False
QEvent.Type.Show, 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) _polish_dialog_buttons(watched)
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish: elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
# Window flags can only be changed before the dialog is on screen, # 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") app.setStyle("Fusion")
cjk_family = _register_preferred_cjk_fonts() cjk_family = _register_preferred_cjk_fonts()
# Pin the Windows CJK face explicitly. A comma-separated QSS fallback # Resolve once for both styled widgets and custom-painted table cells.
# list can resolve to Qt's generic sans face in offscreen/native title-bar application_font = QFont(cjk_family)
# captures, which changes glyph width and can even yield tofu boxes. application_font.setPixelSize(int(TYPE["fs_body"].removesuffix("px")))
application_font = QFont(app.font()) application_font.setWeight(QFont.Weight.Normal)
application_font.setFamily(cjk_family) 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) app.setFont(application_font)
palette = QPalette() palette = QPalette()
active = { active = {
@@ -1324,7 +1547,7 @@ def apply_theme(app: QApplication) -> None:
QPalette.ColorRole.ButtonText: COLORS["text"], QPalette.ColorRole.ButtonText: COLORS["text"],
QPalette.ColorRole.Base: COLORS["surface"], QPalette.ColorRole.Base: COLORS["surface"],
QPalette.ColorRole.Window: COLORS["canvas"], QPalette.ColorRole.Window: COLORS["canvas"],
QPalette.ColorRole.Shadow: "#B7C0D2", QPalette.ColorRole.Shadow: "#C2C2C2",
QPalette.ColorRole.Highlight: COLORS["indigo"], QPalette.ColorRole.Highlight: COLORS["indigo"],
QPalette.ColorRole.HighlightedText: "#FFFFFF", QPalette.ColorRole.HighlightedText: "#FFFFFF",
QPalette.ColorRole.Link: COLORS["info"], QPalette.ColorRole.Link: COLORS["info"],
@@ -1347,7 +1570,7 @@ def apply_theme(app: QApplication) -> None:
QPalette.ColorRole.ButtonText: COLORS["disabled_text"], QPalette.ColorRole.ButtonText: COLORS["disabled_text"],
QPalette.ColorRole.Base: COLORS["disabled_surface"], QPalette.ColorRole.Base: COLORS["disabled_surface"],
QPalette.ColorRole.Window: COLORS["canvas"], QPalette.ColorRole.Window: COLORS["canvas"],
QPalette.ColorRole.Shadow: "#C8CFDC", QPalette.ColorRole.Shadow: "#D2D2D2",
QPalette.ColorRole.Highlight: COLORS["line"], QPalette.ColorRole.Highlight: COLORS["line"],
QPalette.ColorRole.HighlightedText: COLORS["disabled_text"], QPalette.ColorRole.HighlightedText: COLORS["disabled_text"],
QPalette.ColorRole.Link: 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.Inactive, active)
_apply_group(palette, QPalette.ColorGroup.Disabled, disabled) _apply_group(palette, QPalette.ColorGroup.Disabled, disabled)
app.setPalette(palette) app.setPalette(palette)
app.setStyleSheet(GLOBAL_QSS) app.setStyleSheet(GLOBAL_QSS + _indicator_qss())
_install_business_dialog_styling(app) _install_business_dialog_styling(app)
@@ -1373,6 +1596,7 @@ __all__ = [
"GLOBAL_QSS", "GLOBAL_QSS",
"METRICS", "METRICS",
"TYPE", "TYPE",
"WEIGHTS",
"apply_theme", "apply_theme",
"crisp_pixmap", "crisp_pixmap",
"mark_business_dialog", "mark_business_dialog",
+85 -11
View File
@@ -10,7 +10,15 @@ from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
from typing import Any 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.QtGui import QColor, QPainter, QPaintEvent, QResizeEvent
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
@@ -35,6 +43,8 @@ from doctor_workstation.core.errors import (
AuthenticationExpiredError, AuthenticationExpiredError,
) )
from . import icons, motion
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool] AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None _AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
@@ -470,6 +480,8 @@ class PageHeader(QWidget):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("PageHeader") self.setObjectName("PageHeader")
self._compact = False
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8) layout.setSpacing(8)
@@ -514,7 +526,31 @@ class PageHeader(QWidget):
def set_subtitle(self, text: str) -> None: def set_subtitle(self, text: str) -> None:
self.subtitle_label.setText(text) 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): class MetricCard(QFrame):
@@ -636,7 +672,9 @@ class MessageBanner(QFrame):
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setContentsMargins(12, 9, 12, 9) layout.setContentsMargins(12, 9, 12, 9)
layout.setSpacing(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.setObjectName("MessageBannerIcon")
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter) self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.icon.setFixedSize(20, 20) self.icon.setFixedSize(20, 20)
@@ -647,18 +685,33 @@ class MessageBanner(QFrame):
layout.addWidget(self.label, 1) layout.addWidget(self.label, 1)
self.setVisible(bool(text)) 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: 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.label.setText(text)
self.icon.setText(glyphs.get(kind, "i")) self.icon.setPixmap(icons.pixmap(glyph, role, 16))
self.setProperty("kind", kind) self.setProperty("kind", kind)
self.style().unpolish(self) self.style().unpolish(self)
self.style().polish(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: def clear(self) -> None:
self.setVisible(False) if self.isVisible():
self.label.clear() motion.fade_out(self, duration=motion.FAST, on_finished=self.label.clear)
else:
self.label.clear()
class Toast(QLabel): class Toast(QLabel):
@@ -670,7 +723,10 @@ class Toast(QLabel):
self.hide() self.hide()
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.setSingleShot(True) 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: def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
self.setText(text) self.setText(text)
@@ -682,7 +738,7 @@ class Toast(QLabel):
if parent is not None: if parent is not None:
self.move(max(16, parent.width() - self.width() - 24), 20) self.move(max(16, parent.width() - self.width() - 24), 20)
self.raise_() self.raise_()
self.show() motion.fade_in(self, duration=motion.FAST)
self._timer.start(duration) self._timer.start(duration)
@@ -730,7 +786,7 @@ class _BusyTrack(QWidget):
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
rect = self.rect() rect = self.rect()
painter.setBrush(QColor("#D8DEEA")) painter.setBrush(QColor("#E4E4E5"))
painter.drawRoundedRect(rect, 3, 3) painter.drawRoundedRect(rect, 3, 3)
chunk_width = max(36, int(rect.width() * 0.32)) chunk_width = max(36, int(rect.width() * 0.32))
span = rect.width() + chunk_width span = rect.width() + chunk_width
@@ -766,6 +822,16 @@ class BusyOverlay(QFrame):
self.raise_() self.raise_()
super().showEvent(event) 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): class OverlayHost(QWidget):
"""Widget base that automatically sizes a BusyOverlay child.""" """Widget base that automatically sizes a BusyOverlay child."""
@@ -795,11 +861,19 @@ class SortableTable(QTableWidget):
self.setColumnCount(len(self.columns)) self.setColumnCount(len(self.columns))
self.setHorizontalHeaderLabels([column.title for column in self.columns]) self.setHorizontalHeaderLabels([column.title for column in self.columns])
self.setAlternatingRowColors(True) 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.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setVerticalScrollMode(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.setMinimumHeight(0)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.verticalHeader().setVisible(False) self.verticalHeader().setVisible(False)
+10 -5
View File
@@ -1634,7 +1634,11 @@ def test_report_bubbles_report_the_height_they_actually_paint(
host, bubble = _fitted_bubble(reply) host, bubble = _fitted_bubble(reply)
assert bubble.height() > 0 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.close()
host.deleteLater() host.deleteLater()
@@ -1646,14 +1650,15 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
assert "#FEF3F2" in risk_card assert "#FEF3F2" in risk_card
assert "#F1B35C" not in risk_card # 旧的橙色描边 assert "#F1B35C" not in risk_card # 旧的橙色描边
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0] 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: def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
qss = ai_consult_module.AI_CONSULT_QSS qss = ai_consult_module.AI_CONSULT_QSS
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1) body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
assert len(body) == 2 or "font-size: 13px" in qss assert "color: #1a1c1f" in body.lower()
assert "font-size: 14px" in body
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0] 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 assert "font-size: 11px" not in block
+1 -1
View File
@@ -559,7 +559,7 @@ def test_keyboard_focus_has_a_visible_state(
assert focus_target.hasFocus() assert focus_target.hasFocus()
assert application.focusWidget() is focus_target assert application.focusWidget() is focus_target
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS 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() drawer.close()
host.close() host.close()
+18 -9
View File
@@ -10,6 +10,8 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest import pytest
from PySide6.QtCore import QElapsedTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
@@ -398,12 +400,19 @@ def test_appointments_page_default_query_is_today_pending(
page.refresh() page.refresh()
application.processEvents() application.processEvents()
completed = QElapsedTimer()
completed.start()
while page.table.rowCount() == 0 and completed.elapsed() < 2_000:
QTest.qWait(10)
filters = page._query_filters() filters = page._query_filters()
assert filters["status"] == 1 assert filters["status"] == 1
assert filters["include_status_counts"] == 1 assert filters["include_status_counts"] == 1
assert filters["start_date"] == filters["end_date"] assert filters["start_date"] == filters["end_date"]
assert "diag_scope_relax" not in filters assert "diag_scope_relax" not in filters
assert page.table.rowCount() >= 1 assert page.table.rowCount() >= 1
page.close()
application.processEvents()
def test_demo_appointment_status_counts_respect_date_scope() -> None: 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 appointment_text.count("\n") == 2
assert "2026-08-11 14:30" in appointment_text assert "2026-08-11 14:30" in appointment_text
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10 required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
assert 60 <= page.table.rowHeight(0) <= 66 assert page.table.rowHeight(0) >= required
assert page.table.rowHeight(0) >= min(required, 66)
assert page.table.item(0, 4).toolTip() == appointment_text assert page.table.item(0, 4).toolTip() == appointment_text
page.close() page.close()
@@ -854,9 +862,8 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
permissions=PermissionSet(["*"]), permissions=PermissionSet(["*"]),
current_user={"id": 1001, "role_id": 1}, current_user={"id": 1001, "role_id": 1},
) )
# 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter, # Approved shared chrome: 208 px rail, 76 px topbar, no outer gutter.
# and 62 px top bar leaves a 1161x680 page viewport. page.resize(1158, 692)
page.resize(1161, 680)
page.show() page.show()
application.processEvents() application.processEvents()
rows = [ rows = [
@@ -886,10 +893,12 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
application.processEvents() application.processEvents()
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())] heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
# 与其余列表页一致的“面包屑 + 标题 + 副标题”页头。 # Compact title and folded filters leave more room for the patient queue.
assert page.header.height() == 62 assert page.header.height() >= page.header.minimumSizeHint().height()
assert page.filter_panel.height() <= 84 assert page.header.height() <= 44
assert all(60 <= height <= 66 for height in heights) 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.table.viewport().height() // max(heights) >= 4
assert page.pager.isVisibleTo(page) assert page.pager.isVisibleTo(page)
assert page.content_layout.count() == 1 assert page.content_layout.count() == 1
@@ -367,6 +367,8 @@ def test_refresh_generation_ignores_late_results(
monkeypatch.setattr(consultations_module, "run_async", queue_async) monkeypatch.setattr(consultations_module, "run_async", queue_async)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"])) page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.refresh(silent=True) page.refresh(silent=True)
# Identical in-flight requests are deduplicated; a new query supersedes one.
page.keyword_edit.setText("新患者")
page.refresh(silent=True) page.refresh(silent=True)
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1}) callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
+3 -3
View File
@@ -986,14 +986,14 @@ def test_choice_chips_keep_visible_checked_style_when_readonly(
application.processEvents() application.processEvents()
# Sample the pad (not glyph center) so white text does not hide the fill. # Sample the pad (not glyph center) so white text does not hide the fill.
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2) 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) diet.setReadOnly(True)
application.processEvents() application.processEvents()
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2) disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert selected.isChecked() assert selected.isChecked()
assert selected.isEnabled() assert selected.isEnabled()
assert diet.isReadOnly() assert diet.isReadOnly()
assert disabled_color.name().lower() == "#f0f2ff" assert disabled_color.name().lower() == "#eef1fa"
dialog.close() dialog.close()
application.processEvents() 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()) selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents() application.processEvents()
color = selected.grab().toImage().pixelColor(6, selected.height() // 2) color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert color.name().lower() == "#f0f2ff" assert color.name().lower() == "#eef1fa"
assert diet.isReadOnly() assert diet.isReadOnly()
assert not dialog.save_button.isVisibleTo(dialog) assert not dialog.save_button.isVisibleTo(dialog)
dialog.close() dialog.close()
+132 -32
View File
@@ -10,11 +10,14 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest import pytest
from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal
from PySide6.QtGui import QColor, QImage, QPainter from PySide6.QtGui import QColor, QImage, QPainter
from PySide6.QtTest import QTest
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
QComboBox,
QFrame, QFrame,
QSizePolicy, QSizePolicy,
QSpinBox,
QToolButton, QToolButton,
QWidget, QWidget,
) )
@@ -161,27 +164,44 @@ def _page() -> ConsultationsPage:
return ConsultationsPage(_CancellationRepository(), permissions=PermissionSet(["*"])) 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( def test_visual_hierarchy_and_filter_contract(
application: QApplication, application: QApplication,
) -> None: ) -> None:
page = _page() page = _page()
content_layout = page.page_scroll.widget().layout() content_layout = page.page_scroll.widget().layout()
margins = content_layout.contentsMargins() margins = content_layout.contentsMargins()
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (18, 10, 18, 10) # The approved blue shell has no outer gutter; the page owns this spacing.
assert content_layout.spacing() == 8 assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (27, 24, 26, 8)
assert content_layout.spacing() == 10
status_card = page.findChild(QFrame, "DiagnosisStatusCard") status_card = page.findChild(QFrame, "DiagnosisStatusCard")
assert status_card is not None assert status_card is not None
assert page.page_header.height() == 62 assert page.page_header.maximumHeight() >= page.page_header.minimumSizeHint().height()
assert status_card.height() == 50 assert status_card.height() == 54
assert page.findChild(QFrame, "DiagnosisFilterCard") is not None assert page.findChild(QFrame, "DiagnosisFilterCard") is not None
assert page.findChild(QFrame, "DiagnosisListCard") is not None assert page.findChild(QFrame, "DiagnosisListCard") is not None
assert page.filters_card.height() == 90 assert page.filters_card.isHidden()
assert page.keyword_edit.maximumWidth() == 380 assert page.page_header.height() <= 44
assert page.keyword_edit.maximumWidth() == 340
assert list(page.status_buttons) == ["1", "", "4", "2", "3"] assert list(page.status_buttons) == ["1", "", "4", "2", "3"]
assert page.status_buttons["1"].isChecked() assert page.status_buttons["1"].isChecked()
assert not page.advanced_filters.isVisible() assert not page.advanced_filters.isVisible()
assert page.more_filter_button.text() == "更多筛选" 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] == [ 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) page._toggle_advanced_filters(True)
assert not page.advanced_filters.isHidden() assert not page.advanced_filters.isHidden()
assert page.more_filter_button.text() == "收起" 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() assert page.unserved_sort_combo.isHidden()
date_ranges = page.advanced_filters.findChildren(QFrame, "DiagnosisDateRange") date_ranges = page.advanced_filters.findChildren(QFrame, "DiagnosisDateRange")
assert len(date_ranges) == 2 assert len(date_ranges) == 2
@@ -237,15 +257,81 @@ def test_pending_assign_and_secondary_chip_semantics(
application.processEvents() 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( def test_dedicated_model_fixed_columns_selection_and_sort(
application: QApplication, application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
page = _page() page = _page()
assert isinstance(page.table.model(), QAbstractTableModel) assert isinstance(page.table.model(), QAbstractTableModel)
assert isinstance(page.table.model(), DiagnosisTableModel) 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.LEFT_WIDTHS == (48, 70, 82, 102, 244, 90, 84, 90, 88, 122)
assert page.table_host.FIXED_WIDTHS == (120, 410) # Reference-aligned video/actions remain frozen in a compact 250px pane.
assert page.table_host.fixed.width() == 532 assert page.table_host.FIXED_WIDTHS == (92, 158)
assert page.table_host.fixed.width() == 250
assert page.table.isColumnHidden(10) assert page.table.isColumnHidden(10)
assert page.table_host.fixed.isColumnHidden(9) assert page.table_host.fixed.isColumnHidden(9)
assert not page.table_host.fixed.isColumnHidden(10) 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) page.table_host.sort_unserved_requested.connect(requested.append)
assert page.table_host.model.headerData(9, Qt.Orientation.Horizontal) == "未服务天数" assert page.table_host.model.headerData(9, Qt.Orientation.Horizontal) == "未服务天数"
assert page.table_host.model._sort_direction == "" 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) page.table_host.main.horizontalHeader().sectionClicked.emit(9)
assert requested == ["desc"] assert requested == ["desc"]
assert page.table_host.model._sort_direction == "desc" assert page.table_host.model._sort_direction == "desc"
@@ -300,7 +389,7 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
application.processEvents() 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, application: QApplication,
) -> None: ) -> None:
page = _page() page = _page()
@@ -321,13 +410,13 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
page.loading_overlay.stop() page.loading_overlay.stop()
page.pager.update_state(3, 97) page.pager.update_state(3, 97)
assert 40 <= page.pager.height() <= 44 assert page.pager.height() == 24
pager_margins = page.pager.layout().contentsMargins() pager_margins = page.pager.layout().contentsMargins()
assert pager_margins.top() >= 4 assert pager_margins.top() == 0
assert pager_margins.bottom() >= 4 assert pager_margins.bottom() == 0
assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40] assert not page.pager.findChildren(QComboBox)
assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5 assert not page.pager.findChildren(QSpinBox)
assert page.pager.jumper.maximum() == 7 assert not page.pager.findChildren(QToolButton)
page.close() page.close()
application.processEvents() application.processEvents()
@@ -335,11 +424,11 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("record", "stripe", "channel"), ("record", "stripe", "channel"),
[ [
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#9a6813", "warning"), (_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#a9691d", "warning"),
(_row(702, has_appointment=0, appointments=[]), "#2f6edb", "info"), (_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, application: QApplication,
record: dict[str, Any], record: dict[str, Any],
stripe: str, 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(0, 20).name() == stripe
assert image.pixelColor(1, 20).name() == stripe assert image.pixelColor(1, 20).name() == stripe
assert image.pixelColor(2, 20).name() == stripe assert image.pixelColor(2, 20).name() == stripe
gradient = image.pixelColor(6, 20) assert image.pixelColor(3, 20).name() == "#f7f7f7", f"{channel} stripe widened"
assert gradient.name() != "#f8f8f8", f"{channel} hover collapsed to a neutral row" 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( 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.isHidden()
assert shadow.width() == 12 assert shadow.width() == 12
assert shadow.geometry().right() == page.table_host.fixed.geometry().left() - 1 assert shadow.geometry().right() == page.table_host.fixed.geometry().left() - 1
assert 'font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei"' in ( assert "font-family:" not in DIAGNOSIS_INDEX_QSS
DIAGNOSIS_INDEX_QSS
)
assert "QTableView:focus" in DIAGNOSIS_INDEX_QSS assert "QTableView:focus" in DIAGNOSIS_INDEX_QSS
assert "QToolButton[rowLink]:focus" in DIAGNOSIS_INDEX_QSS assert "QToolButton[rowLink]:focus" in DIAGNOSIS_INDEX_QSS
assert '#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"]' in ( 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), min(danger_image.width(), danger_rect.right() + 1),
): ):
color = danger_image.pixelColor(x, y) 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 red_text_pixels += 1
assert red_text_pixels > 8, "删除文案必须由 danger 色绘制,不能回退成原生黑色" assert red_text_pixels > 8, "删除文案必须由 danger 色绘制,不能回退成原生黑色"
more.menu().hide() more.menu().hide()
@@ -642,7 +730,7 @@ def test_error_state_is_persistent_until_rows_replace_it(application: QApplicati
@pytest.mark.parametrize( @pytest.mark.parametrize(
("size", "minimum_visible_rows"), ("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( def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
application: QApplication, application: QApplication,
@@ -686,9 +774,10 @@ def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
application.processEvents() 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, application: QApplication,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
immediate_async: None,
) -> None: ) -> None:
page = _page() page = _page()
rows = [ rows = [
@@ -704,15 +793,26 @@ def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable
for _ in range(4): for _ in range(4):
application.processEvents() application.processEvents()
host_height = page.table_host.height() host_height = page.table_host.height()
calls: list[int] = []
monkeypatch.setattr(page, "refresh", lambda silent=False: None) def list_consultations(**query: Any) -> dict[str, Any]:
page._change_page_size(40) calls.append(query["page_no"])
page.table_host.set_rows(rows) 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): for _ in range(4):
application.processEvents() application.processEvents()
main_scroll = page.table.verticalScrollBar() main_scroll = page.table.verticalScrollBar()
fixed_scroll = page.table_host.fixed.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 page.table_host.height() == host_height
assert main_scroll.maximum() == fixed_scroll.maximum() assert main_scroll.maximum() == fixed_scroll.maximum()
main_scroll.setValue(main_scroll.maximum() // 2) main_scroll.setValue(main_scroll.maximum() // 2)
+49 -37
View File
@@ -7,7 +7,7 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest import pytest
from PySide6.QtCore import QDate, QPoint from PySide6.QtCore import QDate, QPoint, QRect
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
from doctor_workstation.core import PermissionSet 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) monkeypatch.setattr(patients_module, "run_async", queue_async)
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"])) workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
workspace.refresh() workspace.refresh()
# Identical in-flight queries coalesce; a changed query starts a new generation.
workspace.keyword_edit.setText("新结果")
workspace.refresh() workspace.refresh()
newer = { newer = {
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}], "lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
@@ -621,62 +623,72 @@ def test_patient_list_reference_geometry_and_row_actions(
repository = DemoDoctorRepository() repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD) session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user) page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
# 1366x768 shell minus its 170 px patient rail, 26 px outer gutter, page.filter_disclosure.set_expanded(True)
# and 62 px top bar leaves a 1170x680 page viewport. # Patient pages use the approved 208 px rail and 76 px top bar.
page.resize(1170, 680) page.resize(1536 - 208, 960 - 76)
page.show() page.show()
application.processEvents() application.processEvents()
page.patient_workspace.refresh() page.patient_workspace.refresh()
application.processEvents() application.processEvents()
workspace = page.patient_workspace 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.keyword_edit.objectName() == "PatientKeywordInput"
assert workspace.status_host.objectName() == "PatientStatusFilterHost" assert workspace.status_host.objectName() == "PatientStatusFilterHost"
assert workspace.quick_host.objectName() == "PatientQuickDateHost" assert workspace.quick_host.objectName() == "PatientQuickDateHost"
assert workspace.date_host.objectName() == "PatientDateRangeHost" 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.search_button.objectName() == "PatientSearchButton"
assert workspace.reset_button.objectName() == "PatientResetButton" assert workspace.reset_button.objectName() == "PatientResetButton"
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton" assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
for width in (1170, 1290, 1514): assert workspace.table.rowCount() == 4
page.resize(width, 680) assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
application.processEvents() assert workspace.table.horizontalScrollBar().maximum() == 0
for widget in ( assert workspace.pager.summary_label.text() == "共 4 条 · 已全部加载"
workspace.keyword_edit, assert workspace.pager.height() == 24
workspace.status_host, assert workspace.pager.page_size == 15
workspace.search_button, assert workspace.pager.page == 1
workspace.reset_button, assert not workspace.pager.has_more
workspace.quick_host, for width, height in ((1536, 960), (1366, 768), (1024, 640)):
workspace.date_host, page.resize(width - 208, height - 76)
workspace.custom_date_button, 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)) for widget in widgets:
assert top_left.x() >= 0 assert widget.isVisibleTo(page)
assert top_left.x() + widget.width() <= workspace.filter_card.width() assert host.rect().contains(QRect(widget.mapTo(host, QPoint()), widget.size())), widget.objectName()
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values()) 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 page.tabs.minimumHeight() == 0
assert workspace.content_stack.minimumHeight() == 0 assert workspace.content_stack.minimumHeight() == 0
assert workspace.table.minimumHeight() == 0 assert workspace.table.minimumHeight() == 0
assert workspace.bottom_actions.isHidden() 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.objectName() == "PatientTable"
assert workspace.table.columnCount() == 10 assert workspace.table.columnCount() == 10
assert workspace.table.horizontalHeaderItem(9).text() == "操作" assert workspace.table.horizontalHeaderItem(9).text() == "操作"
if workspace.table.rowCount(): assert workspace.table.horizontalHeader().visualIndex(9) == 9
assert workspace.table.rowHeight(0) == 36 assert all(not workspace.table.isColumnHidden(column) for column in range(10))
assert workspace.table.cellWidget(0, 0) is not None actions = workspace.table.cellWidget(0, 9)
assert workspace.table.cellWidget(0, 9) is not None 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() page.close()
application.processEvents() application.processEvents()
+34 -24
View File
@@ -144,23 +144,18 @@ def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> in
) )
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown( def test_infinite_footer_is_shared_compact_and_has_no_page_controls(application):
application: QApplication, from doctor_workstation.ui.infinite_list import InfiniteList
) -> None: footer = InfiniteList(15)
pager = BusinessPager(15) footer.show()
pager.update_state(2, 44)
pager.show()
_settle(application) _settle(application)
assert prescriptions_module.InfiniteList is InfiniteList
assert prescriptions_module.BusinessPager is BusinessPager assert footer.height() == 24
assert 40 <= pager.height() <= 44 assert footer.findChildren(QComboBox) == []
assert pager.minimumHeight() == pager.maximumHeight() == 42 assert not hasattr(footer, "page_size_label")
assert pager.findChildren(QComboBox) == [] assert not hasattr(footer, "next")
assert pager.page_size_label.text() == "15 条/页" assert footer.layout().contentsMargins().bottom() == 0
margins = pager.layout().contentsMargins() footer.close()
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("kind", ["issued", "library"])
@@ -182,9 +177,19 @@ def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
header = page.findChild(QWidget, "PageHeader") header = page.findChild(QWidget, "PageHeader")
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar" toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
toolbar = page.findChild(QFrame, toolbar_name) toolbar = page.findChild(QFrame, toolbar_name)
assert header is not None and 60 <= header.height() <= 64 assert header is not None and header.height() >= header.minimumSizeHint().height()
assert toolbar is not None and 44 <= toolbar.height() <= 48 assert toolbar is not None
assert 40 <= page.pager.height() <= 44 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.pager.minimumHeight() == page.pager.maximumHeight()
assert page.table.minimumHeight() == 0 assert page.table.minimumHeight() == 0
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
@@ -196,18 +201,21 @@ def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
assert pager_position.x() + page.pager.width() <= page.width() assert pager_position.x() + page.pager.width() <= page.width()
assert pager_position.y() >= 0 assert pager_position.y() >= 0
assert pager_position.y() + page.pager.height() <= page.height() assert pager_position.y() + page.pager.height() <= page.height()
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + ( page_size_right = page.pager.summary_label.mapTo(page, QPoint()).x() + (
page.pager.page_size_label.width() page.pager.summary_label.width()
) )
assert page_size_right <= page.width() assert page_size_right <= page.width()
pager_margins = page.pager.layout().contentsMargins() pager_margins = page.pager.layout().contentsMargins()
toolbar_margins = toolbar.layout().contentsMargins() toolbar_margins = toolbar.layout().contentsMargins()
assert pager_margins.left() == toolbar_margins.left() == 16 expected_inset = 16 if kind == "issued" else 18
assert pager_margins.right() == toolbar_margins.right() == 16 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": if kind == "issued":
filters = page.findChild(QFrame, "PrescriptionFilterBar") filters = page.findChild(QFrame, "PrescriptionFilterBar")
assert filters is not None and 84 <= filters.height() <= 92 assert filters is not None and filters.height() == 144
actions_host = page.table.cellWidget(0, 2) actions_host = page.table.cellWidget(0, 2)
assert actions_host is not None assert actions_host is not None
row_edit = next( row_edit = next(
@@ -225,6 +233,8 @@ def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
else: else:
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar") filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
assert filters is not None assert filters is not None
page.filter_disclosure.set_expanded(True)
_settle(application)
assert page.name_filter.minimumWidth() < 500 assert page.name_filter.minimumWidth() < 500
filter_right = filters.contentsRect().right() filter_right = filters.contentsRect().right()
for control in ( for control in (
+1 -1
View File
@@ -140,7 +140,7 @@ def test_number_column_renders_sn_id_and_visible_warning_with_dynamic_height(
assert "已有关联业务订单,当前处方存在重复药材:黄 芪" in duplicate_tip assert "已有关联业务订单,当前处方存在重复药材:黄 芪" in duplicate_tip
assert blank_height > normal_height assert blank_height > normal_height
assert duplicate_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) image = page.table.viewport().grab().toImage().convertToFormat(QImage.Format.Format_RGB32)
red_pixels = 0 red_pixels = 0
+3 -3
View File
@@ -412,7 +412,7 @@ def _finish_queued(callback: dict[str, Any], result: Any) -> None:
callback["on_finished"]() callback["on_finished"]()
def test_prescription_lists_snapshot_queries_and_replay_pending_refresh( def test_prescription_lists_snapshot_queries_and_supersede_stale_refresh(
application: QApplication, application: QApplication,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -435,7 +435,7 @@ def test_prescription_lists_snapshot_queries_and_replay_pending_refresh(
library.refresh() library.refresh()
library.name_filter.setText("新条件") library.name_filter.setText("新条件")
library.refresh() library.refresh()
assert len(library_callbacks) == 1 assert len(library_callbacks) == 2
first_function, first_options = library_callbacks[0] first_function, first_options = library_callbacks[0]
first_result = first_function() first_result = first_function()
_finish_queued(first_options, first_result) _finish_queued(first_options, first_result)
@@ -464,7 +464,7 @@ def test_prescription_lists_snapshot_queries_and_replay_pending_refresh(
issued.refresh() issued.refresh()
issued.patient_filter.setText("新患者") issued.patient_filter.setText("新患者")
issued.refresh() issued.refresh()
assert len(issued_callbacks) == 1 assert len(issued_callbacks) == 2
first_function, first_options = issued_callbacks[0] first_function, first_options = issued_callbacks[0]
first_result = first_function() first_result = first_function()
_finish_queued(first_options, first_result) _finish_queued(first_options, first_result)
+8 -5
View File
@@ -200,7 +200,7 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
"disable_edit": 1, "disable_edit": 1,
"creator_id": 9, "creator_id": 9,
"creator_name": "张医生", "creator_name": "张医生",
"create_time": "2026-08-10 12:00:00", "create_time": QDate.currentDate().toString("yyyy-MM-dd") + " 12:00:00",
} }
], ],
"count": 1, "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 # 处方类型与公开范围改由 _RowDecorationDelegate 绘制,只有操作列仍是真实控件。
assert page.table.cellWidget(0, 6) is not None 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 page.table.cellWidget(0, 9) is not None
assert not page.view_button.isHidden() and page.view_button.isEnabled() assert not page.view_button.isHidden() and page.view_button.isEnabled()
assert not page.ai_button.isHidden() and page.ai_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.columnCount() == 11
assert page.table.horizontalHeaderItem(2).text() == "操作" assert page.table.horizontalHeaderItem(2).text() == "操作"
assert page.table.cellWidget(0, 2) is not None assert page.table.cellWidget(0, 2) is not None
assert page.table.cellWidget(0, 3) is not None # 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
assert page.table.cellWidget(0, 6) is not None # 各挂一个 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( row_edit = next(
button button
for button in page.table.cellWidget(0, 2).findChildren(QPushButton) for button in page.table.cellWidget(0, 2).findChildren(QPushButton)
@@ -59,7 +59,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
geometry, neighbor_geometry = button.geometry(), neighbor.geometry() geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
clicked: list[bool] = [] clicked: list[bool] = []
button.clicked.connect(lambda: clicked.append(True)) button.clicked.connect(lambda: clicked.append(True))
assert surface_color(button) == "#fff7f8" assert surface_color(button) == "#ffffff"
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
QTest.mouseMove(button, button.rect().center()) 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() button.clearFocus()
app.processEvents() app.processEvents()
assert clicked == [] assert clicked == []
assert surface_color(button) == "#fff7f8" assert surface_color(button) == "#ffffff"
assert button.geometry() == geometry assert button.geometry() == geometry
assert neighbor.geometry() == neighbor_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) button.setEnabled(False)
QTest.mouseMove(button, button.rect().center()) QTest.mouseMove(button, button.rect().center())
app.processEvents() app.processEvents()
assert surface_color(button) == "#f7f8fb" assert surface_color(button) == "#f7fafe"
assert button.cursor().shape() == Qt.CursorShape.ArrowCursor assert button.cursor().shape() == Qt.CursorShape.ArrowCursor
QTest.mouseClick(button, Qt.MouseButton.LeftButton) QTest.mouseClick(button, Qt.MouseButton.LeftButton)
assert clicked == [] assert clicked == []
button.setEnabled(True) button.setEnabled(True)
app.processEvents() app.processEvents()
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor 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): 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) button.setFocus(Qt.FocusReason.TabFocusReason)
app.processEvents() app.processEvents()
assert button.hasFocus() 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 surface_color(button, border=True) != border_before
assert button.geometry() == geometry assert button.geometry() == geometry
assert neighbor.geometry() == neighbor_geometry assert neighbor.geometry() == neighbor_geometry
QTest.mouseMove(button, button.rect().center()) QTest.mouseMove(button, button.rect().center())
app.processEvents() app.processEvents()
assert surface_color(button) == "#fff0f2" assert surface_color(button) == "#fff0f2"
assert surface_color(button, border=True) == "#cf4656" assert surface_color(button, border=True) == "#be4b58"
+58 -11
View File
@@ -51,6 +51,52 @@ def application() -> QApplication:
return QApplication.instance() or 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 @pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None: def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately( def run_immediately(
@@ -622,6 +668,7 @@ def test_queue_date_picker_filters_the_selected_day(
page = ReceptionPage(Repository(), PermissionSet([])) page = ReceptionPage(Repository(), PermissionSet([]))
page.show() page.show()
application.processEvents() application.processEvents()
page.filter_disclosure.button.click()
page.queue_date_button.click() page.queue_date_button.click()
calendar = page.queue_date_button.calendarWidget() calendar = page.queue_date_button.calendarWidget()
assert calendar.isVisible() assert calendar.isVisible()
@@ -1036,14 +1083,14 @@ def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summar
caption = medication.findChild(QLabel, "ReceptionCaseFieldCaption") caption = medication.findChild(QLabel, "ReceptionCaseFieldCaption")
value = medication.findChild(QLabel, "ReceptionCaseFieldValue") value = medication.findChild(QLabel, "ReceptionCaseFieldValue")
assert title is not None and title.font().pixelSize() == 18 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 value is not None and value.font().pixelSize() == 14
assert title.font().weight() >= 700 assert title.font().weight() == 600
assert caption.font().weight() >= 600 assert caption.font().weight() == 400
assert value.font().weight() >= 700 assert value.font().weight() == 600
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#17264d" assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#617092" assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#5d6b80"
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#253a83" assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
assert value.wordWrap() assert value.wordWrap()
assert value.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByMouse 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 [call["page_no"] for call in calls] == [1, 2]
assert all(call["page_size"] == 15 for call in calls) assert all(call["page_size"] == 15 for call in calls)
assert page.queue_list.count() == 22 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() assert not page._queue_has_more()
page.close() page.close()
application.processEvents() application.processEvents()
@@ -1466,11 +1513,11 @@ def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
buttons = [ buttons = [
button button
for button in ( for button in (
page.complete_button,
page.notify_button, page.notify_button,
page.history_button, page.history_button,
page.video_button, page.video_button,
page.more_button, page.more_button,
page.complete_button,
) )
if button.isVisibleTo(page) 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.maximumHeight() > 520
assert page.ai_analysis_card.findChildren(QScrollArea) == [] assert page.ai_analysis_card.findChildren(QScrollArea) == []
assert left.height() == right.height() assert left.height() == right.height()
assert 0 <= right.left() - left.right() - 1 <= 2 assert right.left() - left.right() - 1 == 10
assert abs(left.width() * 5 - right.width() * 4) <= 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.width() <= page.ai_analysis_card.contentsRect().width()
assert page.ai_summary_label.minimumSizeHint().width() <= 48 assert page.ai_summary_label.minimumSizeHint().width() <= 48
+5 -3
View File
@@ -400,9 +400,11 @@ def test_refresh_button_visible_and_f5_uses_same_debounce(harness, application)
page.poll_timer.stop() page.poll_timer.stop()
application.processEvents() application.processEvents()
assert page.refresh_button.isVisible() assert page.refresh_button.isVisible()
assert page.refresh_button.text() == "刷新" assert not page.refresh_button.icon().isNull()
assert page.refresh_button.width() >= 50 assert page.refresh_button.accessibleName() == "刷新接诊台"
assert page.refresh_button.geometry().right() < page.queue_date_button.geometry().left() 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() page.note_edit.setFocus()
application.processEvents() application.processEvents()
QTest.keyClick(page.note_edit, Qt.Key.Key_F5) QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
+167 -31
View File
@@ -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 import shell as shell_module
from doctor_workstation.ui.shell import NavigationItem, ShellWindow 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): def _logical_pixel(image: Any, x: int, y: int):
@@ -135,6 +135,7 @@ def shell_window(
("prescriptions", "已开处方", "笺", "tcm.prescription/lists"), ("prescriptions", "已开处方", "笺", "tcm.prescription/lists"),
("patients", "我的患者", "患", "firstvisit.myPatient/lists"), ("patients", "我的患者", "患", "firstvisit.myPatient/lists"),
("consultations", "问诊列表", "询", "tcm.diagnosis/lists"), ("consultations", "问诊列表", "询", "tcm.diagnosis/lists"),
("legacy_reference", "原版框架参照", "旧", "legacy.reference/lists"),
) )
] ]
monkeypatch.setattr( monkeypatch.setattr(
@@ -160,38 +161,40 @@ def shell_window(
application.processEvents() 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, application: QApplication,
shell_window: ShellWindow, shell_window: ShellWindow,
) -> None: ) -> None:
assert shell_window.navigate("legacy_reference")
for width, height in ((1024, 640), (1440, 900)): for width, height in ((1024, 640), (1440, 900)):
shell_window.resize(width, height) shell_window.resize(width, height)
application.processEvents() application.processEvents()
assert shell_window.sidebar.width() == 179 # 独立legacy参照路由继续验证原有导轨和外圈留白。
assert shell_window.sidebar.width() == 190
assert shell_window.topbar.height() == 62 assert shell_window.topbar.height() == 62
assert shell_window.tabs_host.height() == 0 assert shell_window.tabs_host.height() == 0
assert shell_window.workspace.width() == width - 26 - 179 assert shell_window.workspace.width() == width - 26 - 190
assert shell_window.stack.width() == width - 26 - 179 assert shell_window.stack.width() == width - 26 - 190
assert shell_window.stack.height() == height - 26 - 62 assert shell_window.stack.height() == height - 26 - 62
assert shell_window.stack.geometry().right() < shell_window.workspace.width() assert shell_window.stack.geometry().right() < shell_window.workspace.width()
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height() assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
image = shell_window.grab().toImage() image = shell_window.grab().toImage()
assert _logical_pixel(image, 20, 300).name().lower() in { # 侧边栏不再是画布上的一块面板:它就是画布本身,所以导轨内任意一点
"#f2f5fd", # 都必须与外圈留白同色。原先它是 #F4F7FE→#EEF3FD 的斜向渐变,
"#f3f6fd", # 沿整条左边缘都对不上画布,形成一道常驻接缝。
"#f2f6fe", assert _logical_pixel(image, 20, 300).name().lower() == COLORS["canvas"].lower()
"#f3f6fe", assert _logical_pixel(image, 6, 300).name().lower() == COLORS["canvas"].lower()
}
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff" 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, application: QApplication,
shell_window: ShellWindow, shell_window: ShellWindow,
) -> None: ) -> None:
assert shell_window.navigate("legacy_reference")
for width, height in ((1024, 640), (1366, 768)): for width, height in ((1024, 640), (1366, 768)):
shell_window.resize(width, height) shell_window.resize(width, height)
application.processEvents() 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( def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
shell_window: ShellWindow, shell_window: ShellWindow,
) -> None: ) -> None:
assert shell_window.navigate("legacy_reference")
assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint
assert ( assert (
shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号" 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, application: QApplication,
shell_window: ShellWindow, shell_window: ShellWindow,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
key: str,
) -> None: ) -> None:
reception = shell_window.pages["reception"] page = shell_window.pages[key]
assert isinstance(reception, _ShellPageDouble) assert isinstance(page, _ShellPageDouble)
assert shell_window.navigate("reception") assert shell_window.navigate(key)
reception.ai_context_available = True page.ai_context_available = True
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
monkeypatch.setattr( monkeypatch.setattr(
shell_module, 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, 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() application.processEvents()
assert reception.ai_open_count == 0 assert page.ai_open_count == 0
assert len(opened) == 1 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( def test_shell_hides_global_ai_entries_without_ai_permission(
application: QApplication, application: QApplication,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
key: str,
permission: str,
) -> None: ) -> None:
navigation = [ navigation = [
NavigationItem( NavigationItem(
"appointments", key,
"问诊列表", "问诊列表",
"号", "号",
_ShellPageDouble, _ShellPageDouble,
("doctor.appointment/lists",), (permission,),
) )
] ]
monkeypatch.setattr( monkeypatch.setattr(
@@ -452,13 +467,19 @@ def test_shell_hides_global_ai_entries_without_ai_permission(
window = ShellWindow( window = ShellWindow(
object(), object(),
{"user": {"name": "无 AI 权限医生"}, "demo_mode": True}, {"user": {"name": "无 AI 权限医生"}, "demo_mode": True},
permissions={"doctor.appointment/lists"}, permissions={permission},
) )
window.show() window.show()
application.processEvents() application.processEvents()
assert window.assistant_card.isHidden() assert window.assistant_card.isHidden()
assert window.ai_top_button.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() window.close()
application.processEvents() 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"] 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, shell_window: ShellWindow,
) -> None: ) -> 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() shell_window.toggle_sidebar()
assert shell_window.sidebar.width() == 68 assert shell_window.sidebar.width() == 68
assert shell_window.nav_buttons["consultations"].text() == "" assert shell_window.nav_buttons[key].text() == ""
assert shell_window.nav_buttons["consultations"].isChecked() assert shell_window.nav_buttons[key].isChecked()
shell_window.toggle_sidebar() shell_window.toggle_sidebar()
assert shell_window.sidebar.width() == 195 assert shell_window.sidebar.width() == expanded_width
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表") assert shell_window.nav_buttons[key].text() == title
assert shell_window.nav_buttons["consultations"].isChecked() assert shell_window.nav_buttons[key].isChecked()
def test_shell_directional_controls_have_no_unicode_arrow_text( def test_shell_directional_controls_have_no_unicode_arrow_text(
+23 -2
View File
@@ -4,8 +4,16 @@ from types import SimpleNamespace
from typing import Any from typing import Any
from PySide6.QtCore import QCoreApplication, QSettings from PySide6.QtCore import QCoreApplication, QSettings
from PySide6.QtGui import QPageSize, QRawFont from PySide6.QtGui import QFont, QPageSize, QRawFont
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QMessageBox, QVBoxLayout from PySide6.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
)
from doctor_workstation import app as app_module from doctor_workstation import app as app_module
from doctor_workstation.app import ApplicationController from doctor_workstation.app import ApplicationController
@@ -54,12 +62,25 @@ def test_theme_resolves_real_chinese_glyphs() -> None:
application = QApplication.instance() or QApplication([]) application = QApplication.instance() or QApplication([])
apply_theme(application) apply_theme(application)
raw_font = QRawFont.fromFont(application.font()) 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("甄养堂医生工作站") glyphs = raw_font.glyphIndexesForString("甄养堂医生工作站")
assert glyphs assert glyphs
assert all(glyph > 0 for glyph in glyphs) assert all(glyph > 0 for glyph in glyphs)
assert len(set(glyphs)) > 1 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: def test_theme_marks_dynamic_business_dialogs_and_semantic_buttons() -> None:
application = QApplication.instance() or QApplication([]) application = QApplication.instance() or QApplication([])
@@ -359,9 +359,37 @@ class WecomPromotionLogic
$automation, $automation,
$skipVerify, $skipVerify,
$createdRemote, $createdRemote,
&$eligibleUserIds,
$now, $now,
$adminId $adminId
): void { ): 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) { if ($existingPool === null) {
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($poolData + [ $id = (int) Db::name('qywx_promotion_pool')->insertGetId($poolData + [
'public_key' => bin2hex(random_bytes(16)), 'public_key' => bin2hex(random_bytes(16)),
@@ -428,7 +456,13 @@ class WecomPromotionLogic
} }
$syncError = ''; $syncError = '';
if (!$createdRemote) { // 已有方案补建官方链接时,创建接口发生在事务锁之前;若锁内成员范围已变化,
// 仍需把最终范围加入同步队列,避免新链接停留在过期的远端成员集合。
$createdRangeChanged = $createdRemote
&& $remote !== null
&& !QywxPromotionMemberRange::same((array) ($remote['range_userids'] ?? []), $eligibleUserIds);
$needsQueuedSync = !$createdRemote || $createdRangeChanged;
if ($needsQueuedSync) {
QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId); QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId);
if ($syncImmediately) { if ($syncImmediately) {
try { try {
@@ -455,15 +489,15 @@ class WecomPromotionLogic
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。 // 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
'automation_saved' => $automation !== null, 'automation_saved' => $automation !== null,
'sync_error' => $syncError, 'sync_error' => $syncError,
'sync_queued' => !$createdRemote && !$syncImmediately, 'sync_queued' => $needsQueuedSync && !$syncImmediately,
]; ];
} }
/** /**
* 批量局部更新分流方案。changes 只覆盖显式传入的字段;每个方案仍复用 * 批量局部更新分流方案。changes 只覆盖显式传入的字段;方案配置仍复用
* savePool 的成员、自动化、素材和企业微信同步校验。 * 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 public static function batchUpdatePools(array $params, int $adminId, array $adminInfo): array
{ {
@@ -481,7 +515,7 @@ class WecomPromotionLogic
if (!is_array($changes)) { if (!is_array($changes)) {
throw new RuntimeException('批量修改内容格式不正确'); 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); $unknownFields = array_diff(array_keys($changes), $allowedFields);
if ($unknownFields !== []) { if ($unknownFields !== []) {
throw new RuntimeException('批量修改包含不支持的字段'); throw new RuntimeException('批量修改包含不支持的字段');
@@ -493,6 +527,31 @@ class WecomPromotionLogic
throw new RuntimeException('兜底获客助手链接格式不正确'); 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; $automationPatch = null;
if (array_key_exists('automation_config', $changes)) { if (array_key_exists('automation_config', $changes)) {
if (!is_array($changes['automation_config']) || $changes['automation_config'] === []) { if (!is_array($changes['automation_config']) || $changes['automation_config'] === []) {
@@ -510,6 +569,9 @@ class WecomPromotionLogic
QywxPromotionConfig::assertInstalled(); QywxPromotionConfig::assertInstalled();
$automationPatch = $changes['automation_config']; $automationPatch = $changes['automation_config'];
} }
if ($memberStatusPatch !== null && count($changes) > 1) {
throw new RuntimeException('员工上下线需要单独批量保存,请勿与其他方案配置同时修改');
}
// 必须在任何方案写入前完成整批权限校验,避免越权请求产生部分更新。 // 必须在任何方案写入前完成整批权限校验,避免越权请求产生部分更新。
$pools = []; $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 = []; $results = [];
$updated = 0; $updated = 0;
$failed = 0; $failed = 0;
$syncErrorCount = 0; $syncErrorCount = 0;
$syncQueuedCount = 0; $syncQueuedCount = 0;
$memberMatched = 0;
$memberUpdated = 0;
$hasPoolConfigChanges = array_diff(array_keys($changes), ['member_status']) !== [];
foreach ($poolIds as $poolId) { foreach ($poolIds as $poolId) {
$pool = $pools[$poolId]; $pool = $pools[$poolId];
$currentAutomation = QywxPromotionConfig::forPool($poolId); $currentAutomation = QywxPromotionConfig::forPool($poolId);
@@ -565,14 +664,28 @@ class WecomPromotionLogic
} }
try { try {
// 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。 $saved = ['sync_error' => '', 'sync_queued' => false];
$saved = self::savePool($saveParams, $adminId, $adminInfo, 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'] ?? '')); $syncError = trim((string) ($saved['sync_error'] ?? ''));
$updated++; $updated++;
if ($syncError !== '') { if ($syncError !== '') {
$syncErrorCount++; $syncErrorCount++;
} }
$syncQueued = !empty($saved['sync_queued']); $syncQueued = !empty($saved['sync_queued'])
|| !empty($poolMemberResult['dispatch']['queued']);
if ($syncQueued) { if ($syncQueued) {
$syncQueuedCount++; $syncQueuedCount++;
} }
@@ -582,6 +695,8 @@ class WecomPromotionLogic
'success' => true, 'success' => true,
'sync_error' => $syncError, 'sync_error' => $syncError,
'sync_queued' => $syncQueued, 'sync_queued' => $syncQueued,
'member_matched' => $poolMemberResult['matched'],
'member_updated' => $poolMemberResult['updated'],
]; ];
} catch (\Throwable $error) { } catch (\Throwable $error) {
$failed++; $failed++;
@@ -600,10 +715,156 @@ class WecomPromotionLogic
'failed' => $failed, 'failed' => $failed,
'sync_error_count' => $syncErrorCount, 'sync_error_count' => $syncErrorCount,
'sync_queued_count' => $syncQueuedCount, 'sync_queued_count' => $syncQueuedCount,
'member_matched' => $memberMatched,
'member_updated' => $memberUpdated,
'results' => $results, '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 public static function saveWidget(array $params, int $adminId, array $adminInfo): array
{ {
$id = max(0, (int) ($params['pool_id'] ?? $params['id'] ?? 0)); $id = max(0, (int) ($params['pool_id'] ?? $params['id'] ?? 0));
@@ -877,36 +1138,93 @@ class WecomPromotionLogic
$poolId = (int) $member['pool_id']; $poolId = (int) $member['pool_id'];
self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo); self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
$enabled = (int) ($params['status'] ?? $params['enabled'] ?? 1) === 1 ? 1 : 0; $enabled = (int) ($params['status'] ?? $params['enabled'] ?? 1) === 1 ? 1 : 0;
if ($enabled === 0) { $statusOnly = !empty($params['_status_only']);
$otherEnabled = (int) Db::name('qywx_promotion_pool_member') $startAt = $statusOnly ? 0 : self::parseTime($params['active_start'] ?? null);
->where('pool_id', $poolId) $endAt = $statusOnly ? 0 : self::parseTime($params['active_end'] ?? null);
->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);
if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) { if ($startAt > 0 && $endAt > 0 && $endAt <= $startAt) {
throw new RuntimeException('生效结束时间必须晚于开始时间'); throw new RuntimeException('生效结束时间必须晚于开始时间');
} }
$now = time(); $now = time();
Db::transaction(function () use ($id, $enabled, $params, $startAt, $endAt, $now): void { $planned = Db::transaction(function () use (
Db::name('qywx_promotion_pool_member')->where('id', $id)->update([ $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, 'enabled' => $enabled,
// 企业微信原生多人路由不支持逐成员权重;字段固定为 1,仅兼容已部署表结构。 // 企业微信原生多人路由不支持逐成员权重;字段固定为 1,仅兼容已部署表结构。
'weight' => 1, 'weight' => 1,
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))), 'daily_limit' => $statusOnly
'active_start' => $startAt, ? (int) ($lockedMember['daily_limit'] ?? 0)
'active_end' => $endAt, : min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255), '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, '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 = ''; $syncError = '';
if ($planned['queued']) { if ($planned['queued']) {
try { try {
@@ -931,10 +1249,7 @@ class WecomPromotionLogic
return self::saveMember([ return self::saveMember([
'id' => $id, 'id' => $id,
'status' => $status, 'status' => $status,
'daily_limit' => (int) ($row['daily_limit'] ?? 0), '_status_only' => true,
'active_start' => (int) ($row['active_start'] ?? 0),
'active_end' => (int) ($row['active_end'] ?? 0),
'remark' => (string) ($row['remark'] ?? ''),
], $adminId, $adminInfo); ], $adminId, $adminInfo);
} }
@@ -1274,14 +1589,19 @@ class WecomPromotionLogic
} }
/** @param list<array{id:int,userid:string}> $members @return list<string> */ /** @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 === []) { if ($members === []) {
throw new RuntimeException('请至少选择一名获客成员'); 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() ? Db::name('qywx_promotion_pool_member')->where('pool_id', $poolId)->whereNull('delete_time')->select()->toArray()
: []; : []);
$rulesByUserId = []; $rulesByUserId = [];
foreach ($rules as $rule) { foreach ($rules as $rule) {
$rulesByUserId[(string) $rule['userid']] = $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\QywxExternalContact;
use app\common\model\QywxSyncSettings; use app\common\model\QywxSyncSettings;
use app\common\service\qywx\MediaChannelService; use app\common\service\qywx\MediaChannelService;
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
use app\common\service\wechat\WechatWorkService; use app\common\service\wechat\WechatWorkService;
use think\facade\Cache; use think\facade\Cache;
use think\facade\Db; use think\facade\Db;
@@ -589,7 +590,11 @@ class CustomerLogic extends BaseLogic
* *
* @see https://developer.work.weixin.qq.com/document/path/92130 * @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); $externalUserId = trim($externalUserId);
if ($externalUserId === '') { if ($externalUserId === '') {
@@ -652,6 +657,13 @@ class CustomerLogic extends BaseLogic
$updateCount, $updateCount,
$skippedCount $skippedCount
); );
if ($snapshotEventId > 0 && $snapshotFollowUserId !== '') {
QywxExternalContactEventTagSnapshotService::captureFromFollowUsers(
$snapshotEventId,
$snapshotFollowUserId,
$followUsers
);
}
MediaChannelService::forgetCurrentTagCatalogCache(); MediaChannelService::forgetCurrentTagCatalogCache();
} }
@@ -674,12 +686,12 @@ class CustomerLogic extends BaseLogic
* *
* @param array<string, mixed> $data * @param array<string, mixed> $data
*/ */
public static function recordExternalContactEvent(array $data): void public static function recordExternalContactEvent(array $data): int
{ {
$changeType = (string) ($data['change_type'] ?? ''); $changeType = (string) ($data['change_type'] ?? '');
if ($changeType === '') { if ($changeType === '') {
// 没有 ChangeType 的事件流水没有价值,直接丢弃 // 没有 ChangeType 的事件流水没有价值,直接丢弃
return; return 0;
} }
$eventTime = (int) ($data['event_time'] ?? 0); $eventTime = (int) ($data['event_time'] ?? 0);
@@ -716,9 +728,18 @@ class CustomerLogic extends BaseLogic
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES (' $sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES ('
. implode(',', array_fill(0, count($cols), '?')) . ')'; . implode(',', array_fill(0, count($cols), '?')) . ')';
Db::execute($sql, array_values($row)); 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) { } catch (\Throwable $e) {
// 事件流水只用于统计,失败只记日志不阻塞主回调 // 事件流水只用于统计,失败只记日志不阻塞主回调
Log::warning('qywx external contact event insert failed: ' . $e->getMessage()); Log::warning('qywx external contact event insert failed: ' . $e->getMessage());
return 0;
} }
} }
@@ -341,7 +341,7 @@ class ConversionLogic
} }
/** /**
* 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 * The caller must pass the already-authorized admin range and the exact
* department ids represented by the clicked tree node. This keeps the * department ids represented by the clicked tree node. This keeps the
@@ -523,6 +523,11 @@ class ConversionLogic
return $timeCompare; return $timeCompare;
} }
$eventCompare = ((int) ($right['add_event_id'] ?? 0)) <=> ((int) ($left['add_event_id'] ?? 0));
if ($eventCompare !== 0) {
return $eventCompare;
}
return strcmp( return strcmp(
(string) ($left['external_userid'] ?? ''), (string) ($left['external_userid'] ?? ''),
(string) ($right['external_userid'] ?? '') (string) ($right['external_userid'] ?? '')
@@ -1263,16 +1268,16 @@ class ConversionLogic
} }
/** /**
* 区间新增加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。 * 区间新增客户关系:按企微员工聚合后,再投影到部门/成员/虚拟桶。
* *
* 口径(对齐企微客户列表 / 官方「新增客户」不含继承,而非原始回调条数): * 口径:
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重; * - 以 (user_id, external_userid) 作为唯一组合,同一员工的同一客户只计一次;
* - 同一员工在区间开始前已加过该客户的重加不计(企微「添加时间」仍是首次跟进时间, * - 同一客户添加到不同员工名下时,因 user_id 不同,每名员工分别计一次;
* 删后再加会再推 add_external_contact,但不能当当天新客,否则会跨日重复计); * - 同一组合在区间开始前已经产生过 add_external_contact 时,不再算区间新增;
* - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意 * - add_external_contact 是企微确认客户关系已建立后的权威事件;会话存档同意
* msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零; * msg_audit_approved 属于独立能力,不能作为加粉前置条件,否则未开通会话存档的员工会被整批清零;
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除),仍计入加粉总数, * - 组合在统计结束时已删除,仍计入加粉总数,并额外计入已删提示子集;
* 并额外计入已删提示子集;不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中; * 不处理 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中;
* - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享}; * - 剔除非投放加粉:跟进人 add_way∈{1 扫一扫, 2 搜索手机号, 3 名片分享};
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。 * - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
* *
@@ -1287,12 +1292,8 @@ class ConversionLogic
?array $adminIds = null ?array $adminIds = null
): array { ): array {
$detailRows = self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds); $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); return self::buildFanCountRows($detailRows);
} }
/** /**
@@ -1303,7 +1304,7 @@ class ConversionLogic
* *
* @param array<string, mixed>|null $mediaChannel * @param array<string, mixed>|null $mediaChannel
* @param int[]|null $adminIds * @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( private static function loadFanDetailRows(
int $startTimestamp, int $startTimestamp,
@@ -1335,7 +1336,7 @@ class ConversionLogic
} }
} }
$baseKey = self::requestRowsCacheKey('fans-detail-v1', [ $baseKey = self::requestRowsCacheKey('fans-detail-v3-employee-customer', [
$startTimestamp, $startTimestamp,
$endTimestamp, $endTimestamp,
self::mediaChannelCacheKey($mediaChannel), self::mediaChannelCacheKey($mediaChannel),
@@ -1380,6 +1381,28 @@ class ConversionLogic
} }
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event'; $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') $query = Db::name('qywx_external_contact_event')
->alias('e') ->alias('e')
->where('e.change_type', 'add_external_contact') ->where('e.change_type', 'add_external_contact')
@@ -1394,8 +1417,19 @@ class ConversionLogic
. ' AND prev_e.event_time < ?)', . ' AND prev_e.event_time < ?)',
['add_external_contact', $startTimestamp] ['add_external_contact', $startTimestamp]
) )
->field('e.user_id,e.external_userid,MIN(e.event_time) AS add_time') // 同一员工+客户只保留区间内最早事件;不同员工拥有不同 user_id,会分别保留。
->group('e.user_id, e.external_userid'); ->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) { if ($workWechatUserIds !== null) {
$query->whereIn('e.user_id', $workWechatUserIds); $query->whereIn('e.user_id', $workWechatUserIds);
} elseif ($unboundOnly) { } elseif ($unboundOnly) {
@@ -1408,56 +1442,47 @@ class ConversionLogic
} }
$effectiveQuery = clone $query; $effectiveQuery = clone $query;
if ($mediaChannel !== null) { if ($mediaChannel !== null) {
// 有效加粉保持原口径:只按未删客户的当前标签/渠道关系筛选。 MediaChannelService::applyExternalUserEventChannelFilter(
MediaChannelService::applyExternalUserChannelFilter($effectiveQuery, 'e.external_userid', $mediaChannel); $effectiveQuery,
'e.id',
'e.external_userid',
'e.user_id',
$mediaChannel
);
} }
$effectivePairs = $effectiveQuery $effectivePairs = $effectiveQuery
->whereRaw( ->whereRaw($livePairPredicate, $livePairBindings)
'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() ->select()
->toArray(); ->toArray();
foreach ($effectivePairs as &$effectivePair) {
$effectivePair['is_deleted'] = false;
}
unset($effectivePair);
$deletedQuery = clone $query; $deletedQuery = clone $query;
if ($mediaChannel !== null) { if ($mediaChannel !== null) {
// del_external_contact 会软删 contact 并清掉关系表,已删归属改用保留的 follow_users 快照。 MediaChannelService::applyExternalUserEventChannelFilter(
MediaChannelService::applyHistoricalExternalUserChannelFilter($deletedQuery, 'e.external_userid', $mediaChannel); $deletedQuery,
'e.id',
'e.external_userid',
'e.user_id',
$mediaChannel,
true
);
} }
$deletedPairs = $deletedQuery $deletedPairs = $deletedQuery
->whereRaw( ->whereRaw('NOT (' . $livePairPredicate . ')', $livePairBindings)
'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() ->select()
->toArray(); ->toArray();
foreach ($deletedPairs as &$deletedPair) {
$deletedPair['is_deleted'] = true;
}
unset($deletedPair);
// add_way 筛选对有效与已删粉丝使用同一口径。 // add_way 筛选对有效与已删组合使用同一口径。
$candidatePairs = array_merge($effectivePairs, $deletedPairs); $candidatePairs = array_merge($effectivePairs, $deletedPairs);
$candidatePairs = self::excludeUncountedFanPairs($candidatePairs); $candidatePairs = self::excludeUncountedFanPairs($candidatePairs);
$rows = self::buildFanDetailRows($candidatePairs, $effectivePairs); $rows = self::buildFanDetailRows($candidatePairs);
self::$requestRowsCache[$cacheKey] = $rows; self::$requestRowsCache[$cacheKey] = $rows;
@@ -1466,76 +1491,48 @@ class ConversionLogic
/** /**
* @param array<int,array<string,mixed>> $candidatePairs * @param array<int,array<string,mixed>> $candidatePairs
* @param array<int,array<string,mixed>> $effectivePairs * @return array<int, array{add_event_id:int,user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
* @return array<int, array{user_id:string,external_userid:string,add_time:int,is_deleted:bool,delete_time:int}>
*/ */
private static function buildFanDetailRows( private static function buildFanDetailRows(array $candidatePairs): array
array $candidatePairs, {
array $effectivePairs $detailsByPair = [];
): 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) { foreach ($candidatePairs as $pair) {
$eventId = max(0, (int) ($pair['add_event_id'] ?? 0));
$userId = trim((string) ($pair['user_id'] ?? '')); $userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? '')); $externalUserId = trim((string) ($pair['external_userid'] ?? ''));
if ($userId === '' || $externalUserId === '') { if ($eventId <= 0 || $userId === '' || $externalUserId === '') {
continue; continue;
} }
$key = $userId . "\0" . $externalUserId; $key = $userId . "\0" . $externalUserId;
$addTime = max(0, (int) ($pair['add_time'] ?? 0)); if (isset($detailsByPair[$key])) {
if (isset($detailsByKey[$key])) {
$existingTime = (int) ($detailsByKey[$key]['add_time'] ?? 0);
if ($addTime > 0 && ($existingTime <= 0 || $addTime < $existingTime)) {
$detailsByKey[$key]['add_time'] = $addTime;
}
continue; continue;
} }
$detailsByKey[$key] = [ $detailsByPair[$key] = [
'add_event_id' => $eventId,
'user_id' => $userId, 'user_id' => $userId,
'external_userid' => $externalUserId, 'external_userid' => $externalUserId,
'add_time' => $addTime, 'add_time' => max(0, (int) ($pair['add_time'] ?? 0)),
'is_deleted' => !isset($effectiveKeys[$key]), 'is_deleted' => !empty($pair['is_deleted']),
'delete_time' => 0, 'delete_time' => 0,
]; ];
} }
return array_values($detailsByKey); return array_values($detailsByPair);
} }
/** /**
* 按员工聚合全部候选新增,并标记其中期末已删除的子集。 * 按员工聚合全部新增员工+客户组合,并标记其中期末已删除的子集。
* *
* 候选对已经过“区间前未添加”、渠道与 add_way 规则,每个去重候选对都计入 * 每个不同 (user_id, external_userid) 都计入 add_fans_count;is_deleted=true 的组合同时计入
* add_fans_count;有效对是期末未删除的子集,因此候选对与有效对的差集另计入
* deleted_fans_count。deleted_fans_count 只是 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>> $candidatePairs
* @param array<int, array<string, mixed>> $effectivePairs
* @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}> * @return array<int, array{user_id: string, add_fans_count: int, deleted_fans_count: int}>
*/ */
private static function buildFanCountRows(array $candidatePairs, array $effectivePairs): array private static function buildFanCountRows(array $candidatePairs): 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 = []; $countsByUser = [];
$seenCandidateKeys = []; $seenPairs = [];
foreach ($candidatePairs as $pair) { foreach ($candidatePairs as $pair) {
$userId = trim((string) ($pair['user_id'] ?? '')); $userId = trim((string) ($pair['user_id'] ?? ''));
$externalUserId = trim((string) ($pair['external_userid'] ?? '')); $externalUserId = trim((string) ($pair['external_userid'] ?? ''));
@@ -1543,17 +1540,17 @@ class ConversionLogic
continue; continue;
} }
$key = $userId . "\0" . $externalUserId; $key = $userId . "\0" . $externalUserId;
if (isset($seenCandidateKeys[$key])) { if (isset($seenPairs[$key])) {
continue; continue;
} }
$seenCandidateKeys[$key] = true; $seenPairs[$key] = true;
$countsByUser[$userId] ??= [ $countsByUser[$userId] ??= [
'user_id' => $userId, 'user_id' => $userId,
'add_fans_count' => 0, 'add_fans_count' => 0,
'deleted_fans_count' => 0, 'deleted_fans_count' => 0,
]; ];
++$countsByUser[$userId]['add_fans_count']; ++$countsByUser[$userId]['add_fans_count'];
if (!isset($effectiveKeys[$key])) { if (!empty($pair['is_deleted'])) {
++$countsByUser[$userId]['deleted_fans_count']; ++$countsByUser[$userId]['deleted_fans_count'];
} }
} }
@@ -6,6 +6,7 @@ namespace app\api\controller;
use app\adminapi\logic\qywx\CustomerLogic; use app\adminapi\logic\qywx\CustomerLogic;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService; use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
use app\common\service\qywx\QywxPromotionMemberSchedulerService; use app\common\service\qywx\QywxPromotionMemberSchedulerService;
use app\common\service\qywx\QywxPromotionRangeSyncService; use app\common\service\qywx\QywxPromotionRangeSyncService;
use app\common\service\qywx\QywxPromotionAutomationService; use app\common\service\qywx\QywxPromotionAutomationService;
@@ -140,7 +141,7 @@ class QywxExternalContactCallbackController extends BaseApiController
// 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计; // 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计;
// 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。 // 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。
CustomerLogic::recordExternalContactEvent([ $eventId = CustomerLogic::recordExternalContactEvent([
'change_type' => $changeType, 'change_type' => $changeType,
'user_id' => $userId, 'user_id' => $userId,
'external_userid' => $extId, 'external_userid' => $extId,
@@ -152,6 +153,8 @@ class QywxExternalContactCallbackController extends BaseApiController
]); ]);
if ($queued) { if ($queued) {
// 推广任务先于事件流水执行;事件落库后再用任务内不可变配置补写渠道快照。
QywxExternalContactEventTagSnapshotService::captureForPromotionEvent($eventId);
// 即时通道已尝试欢迎语/标签;分钟补偿完成备注、成员记账、范围与客户资料同步。 // 即时通道已尝试欢迎语/标签;分钟补偿完成备注、成员记账、范围与客户资料同步。
return; return;
} }
@@ -227,6 +230,10 @@ class QywxExternalContactCallbackController extends BaseApiController
} }
} }
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType // 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
CustomerLogic::upsertSingleExternalContactFromApi($extId); CustomerLogic::upsertSingleExternalContactFromApi(
$extId,
$changeType === 'add_external_contact' ? $eventId : 0,
$userId
);
} }
} }
@@ -422,9 +422,17 @@ SQL;
* multiplies facts. Enterprise tag channels use the normalized relation * multiplies facts. Enterprise tag channels use the normalized relation
* table, while legacy name-only channels keep a deduplicated JSON fallback. * table, while legacy name-only channels keep a deduplicated JSON fallback.
* *
* When the fact has an employee dimension, pass $followUserField so a tag
* applied by employee A cannot make employee B's event match the channel.
*
* @param array<string, mixed>|null $channel * @param array<string, mixed>|null $channel
*/ */
public static function applyExternalUserChannelFilter(Query $query, string $field, ?array $channel): void public static function applyExternalUserChannelFilter(
Query $query,
string $field,
?array $channel,
?string $followUserField = null
): void
{ {
if ($channel === null) { if ($channel === null) {
return; return;
@@ -437,10 +445,14 @@ SQL;
$tagPredicate = count($tagIds) === 1 $tagPredicate = count($tagIds) === 1
? 'channel_tag.tag_id = ?' ? 'channel_tag.tag_id = ?'
: 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')'; : 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
$followUserPredicate = $followUserField === null
? ''
: "AND channel_tag.follow_user_id = {$followUserField} ";
// 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。 // 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。
$query->whereRaw( $query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} channel_tag " "EXISTS (SELECT 1 FROM {$tagTable} channel_tag "
. "WHERE channel_tag.external_userid = {$field} " . "WHERE channel_tag.external_userid = {$field} "
. $followUserPredicate
. "AND {$tagPredicate} " . "AND {$tagPredicate} "
. "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact " . "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact "
. 'WHERE active_channel_contact.external_userid = channel_tag.external_userid ' . 'WHERE active_channel_contact.external_userid = channel_tag.external_userid '
@@ -472,6 +484,92 @@ SQL;
); );
} }
/**
* Filter an add-event fact by its append-only channel snapshot. Events that
* pre-date the snapshot migration explicitly fall back to the old projection,
* but the fallback is constrained to the event's exact employee.
*
* @param array<string, mixed>|null $channel
*/
public static function applyExternalUserEventChannelFilter(
Query $query,
string $eventIdField,
string $externalUserField,
string $followUserField,
?array $channel,
bool $historicalContact = false
): void
{
if ($channel === null) {
return;
}
$tagIds = self::channelTagIds($channel);
if ($tagIds === [] || !QywxExternalContactEventTagSnapshotService::installed()) {
if ($historicalContact) {
self::applyHistoricalExternalUserChannelFilter(
$query,
$externalUserField,
$channel,
$followUserField
);
} else {
self::applyExternalUserChannelFilter($query, $externalUserField, $channel, $followUserField);
}
return;
}
$snapshotTable = self::tableWithPrefix('qywx_external_contact_event_tag');
$snapshotPredicate = count($tagIds) === 1
? 'event_channel_tag.tag_id = ?'
: 'event_channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
$snapshotMatch = "EXISTS (SELECT 1 FROM {$snapshotTable} event_channel_tag"
. " WHERE event_channel_tag.event_id = {$eventIdField}"
. " AND event_channel_tag.follow_user_id = {$followUserField}"
. " AND {$snapshotPredicate})";
$snapshotMissing = "NOT EXISTS (SELECT 1 FROM {$snapshotTable} captured_event_channel"
. " WHERE captured_event_channel.event_id = {$eventIdField}"
. " AND captured_event_channel.follow_user_id = {$followUserField}"
. " AND captured_event_channel.tag_id = '')";
$query->where(function ($channelQuery) use (
$snapshotMatch,
$snapshotMissing,
$tagIds,
$historicalContact,
$externalUserField,
$followUserField,
$channel
): void {
$channelQuery->whereRaw($snapshotMatch, $tagIds)
->whereOr(function ($legacyQuery) use (
$snapshotMissing,
$historicalContact,
$externalUserField,
$followUserField,
$channel
): void {
$legacyQuery->whereRaw($snapshotMissing);
if ($historicalContact) {
self::applyHistoricalExternalUserChannelFilter(
$legacyQuery,
$externalUserField,
$channel,
$followUserField
);
} else {
self::applyExternalUserChannelFilter(
$legacyQuery,
$externalUserField,
$channel,
$followUserField
);
}
});
});
}
/** /**
* Filter an external_userid fact by the channel snapshot retained in * Filter an external_userid fact by the channel snapshot retained in
* qywx_external_contact.follow_users, including soft-deleted contacts. * qywx_external_contact.follow_users, including soft-deleted contacts.
@@ -481,14 +579,48 @@ SQL;
* contact row itself retains follow_users and is the best available channel * contact row itself retains follow_users and is the best available channel
* snapshot for this specific historical statistic. * snapshot for this specific historical statistic.
* *
* When $followUserField is provided, JSON_SEARCH first locates that exact
* employee object and JSON_CONTAINS checks only its tag array.
*
* @param array<string, mixed>|null $channel * @param array<string, mixed>|null $channel
*/ */
public static function applyHistoricalExternalUserChannelFilter(Query $query, string $field, ?array $channel): void public static function applyHistoricalExternalUserChannelFilter(
Query $query,
string $field,
?array $channel,
?string $followUserField = null
): void
{ {
if ($channel === null) { if ($channel === null) {
return; return;
} }
$contactTable = self::tableWithPrefix('qywx_external_contact');
$tagIds = self::channelTagIds($channel);
if ($followUserField !== null && $tagIds !== []) {
$safeFollowUsers = "IF(JSON_VALID(historical_channel_contact.follow_users),"
. ' historical_channel_contact.follow_users, JSON_ARRAY())';
$userPath = "JSON_UNQUOTE(JSON_SEARCH({$safeFollowUsers}, 'one', {$followUserField},"
. " NULL, '$[*].userid'))";
$tagsPath = "IFNULL(REPLACE({$userPath}, '.userid', '.tags'), '$.__missing__')";
$tagsJson = "JSON_EXTRACT({$safeFollowUsers}, {$tagsPath})";
$tagPredicates = [];
$bindings = [];
foreach ($tagIds as $tagId) {
$tagPredicates[] = "JSON_CONTAINS({$tagsJson}, JSON_OBJECT('tag_id', ?))";
$bindings[] = $tagId;
}
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
. " WHERE historical_channel_contact.external_userid = {$field}"
. " AND {$userPath} IS NOT NULL"
. ' AND (' . implode(' OR ', $tagPredicates) . '))',
$bindings
);
return;
}
$patterns = self::buildLikePatterns($channel); $patterns = self::buildLikePatterns($channel);
if ($patterns === []) { if ($patterns === []) {
$query->whereRaw('1 = 0'); $query->whereRaw('1 = 0');
@@ -502,7 +634,6 @@ SQL;
$segments[] = 'historical_channel_contact.follow_users LIKE ?'; $segments[] = 'historical_channel_contact.follow_users LIKE ?';
$bindings[] = $pattern; $bindings[] = $pattern;
} }
$contactTable = self::tableWithPrefix('qywx_external_contact');
$query->whereRaw( $query->whereRaw(
"EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact" "EXISTS (SELECT 1 FROM {$contactTable} historical_channel_contact"
. " WHERE historical_channel_contact.external_userid = {$field}" . " WHERE historical_channel_contact.external_userid = {$field}"
@@ -139,6 +139,9 @@ class QywxPromotionAutomationStore
Db::name('qywx_promotion_automation_action_log')->insert($log + ['task_id' => $row['id']]); Db::name('qywx_promotion_automation_action_log')->insert($log + ['task_id' => $row['id']]);
} }
}); });
// 标签动作成功/终止后,以任务入队时的配置冻结事件渠道;重复调用由完成标记幂等保护。
QywxExternalContactEventTagSnapshotService::captureFromPromotionTask($row);
} }
public function localNames(array $task): array public function localNames(array $task): array
@@ -167,7 +170,17 @@ class QywxPromotionAutomationStore
public function syncCustomer(array $task): void public function syncCustomer(array $task): void
{ {
$started = time(); $started = time();
CustomerLogic::upsertSingleExternalContactFromApi($task['external_userid']); $eventId = (int) Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('user_id', (string) $task['userid'])
->where('external_userid', (string) $task['external_userid'])
->where('event_time', (int) $task['event_time'])
->value('id');
CustomerLogic::upsertSingleExternalContactFromApi(
$task['external_userid'],
$eventId,
(string) $task['userid']
);
// 旧方法在API空结果时只log并返回void;必须核验本地实际更新,避免把未同步记为成功。 // 旧方法在API空结果时只log并返回void;必须核验本地实际更新,避免把未同步记为成功。
$updated = (int) Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('update_time'); $updated = (int) Db::name('qywx_external_contact')->where('external_userid', $task['external_userid'])->value('update_time');
if ($updated < $started) { if ($updated < $started) {
+2
View File
@@ -38,6 +38,8 @@ HOST = "https://公开访问域名"
创建分流方案时必须选择一名或多名医助。一个方案只创建一条企业微信官方链接,当前全部可用医助会同时写入该链接的 `range.user_list`,由企业微信在打开、添加阶段执行官方多人路由。成员可配置启用状态、每日上限和有效时间,系统按实际获客回调累计数量。 创建分流方案时必须选择一名或多名医助。一个方案只创建一条企业微信官方链接,当前全部可用医助会同时写入该链接的 `range.user_list`,由企业微信在打开、添加阶段执行官方多人路由。成员可配置启用状态、每日上限和有效时间,系统按实际获客回调累计数量。
批量修改方案支持按员工统一上线或下线。操作只影响所选方案中已经存在的员工规则,不会把员工自动加入其他方案;下线操作如果会令任一方案没有当前可用的上线员工,则整批在写入前拒绝。员工上下线需与其他方案配置分开保存;状态保存与同步意图在同一事务内提交,每个受影响方案只重算一次成员范围,并进入企业微信后台同步队列。
企业微信 `range.user_list` 只接受成员 userid 列表,不提供逐成员权重字段,因此本站不再展示或执行 2:1:1 一类权重规则。官方多人路由的实际承接还会受到成员可服务状态、客户已有好友关系等企业微信规则影响,不能承诺每次刷新严格随机或短期样本绝对平均。 企业微信 `range.user_list` 只接受成员 userid 列表,不提供逐成员权重字段,因此本站不再展示或执行 2:1:1 一类权重规则。官方多人路由的实际承接还会受到成员可服务状态、客户已有好友关系等企业微信规则影响,不能承诺每次刷新严格随机或短期样本绝对平均。
系统只使用企业微信获客助手生成的链接: 系统只使用企业微信获客助手生成的链接:
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import t from"./error-DJTRJcDD.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-BSw4l71J.js";import"./index-0obBWZiZ.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
@@ -1 +0,0 @@
import e from"./error-DJTRJcDD.js";import{o,q as r,r as t,v as s}from"./.pnpm-BSw4l71J.js";import"./index-0obBWZiZ.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-BSw4l71J.js";import{a as V}from"./doctor-v05-2hcw.js";import{m as A,_ as M}from"./index-0obBWZiZ.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
@@ -1 +0,0 @@
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-BSw4l71J.js";import{af as V}from"./tcm-DKwa3Njw.js";import{_ as q}from"./index-0obBWZiZ.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
@@ -1 +0,0 @@
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,di as c}from"./.pnpm-BSw4l71J.js";import{ag as Y}from"./tcm-DKwa3Njw.js";import{_ as q}from"./index-0obBWZiZ.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{o as N,dk as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-BSw4l71J.js";import j from"./RecordingPlaybackBlock-2wN2LtoH.js";import{U as k}from"./index-BqGqHzpB.js";import{i as c,_ as q}from"./index-0obBWZiZ.js";import{ak as K,al as x,am as A}from"./tcm-DKwa3Njw.js";import"./RecordingVideoPlayer-CTZLdMyy.js";import"./file-DBDqscMe.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
@@ -1 +0,0 @@
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-BSw4l71J.js";import{an as q}from"./tcm-DKwa3Njw.js";import{_ as H}from"./index-0obBWZiZ.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-BDl0pktY.js";import"./.pnpm-BSw4l71J.js";import"./tcm-DKwa3Njw.js";import"./index-0obBWZiZ.js";export{o as default};
@@ -1 +0,0 @@
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-BSw4l71J.js";import{p as j}from"./tcm-DKwa3Njw.js";import{i as C}from"./index-0obBWZiZ.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
@@ -1 +0,0 @@
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cX as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as X,M as m,p as Q,ae as U,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-BSw4l71J.js";import{d as te}from"./dayjs-CVa8MSSA.js";import{as as ne,at as oe}from"./tcm-DKwa3Njw.js";import{p as re}from"./im-business-message-parse-oYIP1khU.js";import{_ as le}from"./index-0obBWZiZ.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=Q(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,Y=ee,V=se,z=W,A=X;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:U(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(Y,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(V,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
@@ -1 +0,0 @@
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-CsbRGLkf.js";import"./.pnpm-BSw4l71J.js";export{m as default};
@@ -1 +0,0 @@
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-BSw4l71J.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
@@ -1 +0,0 @@
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-BSw4l71J.js";import{t as j,_ as J}from"./index-0obBWZiZ.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
@@ -1,2 +0,0 @@
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d9 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-BSw4l71J.js";import{_ as fe}from"./picker-DH1zPBQR.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-0obBWZiZ.js";import{a as T,d as he}from"./patient-DmuYDLe3.js";import{h as ke}from"./perm-CEhSz6WX.js";import"./index-hk2ar054.js";import"./index-GmRprI6W.js";import"./index.vue_vue_type_script_setup_true_lang-BOJpu9c9.js";import"./index-Bliua-X3.js";import"./index-BqGqHzpB.js";import"./file-DBDqscMe.js";import"./index.vue_vue_type_script_setup_true_lang-D3ZtXZaD.js";import"./usePaging-BeGcb2kN.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-BSw4l71J.js";import{_ as V}from"./index-0obBWZiZ.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-BSw4l71J.js";import H from"./RecordingVideoPlayer-CTZLdMyy.js";import{e as I,_ as P}from"./index-0obBWZiZ.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-BSw4l71J.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-BSw4l71J.js";import{e as ae,_ as ne}from"./index-0obBWZiZ.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?U(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function U(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function C(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-BSw4l71J.js").then(M=>M.dP),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function N(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{N()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:C},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-BSw4l71J.js";import{a5 as L}from"./tcm-DKwa3Njw.js";import{i as M,_ as S}from"./index-0obBWZiZ.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More