Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
691c749f34 | ||
|
|
40319eea41 | ||
|
|
c1f7287331 | ||
|
|
9d91ff90ae | ||
|
|
1b8e166b33 | ||
|
|
0cea43b027 | ||
|
|
d0e2422155 | ||
|
|
9206d5ea62 | ||
|
|
bd0a3d3b96 | ||
|
|
a5f4c4472d | ||
|
|
9ef6eb8d67 | ||
|
|
d5164b7369 | ||
|
|
cf3fbdc5ef | ||
|
|
5e22d423d4 | ||
|
|
7f1ed49cc8 |
@@ -32,3 +32,4 @@ app/.test-tmp-stream/
|
|||||||
/.spool
|
/.spool
|
||||||
TUICallKit-Vue3/.env
|
TUICallKit-Vue3/.env
|
||||||
/.codegraph
|
/.codegraph
|
||||||
|
app/artifacts/
|
||||||
|
|||||||
Generated
+475
-423
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const read = (relative) => fs.readFileSync(path.join(root, 'src', relative), 'utf8')
|
||||||
|
const utils = ts.transpileModule(read('utils/appointment-type.ts'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS }
|
||||||
|
}).outputText
|
||||||
|
const exportsObject = {}
|
||||||
|
vm.runInNewContext(utils, { exports: exportsObject })
|
||||||
|
for (const [value, expected] of [
|
||||||
|
['video', '视频问诊'], ['text', '图文问诊'], [undefined, '视频问诊'],
|
||||||
|
[null, '视频问诊'], ['', '视频问诊'], [' ', '视频问诊'],
|
||||||
|
['phone', '电话问诊'], ['other', '未知']
|
||||||
|
]) {
|
||||||
|
assert.equal(exportsObject.appointmentTypeDescription(value), expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const callers = [
|
||||||
|
'views/tcm/appointment/list.vue',
|
||||||
|
'views/tcm/appointment/list_h5.vue',
|
||||||
|
'views/patient/reception/index.vue'
|
||||||
|
]
|
||||||
|
for (const caller of callers) {
|
||||||
|
assert.match(read(caller), /chatDialogRef\.value\?\.open\(\{[\s\S]*?appointmentType: row\.appointment_type,/)
|
||||||
|
}
|
||||||
|
const chat = read('components/chat-dialog/index.vue')
|
||||||
|
assert.match(chat, /appointmentType\.value = data\.appointmentType/)
|
||||||
|
assert.match(chat, /class="chat-appointment-type">\{\{ appointmentTypeLabel \}\}/)
|
||||||
|
const form = read('views/tcm/diagnosis/appointment.vue')
|
||||||
|
assert.match(form, /<el-radio value="text">图文问诊<\/el-radio>/)
|
||||||
|
assert.match(form, /appointmentType: 'video'/)
|
||||||
|
assert.match(form, /form\.appointmentType = 'video'/)
|
||||||
|
assert.match(form, /appointment_type: form\.appointmentType/)
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const components = [
|
||||||
|
...callers, 'components/chat-dialog/index.vue',
|
||||||
|
'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue'
|
||||||
|
]
|
||||||
|
for (const filename of components) {
|
||||||
|
const { descriptor, errors } = parse(read(filename), { filename })
|
||||||
|
assert.deepEqual(errors, [], `${filename} parses`)
|
||||||
|
const script = compileScript(descriptor, { id: filename })
|
||||||
|
const template = compileTemplate({
|
||||||
|
source: descriptor.template.content,
|
||||||
|
filename, id: filename,
|
||||||
|
compilerOptions: { bindingMetadata: script.bindings }
|
||||||
|
})
|
||||||
|
assert.deepEqual(template.errors, [], `${filename} template compiles`)
|
||||||
|
for (const style of descriptor.styles) {
|
||||||
|
const result = await compileStyleAsync({
|
||||||
|
source: style.content, filename: path.join(root, 'src', filename),
|
||||||
|
id: filename, scoped: style.scoped, preprocessLang: style.lang
|
||||||
|
})
|
||||||
|
assert.deepEqual(result.errors, [], `${filename} styles compile`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Appointment type defaults/labels, ${callers.length} chat entry contracts, ${components.length} Vue components: OK`)
|
||||||
|
}
|
||||||
|
main().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||||
@@ -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,9 @@ export interface WecomPromotionBatchUpdatePoolResult {
|
|||||||
success: boolean
|
success: boolean
|
||||||
sync_error?: string
|
sync_error?: string
|
||||||
sync_queued?: boolean
|
sync_queued?: boolean
|
||||||
|
sync_status?: WecomPromotionMemberSyncStatus
|
||||||
|
member_matched?: number
|
||||||
|
member_updated?: number
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +325,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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +362,25 @@ export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
|
|||||||
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
|
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WecomPromotionMemberSyncStatus = 'synced' | 'pending' | 'failed' | 'blocked'
|
||||||
|
|
||||||
|
export interface WecomPromotionMemberSyncResult {
|
||||||
|
pool_id: number
|
||||||
|
sync_status: WecomPromotionMemberSyncStatus
|
||||||
|
sync_error: string
|
||||||
|
sync_queued: boolean
|
||||||
|
range_userids: string[]
|
||||||
|
range_department_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionSyncMemberRange(params: { pool_id: number }) {
|
||||||
|
return request.post<WecomPromotionMemberSyncResult>({
|
||||||
|
url: '/firstvisit.wecomPromotion/syncMemberRange',
|
||||||
|
params,
|
||||||
|
timeout: 120000
|
||||||
|
}, { ignoreCancelToken: true, isOpenRetry: false })
|
||||||
|
}
|
||||||
|
|
||||||
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
|
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
|
||||||
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
|
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,6 +424,11 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅修改处方业务订单创建时间,需独立的 editTime 权限 */
|
||||||
|
export function prescriptionOrderEditTime(params: { id: number; create_time: string }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
|
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
|
||||||
export function prescriptionOrderDdcode(params: {
|
export function prescriptionOrderDdcode(params: {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
class="chat-window-header"
|
class="chat-window-header"
|
||||||
@mousedown="onHeaderMouseDown"
|
@mousedown="onHeaderMouseDown"
|
||||||
>
|
>
|
||||||
<span class="chat-window-title" :title="patientName">与 {{ patientName }} 通讯</span>
|
<div class="chat-window-heading">
|
||||||
|
<span class="chat-window-title" :title="patientName">与 {{ patientName }} 通讯</span>
|
||||||
|
<el-tag size="small" type="info" class="chat-appointment-type">{{ appointmentTypeLabel }}</el-tag>
|
||||||
|
</div>
|
||||||
<div class="chat-header-actions" @mousedown.stop>
|
<div class="chat-header-actions" @mousedown.stop>
|
||||||
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
|
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
|
||||||
<el-icon><Close /></el-icon>
|
<el-icon><Close /></el-icon>
|
||||||
@@ -140,6 +143,7 @@ import {
|
|||||||
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
||||||
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
|
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||||
import {
|
import {
|
||||||
formatTUICallUserError,
|
formatTUICallUserError,
|
||||||
getTUICallPackageArrearsMessage,
|
getTUICallPackageArrearsMessage,
|
||||||
@@ -180,6 +184,8 @@ const visible = ref(false)
|
|||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const patientName = ref('')
|
const patientName = ref('')
|
||||||
|
const appointmentType = ref<string | null | undefined>('video')
|
||||||
|
const appointmentTypeLabel = computed(() => appointmentTypeDescription(appointmentType.value))
|
||||||
const patientId = ref<number | null>(null)
|
const patientId = ref<number | null>(null)
|
||||||
const diagnosisId = ref<number | null>(null)
|
const diagnosisId = ref<number | null>(null)
|
||||||
const loadingText = ref('正在初始化...')
|
const loadingText = ref('正在初始化...')
|
||||||
@@ -1010,13 +1016,14 @@ watch(() => activeConversation.value, async (newConversation) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => {
|
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentType?: string | null }) => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
isMinimized.value = false
|
isMinimized.value = false
|
||||||
posX.value = 100
|
posX.value = 100
|
||||||
posY.value = 80
|
posY.value = 80
|
||||||
resetCallKitPositionToLeft()
|
resetCallKitPositionToLeft()
|
||||||
patientName.value = data.patientName
|
patientName.value = data.patientName
|
||||||
|
appointmentType.value = data.appointmentType
|
||||||
patientId.value = data.patientId
|
patientId.value = data.patientId
|
||||||
diagnosisId.value = data.diagnosisId || null
|
diagnosisId.value = data.diagnosisId || null
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -1426,6 +1433,18 @@ defineExpose({ open })
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.chat-window-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-appointment-type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-window-title {
|
.chat-window-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** 展示当前挂号的问诊方式;旧记录的空值默认视频。 */
|
||||||
|
export function appointmentTypeDescription(value?: string | null): string {
|
||||||
|
if (value == null || value.trim() === '') return '视频问诊'
|
||||||
|
if (value === 'text') return '图文问诊'
|
||||||
|
if (value === 'video') return '视频问诊'
|
||||||
|
if (value === 'phone') return '电话问诊'
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="修改订单创建时间"
|
||||||
|
width="min(440px, 94vw)"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="!submitting"
|
||||||
|
:show-close="!submitting"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||||
|
<el-form-item label="业务订单">
|
||||||
|
<span class="break-all">{{ orderNo || `#${form.id}` }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="创建时间" prop="create_time">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.create_time"
|
||||||
|
type="datetime"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="请选择创建时间"
|
||||||
|
:clearable="false"
|
||||||
|
:disabled="submitting"
|
||||||
|
class="!w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<p class="text-xs text-gray-500">保存后,列表和业绩统计将按新的创建时间归属日期,修改记录可在订单日志中查看。</p>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { nextTick, reactive, ref } from 'vue'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { prescriptionOrderEditTime } from '@/api/tcm'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (event: 'saved', id: number): void }>()
|
||||||
|
const visible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const orderNo = ref('')
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const form = reactive({ id: 0, create_time: '' })
|
||||||
|
const rules: FormRules = {
|
||||||
|
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表可能返回日期字符串或历史 Unix 时间戳;保留秒,避免只打开弹窗就丢失精度。
|
||||||
|
function editableTime(value: unknown): string {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
|
||||||
|
return raw.replace('T', ' ').padEnd(19, ':00')
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(raw) || Number(raw) <= 0) return ''
|
||||||
|
const timestamp = Number(raw)
|
||||||
|
const date = new Date(timestamp < 1e11 ? timestamp * 1000 : timestamp)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(row: { id?: unknown; order_no?: unknown; create_time?: unknown }) {
|
||||||
|
if (submitting.value || !Number(row.id)) return
|
||||||
|
form.id = Number(row.id)
|
||||||
|
form.create_time = editableTime(row.create_time)
|
||||||
|
orderNo.value = String(row.order_no || '')
|
||||||
|
visible.value = true
|
||||||
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (submitting.value || !formRef.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (!(await formRef.value.validate().catch(() => false))) return
|
||||||
|
await prescriptionOrderEditTime({ id: form.id, create_time: form.create_time })
|
||||||
|
feedback.msgSuccess('订单创建时间已修改')
|
||||||
|
visible.value = false
|
||||||
|
emit('saved', form.id)
|
||||||
|
} catch {
|
||||||
|
// 请求拦截器展示错误,保留已填写的时间便于重试。
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
@@ -179,6 +179,7 @@ export function logActionText(act: string) {
|
|||||||
patch_rx_patient: '处方患者信息',
|
patch_rx_patient: '处方患者信息',
|
||||||
patch_rx_usage: '服用参数',
|
patch_rx_usage: '服用参数',
|
||||||
update_amount: '修改订单金额',
|
update_amount: '修改订单金额',
|
||||||
|
edit_time: '修改创建时间',
|
||||||
complete: '完成订单',
|
complete: '完成订单',
|
||||||
refund: '退款',
|
refund: '退款',
|
||||||
manual_log: '手工备注',
|
manual_log: '手工备注',
|
||||||
|
|||||||
@@ -182,7 +182,6 @@
|
|||||||
<el-select v-model="editForm.appointment_type" class="!w-full">
|
<el-select v-model="editForm.appointment_type" class="!w-full">
|
||||||
<el-option label="视频问诊" value="video" />
|
<el-option label="视频问诊" value="video" />
|
||||||
<el-option label="图文问诊" value="text" />
|
<el-option label="图文问诊" value="text" />
|
||||||
<el-option label="电话问诊" value="phone" />
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="渠道来源" required>
|
<el-form-item label="渠道来源" required>
|
||||||
|
|||||||
@@ -1585,7 +1585,7 @@ import DaterangePicker from '@/components/daterange-picker/index.vue'
|
|||||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||||
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
|
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
|
||||||
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
|
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
|
||||||
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
|
import { computed, onMounted, onActivated, onDeactivated, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
@@ -3882,6 +3882,17 @@ const handleDelete = async (id: number) => {
|
|||||||
getLists()
|
getLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从订单页返回缓存的处方列表时,重新读取退款后的占用状态。
|
||||||
|
let refreshOnReturn = false
|
||||||
|
onDeactivated(() => {
|
||||||
|
refreshOnReturn = true
|
||||||
|
})
|
||||||
|
onActivated(() => {
|
||||||
|
if (!refreshOnReturn) return
|
||||||
|
refreshOnReturn = false
|
||||||
|
getLists()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadRoleOptions()
|
await loadRoleOptions()
|
||||||
await loadRegionData()
|
await loadRegionData()
|
||||||
|
|||||||
@@ -551,6 +551,12 @@
|
|||||||
link
|
link
|
||||||
@click="openEdit(row)"
|
@click="openEdit(row)"
|
||||||
>编辑</el-button>
|
>编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.prescriptionOrder/editTime']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="orderTimeDialogRef?.open(row)"
|
||||||
|
>修改创建时间</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canQuickTrackRow(row)"
|
v-if="canQuickTrackRow(row)"
|
||||||
v-perms="['tcm.prescriptionOrder/ddcode']"
|
v-perms="['tcm.prescriptionOrder/ddcode']"
|
||||||
@@ -650,6 +656,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #header-actions="{ detail }">
|
<template #header-actions="{ detail }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.prescriptionOrder/editTime']"
|
||||||
|
size="small"
|
||||||
|
@click="orderTimeDialogRef?.open(detail)"
|
||||||
|
>修改创建时间</el-button>
|
||||||
<gancao-submission-reconcile-button
|
<gancao-submission-reconcile-button
|
||||||
:order="detail"
|
:order="detail"
|
||||||
@resolved="handleGancaoSubmissionResolved"
|
@resolved="handleGancaoSubmissionResolved"
|
||||||
@@ -1511,6 +1522,8 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
|
||||||
|
|
||||||
<!-- 订单退款:须填写原因 -->
|
<!-- 订单退款:须填写原因 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="refundOrderDialogVisible"
|
v-model="refundOrderDialogVisible"
|
||||||
@@ -2032,6 +2045,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
|
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
|
||||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
||||||
|
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
|
||||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||||
import {
|
import {
|
||||||
TCM_ASSISTANT_ROLE_ID,
|
TCM_ASSISTANT_ROLE_ID,
|
||||||
@@ -3088,6 +3102,12 @@ function canUploadPharmacyRow(row: {
|
|||||||
|
|
||||||
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
|
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
|
||||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||||
|
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
|
||||||
|
|
||||||
|
async function onOrderTimeSaved(id: number) {
|
||||||
|
getLists()
|
||||||
|
await detailDrawerRef.value?.refreshIfCurrent(id)
|
||||||
|
}
|
||||||
/** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh()) */
|
/** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh()) */
|
||||||
const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null)
|
const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null)
|
||||||
|
|
||||||
|
|||||||
@@ -475,6 +475,11 @@
|
|||||||
size="small"
|
size="small"
|
||||||
@click="openEdit(row)"
|
@click="openEdit(row)"
|
||||||
>编辑</el-button>
|
>编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.prescriptionOrder/editTime']"
|
||||||
|
size="small"
|
||||||
|
@click="orderTimeDialogRef?.open(row)"
|
||||||
|
>修改创建时间</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canQuickTrackRow(row)"
|
v-if="canQuickTrackRow(row)"
|
||||||
v-perms="['tcm.prescriptionOrder/ddcode']"
|
v-perms="['tcm.prescriptionOrder/ddcode']"
|
||||||
@@ -592,6 +597,11 @@
|
|||||||
>
|
>
|
||||||
撤回支付审核
|
撤回支付审核
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.prescriptionOrder/editTime']"
|
||||||
|
size="small"
|
||||||
|
@click="orderTimeDialogRef?.open(detailData)"
|
||||||
|
>修改创建时间</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canShipRow(detailData)"
|
v-if="canShipRow(detailData)"
|
||||||
v-perms="['tcm.prescriptionOrder/ship']"
|
v-perms="['tcm.prescriptionOrder/ship']"
|
||||||
@@ -2568,6 +2578,7 @@
|
|||||||
|
|
||||||
<!-- 与 consumer/prescription/index、诊单 edit 同一套界面:只读诊单与全部分页签 -->
|
<!-- 与 consumer/prescription/index、诊单 edit 同一套界面:只读诊单与全部分页签 -->
|
||||||
<TcmDiagnosisEditView ref="diagnosisViewRef" />
|
<TcmDiagnosisEditView ref="diagnosisViewRef" />
|
||||||
|
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -2586,6 +2597,7 @@ import {
|
|||||||
User
|
User
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
|
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
|
||||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
import {
|
import {
|
||||||
@@ -3514,6 +3526,14 @@ function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | nul
|
|||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailData = ref<Record<string, any> | null>(null)
|
const detailData = ref<Record<string, any> | null>(null)
|
||||||
|
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
|
||||||
|
|
||||||
|
async function onOrderTimeSaved(id: number) {
|
||||||
|
getLists()
|
||||||
|
if (detailVisible.value && Number(detailData.value?.id) === id) {
|
||||||
|
await openDetail(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const detailServicePackageText = computed(() =>
|
const detailServicePackageText = computed(() =>
|
||||||
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
||||||
@@ -3838,7 +3858,8 @@ function logActionText(act: string) {
|
|||||||
patch_rx_patient: '处方患者信息',
|
patch_rx_patient: '处方患者信息',
|
||||||
update_amount: '修改订单金额',
|
update_amount: '修改订单金额',
|
||||||
complete: '完成订单',
|
complete: '完成订单',
|
||||||
refund: '退款'
|
refund: '退款',
|
||||||
|
edit_time: '修改创建时间'
|
||||||
}
|
}
|
||||||
return m[act] || act
|
return m[act] || act
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,8 +72,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="operationResults.length" class="member-sync-results" aria-live="polite">
|
||||||
|
<strong>本次保存与企微同步结果</strong>
|
||||||
|
<ul>
|
||||||
|
<li v-for="result in operationResults" :key="result.id">
|
||||||
|
<span>{{ result.name || `方案 ${result.id}` }}:{{ operationResultText(result) }}</span>
|
||||||
|
<el-button
|
||||||
|
v-if="result.success && result.sync_status !== 'synced' && canSyncPool(result.id)"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
:loading="syncingPoolId === result.id"
|
||||||
|
:disabled="syncingPoolId > 0 || savingBatchConfig"
|
||||||
|
@click="syncMemberRange(result.id)"
|
||||||
|
>重试同步</el-button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<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"
|
||||||
@@ -116,6 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
|
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
|
||||||
|
<el-button v-if="selectedPool.can_operate" :icon="Refresh" :loading="syncingPoolId === Number(selectedPool.id)" :disabled="!selectedPool.official_link || syncingPoolId > 0 || togglingMemberId > 0" @click="syncMemberRange(Number(selectedPool.id))">同步成员范围</el-button>
|
||||||
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
|
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
|
||||||
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
|
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
|
||||||
<el-button v-if="selectedPool.can_manage_access" :icon="User" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
|
<el-button v-if="selectedPool.can_manage_access" :icon="User" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
|
||||||
@@ -140,7 +168,16 @@
|
|||||||
show-icon
|
show-icon
|
||||||
:closable="false"
|
:closable="false"
|
||||||
title="企业微信原生多人路由"
|
title="企业微信原生多人路由"
|
||||||
description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。"
|
description="上线开关和可用性表示本地规则;企微范围显示上次远端确认结果。“待移出”的成员仍可能获客,只有同步完成才会从官方链接移除。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
class="legacy-sync-alert member-sync-state"
|
||||||
|
:type="selectedSyncState.type"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
:title="selectedSyncState.title"
|
||||||
|
:description="selectedSyncState.description"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="member-table-area">
|
<div class="member-table-area">
|
||||||
@@ -153,10 +190,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="企微范围同步" width="130" align="center">
|
<el-table-column label="企微范围(上次确认)" width="165" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="status-tag" :class="routeStatus(row).className">{{ routeStatus(row).label }}</span>
|
<span class="status-tag" :class="routeStatus(row).className">{{ routeStatus(row).label }}</span>
|
||||||
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败,后台重试中</div>
|
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败,请查看上方原因</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="今日 / 上限" width="125" align="center">
|
<el-table-column label="今日 / 上限" width="125" align="center">
|
||||||
@@ -170,7 +207,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="上线" width="90" align="center">
|
<el-table-column label="上线" width="90" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
|
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate || togglingMemberId > 0 || syncingPoolId > 0" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" min-width="100" fixed="right">
|
<el-table-column label="操作" min-width="100" fixed="right">
|
||||||
@@ -488,6 +525,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,10 +607,11 @@
|
|||||||
@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>
|
||||||
|
<span v-if="batchSyncProgress.total" class="uploading-save-tip" role="status">本地配置已保存,正在同步企微 {{ batchSyncProgress.completed }} / {{ batchSyncProgress.total }} 个方案</span>
|
||||||
<span v-if="batchConfigBusy" class="uploading-save-tip">正在处理标签或素材,请稍候</span>
|
<span v-if="batchConfigBusy" class="uploading-save-tip">正在处理标签或素材,请稍候</span>
|
||||||
<el-button :disabled="savingBatchConfig || batchConfigBusy" @click="batchConfigDialogVisible = false">取消</el-button>
|
<el-button :disabled="savingBatchConfig || batchConfigBusy" @click="batchConfigDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="savingBatchConfig" :disabled="batchConfigBusy" @click="saveBatchConfig">批量保存</el-button>
|
<el-button type="primary" :loading="savingBatchConfig" :disabled="batchConfigBusy" @click="saveBatchConfig">批量保存</el-button>
|
||||||
@@ -628,9 +722,10 @@ import {
|
|||||||
wecomPromotionSaveMember,
|
wecomPromotionSaveMember,
|
||||||
wecomPromotionSavePool,
|
wecomPromotionSavePool,
|
||||||
wecomPromotionSyncCustomers,
|
wecomPromotionSyncCustomers,
|
||||||
|
wecomPromotionSyncMemberRange,
|
||||||
wecomPromotionToggleMember
|
wecomPromotionToggleMember
|
||||||
} from '@/api/first_visit'
|
} from '@/api/first_visit'
|
||||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
import type { WecomPromotionBatchUpdatePoolResult, WecomPromotionCustomerChatStatus, WecomPromotionMemberSyncResult } from '@/api/first_visit'
|
||||||
|
|
||||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||||
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
|
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
|
||||||
@@ -655,6 +750,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
|
||||||
@@ -706,6 +807,9 @@ const accessDialogVisible = ref(false)
|
|||||||
const savingAccess = ref(false)
|
const savingAccess = ref(false)
|
||||||
const batchConfigDialogVisible = ref(false)
|
const batchConfigDialogVisible = ref(false)
|
||||||
const savingBatchConfig = ref(false)
|
const savingBatchConfig = ref(false)
|
||||||
|
const syncingPoolId = ref(0)
|
||||||
|
const batchSyncProgress = reactive({ completed: 0, total: 0 })
|
||||||
|
const operationResults = ref<WecomPromotionBatchUpdatePoolResult[]>([])
|
||||||
const batchConfigBusy = ref(false)
|
const batchConfigBusy = ref(false)
|
||||||
const batchConfigScroll = ref<HTMLElement>()
|
const batchConfigScroll = ref<HTMLElement>()
|
||||||
const batchConfigError = ref('')
|
const batchConfigError = ref('')
|
||||||
@@ -714,8 +818,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)
|
||||||
@@ -736,9 +840,105 @@ const customerStats = reactive({
|
|||||||
|
|
||||||
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
||||||
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
||||||
|
const selectedSyncState = computed(() => {
|
||||||
|
const pool = selectedPool.value
|
||||||
|
const sync = pool?.dispatch_sync || {}
|
||||||
|
const remote = Array.isArray(pool?.official_link?.range_userids) ? pool.official_link.range_userids.map(String) : []
|
||||||
|
const planned = selectedMemberRules.value.filter((row: any) => eligibility(row).className === 'is-ok').map((row: any) => String(row.userid))
|
||||||
|
const names = new Map<string, string>(selectedMemberRules.value.map((row: any) => [String(row.userid), String(row.name || row.userid)]))
|
||||||
|
const remoteNames = remote.map((id: string) => names.get(id) || id).join('、') || '暂无确认记录'
|
||||||
|
const plannedNames = planned.map((id: string) => names.get(id) || id).join('、') || '暂无可用成员'
|
||||||
|
const lastSync = pool?.official_link?.last_sync_time
|
||||||
|
const detail = `当前计划:${plannedNames};上次企微确认:${remoteNames}${lastSync ? `(${formatCustomerTime(lastSync)})` : ''}。`
|
||||||
|
const error = String(sync.last_error || pool?.official_link?.sync_error || '')
|
||||||
|
if (Number(sync.status) === 3 || error) return { type: 'warning' as const, title: '企微成员范围尚未同步成功', description: `${detail}${error || '同步失败,请点击“同步成员范围”重试。'}` }
|
||||||
|
if (Number(sync.status) === 4) return { type: 'warning' as const, title: '企微成员范围同步受阻', description: `${detail}请检查可用成员后重试同步。` }
|
||||||
|
if ([1, 2].includes(Number(sync.status))) return { type: 'warning' as const, title: '本地规则已保存,企微成员范围同步待完成', description: `${detail}可点击“同步成员范围”立即重试。` }
|
||||||
|
const hasDepartments = (pool?.official_link?.range_department_ids || []).length > 0
|
||||||
|
const matches = remote.length > 0 && new Set(remote).size === new Set(planned).size && planned.every((id: string) => remote.includes(id)) && !hasDepartments
|
||||||
|
if (!matches) return { type: 'warning' as const, title: '当前计划与上次企微范围不一致', description: `${detail}${hasDepartments ? '企微范围仍包含部门。' : ''}请点击“同步成员范围”更新并确认。` }
|
||||||
|
return { type: 'info' as const, title: '上次确认的企微成员范围与当前计划一致', description: detail }
|
||||||
|
})
|
||||||
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 +1011,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 +1027,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 +1037,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 = ''
|
||||||
@@ -855,14 +1062,105 @@ function setBatchConfigError(message: string) {
|
|||||||
ElMessage.warning(message)
|
ElMessage.warning(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncErrorMessage(error: unknown, fallback: string) {
|
||||||
|
if (typeof error === 'string' && error) return error
|
||||||
|
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationResultText(result: WecomPromotionBatchUpdatePoolResult) {
|
||||||
|
if (!result.success) return `本地保存失败:${result.error || '请检查配置后重试'}`
|
||||||
|
if (result.sync_status === 'synced') return '本地已保存,企微成员范围已确认同步'
|
||||||
|
if (result.sync_status === 'blocked' || result.sync_status === 'failed' || result.sync_error) {
|
||||||
|
return `本地已保存,企微同步${result.sync_status === 'blocked' ? '受阻' : '失败'}:${result.sync_error || '请重试同步'}`
|
||||||
|
}
|
||||||
|
return '本地已保存,企微成员范围尚未确认同步,请重试同步'
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordOperationResult(result: WecomPromotionBatchUpdatePoolResult) {
|
||||||
|
const index = operationResults.value.findIndex((item) => item.id === result.id)
|
||||||
|
if (index < 0) operationResults.value.push(result)
|
||||||
|
else operationResults.value[index] = result
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifySavedResult(label: string, result: Partial<WecomPromotionMemberSyncResult>) {
|
||||||
|
if (result.sync_status === 'synced') {
|
||||||
|
ElMessage.success(`${label},企微成员范围已确认同步`)
|
||||||
|
} else {
|
||||||
|
const reason = result.sync_error || '企微成员范围尚未确认同步,请点击“同步成员范围”重试'
|
||||||
|
ElMessage.warning({ message: `${label}。${reason}`, duration: 8000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordSavedResult(poolId: number, name: string, result: Partial<WecomPromotionMemberSyncResult>) {
|
||||||
|
recordOperationResult({ id: poolId, name, success: true, sync_status: result.sync_status, sync_error: result.sync_error, sync_queued: result.sync_queued })
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSyncPool(poolId: number) {
|
||||||
|
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
|
||||||
|
return Boolean(pool?.can_operate && pool?.official_link)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestMemberSync(poolId: number): Promise<Partial<WecomPromotionMemberSyncResult>> {
|
||||||
|
try {
|
||||||
|
return await wecomPromotionSyncMemberRange({ pool_id: poolId })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { pool_id: poolId, sync_status: 'failed', sync_error: syncErrorMessage(error, '同步请求失败,请重试确认企微范围'), sync_queued: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncMemberRange(poolId: number) {
|
||||||
|
if (syncingPoolId.value || savingBatchConfig.value || !canSyncPool(poolId)) return
|
||||||
|
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
|
||||||
|
syncingPoolId.value = poolId
|
||||||
|
try {
|
||||||
|
const result = await requestMemberSync(poolId)
|
||||||
|
recordOperationResult({ id: poolId, name: pool.name, success: true, ...result })
|
||||||
|
await loadOverview()
|
||||||
|
notifySavedResult('本地规则已保存', result)
|
||||||
|
} finally {
|
||||||
|
syncingPoolId.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncBatchResults(results: WecomPromotionBatchUpdatePoolResult[]) {
|
||||||
|
operationResults.value = results.map((item) => ({ ...item }))
|
||||||
|
const queued = results.filter((item) => item.success && item.sync_queued)
|
||||||
|
Object.assign(batchSyncProgress, { total: queued.length, completed: 0 })
|
||||||
|
let next = 0
|
||||||
|
// Two workers keep large batches responsive without flooding the enterprise API.
|
||||||
|
await Promise.all(Array.from({ length: Math.min(2, queued.length) }, async () => {
|
||||||
|
while (next < queued.length) {
|
||||||
|
const item = queued[next++]
|
||||||
|
const synced = await requestMemberSync(item.id)
|
||||||
|
recordOperationResult({ ...item, ...synced })
|
||||||
|
batchSyncProgress.completed++
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async function saveBatchConfig() {
|
async function saveBatchConfig() {
|
||||||
if (savingBatchConfig.value || batchConfigBusy.value) return
|
if (savingBatchConfig.value || batchConfigBusy.value) return
|
||||||
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 +1187,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> = {}
|
||||||
@@ -904,11 +1209,13 @@ async function saveBatchConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
savingBatchConfig.value = true
|
savingBatchConfig.value = true
|
||||||
|
operationResults.value = []
|
||||||
try {
|
try {
|
||||||
const result = await wecomPromotionBatchUpdatePools({
|
const result = await wecomPromotionBatchUpdatePools({
|
||||||
pool_ids: [...batchConfigForm.pool_ids],
|
pool_ids: [...batchConfigForm.pool_ids],
|
||||||
changes
|
changes
|
||||||
})
|
})
|
||||||
|
operationResults.value = result.results.map((item) => ({ ...item }))
|
||||||
if (result.updated === 0) {
|
if (result.updated === 0) {
|
||||||
const detail = result.results
|
const detail = result.results
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
@@ -916,27 +1223,27 @@ async function saveBatchConfig() {
|
|||||||
.join(';')
|
.join(';')
|
||||||
return setBatchConfigError(detail || '所选方案均未能保存,请检查配置后重试')
|
return setBatchConfigError(detail || '所选方案均未能保存,请检查配置后重试')
|
||||||
}
|
}
|
||||||
|
await syncBatchResults(result.results)
|
||||||
batchConfigDialogVisible.value = false
|
batchConfigDialogVisible.value = false
|
||||||
selectedPoolIds.value = []
|
selectedPoolIds.value = operationResults.value
|
||||||
|
.filter((item) => !item.success || item.sync_status !== 'synced')
|
||||||
|
.map((item) => item.id)
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
if (result.failed > 0) {
|
const memberStatusText = batchConfigForm.member_status === 1 ? '上线' : '下线'
|
||||||
const detail = result.results
|
const memberResultText = hasMemberStatusChange
|
||||||
.filter((item) => !item.success)
|
? (result.member_updated > 0
|
||||||
.slice(0, 2)
|
? `,${result.member_updated} 条本地员工规则已${memberStatusText}`
|
||||||
.map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`)
|
: `,所选员工本地均已处于${memberStatusText}状态`)
|
||||||
.join(';')
|
: ''
|
||||||
ElMessage.warning(`已更新 ${result.updated} 个方案,${result.failed} 个失败${detail ? `。${detail}` : ''}`)
|
const synced = operationResults.value.filter((item) => item.success && item.sync_status === 'synced').length
|
||||||
} else if (result.sync_error_count > 0) {
|
const unconfirmed = result.updated - synced
|
||||||
ElMessage.warning(`已更新 ${result.updated} 个方案,其中 ${result.sync_error_count} 个企微范围将在后台自动重试同步`)
|
const summary = `本地已保存 ${result.updated} 个方案${memberResultText};企微已确认同步 ${synced} 个${unconfirmed ? `,${unconfirmed} 个尚未确认同步` : ''}${result.failed ? `;${result.failed} 个本地保存失败` : ''}`
|
||||||
} else if (result.sync_queued_count > 0) {
|
ElMessage({ type: unconfirmed || result.failed ? 'warning' : 'success', message: `${summary}。详情见方案列表上方。`, duration: 8000 })
|
||||||
ElMessage.success(`已批量更新 ${result.updated} 个分流方案,${result.sync_queued_count} 个企微链接配置将在后台同步`)
|
|
||||||
} else {
|
|
||||||
ElMessage.success(`已批量更新 ${result.updated} 个分流方案`)
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setBatchConfigError(error?.message || '批量修改分流方案失败')
|
setBatchConfigError(error?.message || '批量修改分流方案失败')
|
||||||
} finally {
|
} finally {
|
||||||
savingBatchConfig.value = false
|
savingBatchConfig.value = false
|
||||||
|
Object.assign(batchSyncProgress, { completed: 0, total: 0 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,6 +1253,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: [],
|
||||||
@@ -1058,11 +1366,10 @@ async function savePool() {
|
|||||||
throw new Error('方案已提交,但服务端未确认标签和欢迎语配置保存成功,请刷新后重试')
|
throw new Error('方案已提交,但服务端未确认标签和欢迎语配置保存成功,请刷新后重试')
|
||||||
}
|
}
|
||||||
poolDialogVisible.value = false
|
poolDialogVisible.value = false
|
||||||
|
recordSavedResult(Number(result?.id || poolForm.id), poolForm.name, result || {})
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
if (result?.id) selectedPoolId.value = Number(result.id)
|
if (result?.id) selectedPoolId.value = Number(result.id)
|
||||||
result?.sync_error
|
notifySavedResult(poolForm.id ? '本地方案已保存' : '分流方案已创建', result || {})
|
||||||
? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步')
|
|
||||||
: ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
|
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
|
||||||
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
@@ -1111,7 +1418,10 @@ function openMemberDialog(row: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveMemberRule() {
|
async function saveMemberRule() {
|
||||||
|
if (savingMember.value) return
|
||||||
savingMember.value = true
|
savingMember.value = true
|
||||||
|
const poolId = Number(selectedPool.value?.id)
|
||||||
|
const poolName = String(selectedPool.value?.name || '')
|
||||||
try {
|
try {
|
||||||
const result: any = await wecomPromotionSaveMember({
|
const result: any = await wecomPromotionSaveMember({
|
||||||
...memberForm,
|
...memberForm,
|
||||||
@@ -1119,10 +1429,9 @@ async function saveMemberRule() {
|
|||||||
active_end: memberForm.active_range?.[1] || ''
|
active_end: memberForm.active_range?.[1] || ''
|
||||||
})
|
})
|
||||||
memberDialogVisible.value = false
|
memberDialogVisible.value = false
|
||||||
|
recordSavedResult(poolId, poolName, result || {})
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
result?.sync_error
|
notifySavedResult('本地医助规则已保存', result || {})
|
||||||
? ElMessage.warning('规则已保存,企业微信成员范围将在后台自动重试同步')
|
|
||||||
: ElMessage.success('医助分流规则已保存')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '医助分流规则保存失败')
|
ElMessage.error(error?.message || '医助分流规则保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1131,13 +1440,15 @@ async function saveMemberRule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleMemberToggle(row: any, value: unknown) {
|
async function handleMemberToggle(row: any, value: unknown) {
|
||||||
|
if (togglingMemberId.value || syncingPoolId.value) return
|
||||||
togglingMemberId.value = Number(row.id)
|
togglingMemberId.value = Number(row.id)
|
||||||
|
const poolId = Number(selectedPool.value?.id)
|
||||||
|
const poolName = String(selectedPool.value?.name || '')
|
||||||
try {
|
try {
|
||||||
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
|
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
|
||||||
|
recordSavedResult(poolId, poolName, result || {})
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
result?.sync_error
|
notifySavedResult('本地成员状态已保存', result || {})
|
||||||
? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步')
|
|
||||||
: ElMessage.success('成员规则已保存,企微多人路由范围已重新计算')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '成员状态更新失败')
|
ElMessage.error(error?.message || '成员状态更新失败')
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
@@ -1275,8 +1586,10 @@ function eligibility(row: any) {
|
|||||||
|
|
||||||
function routeStatus(row: any) {
|
function routeStatus(row: any) {
|
||||||
const available = eligibility(row).className === 'is-ok'
|
const available = eligibility(row).className === 'is-ok'
|
||||||
if (row.is_in_remote_range) return { label: available ? '企微路由中' : '待移出', className: available ? 'is-online' : 'is-offline' }
|
if (row.is_in_remote_range === true || Number(row.is_in_remote_range) === 1) {
|
||||||
return { label: available ? '待同步' : '未在路由范围', className: 'is-offline' }
|
return { label: available ? '企微范围内' : '待移出(仍在企微)', className: available ? 'is-online' : 'is-pending' }
|
||||||
|
}
|
||||||
|
return { label: available ? '待加入企微' : '未在企微范围', className: 'is-offline' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function todayCount(row: any) {
|
function todayCount(row: any) {
|
||||||
@@ -1286,7 +1599,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 +1648,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 +1684,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 +1701,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 +1887,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 +1940,17 @@ 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; }
|
||||||
|
.status-tag.is-pending { color: #a36413; background: #fff1dd; }
|
||||||
|
.member-sync-state :deep(.el-alert__description) { max-height: 84px; overflow-y: auto; overflow-wrap: anywhere; }
|
||||||
|
.member-sync-results { margin-bottom: 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: #f8fafb; font-size: 12px; }
|
||||||
|
.member-sync-results ul { max-height: 150px; margin: 8px 0 0; padding: 0; overflow-y: auto; list-style: none; }
|
||||||
|
.member-sync-results li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; }
|
||||||
|
.member-sync-results li span { overflow-wrap: anywhere; }.member-sync-results .el-button { flex-shrink: 0; }
|
||||||
: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>
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ interface QueueRow {
|
|||||||
assistant_name?: string
|
assistant_name?: string
|
||||||
appointment_date?: string
|
appointment_date?: string
|
||||||
appointment_time?: string
|
appointment_time?: string
|
||||||
|
appointment_type?: string | null
|
||||||
gender?: number
|
gender?: number
|
||||||
age?: number | null
|
age?: number | null
|
||||||
status?: number
|
status?: number
|
||||||
@@ -456,6 +457,7 @@ const handleCall = async (row: QueueRow) => {
|
|||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res
|
signatureData: res
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -1022,6 +1022,7 @@ const handleChat = async (row: any) => {
|
|||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res // 传入签名数据
|
signatureData: res // 传入签名数据
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -662,6 +662,7 @@ const handleChat = async (row: any) => {
|
|||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res
|
signatureData: res
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
<el-form-item label="预约类型:">
|
<el-form-item label="预约类型:">
|
||||||
<el-radio-group v-model="form.appointmentType">
|
<el-radio-group v-model="form.appointmentType">
|
||||||
<el-radio value="video">视频问诊</el-radio>
|
<el-radio value="video">视频问诊</el-radio>
|
||||||
|
<el-radio value="text">图文问诊</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
|
||||||
|
const filename = path.join(__dirname, '../src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue')
|
||||||
|
const source = fs.readFileSync(filename, 'utf8')
|
||||||
|
const { descriptor, errors } = parse(source, { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: 'order-time-test' })
|
||||||
|
const template = compileTemplate({
|
||||||
|
source: descriptor.template.content,
|
||||||
|
filename,
|
||||||
|
id: 'order-time-test',
|
||||||
|
compilerOptions: { bindingMetadata: script.bindings }
|
||||||
|
})
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
const compiled = ts.transpileModule(script.content, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
function dialog(save = async () => ({})) {
|
||||||
|
const calls = []
|
||||||
|
const events = []
|
||||||
|
const module = { exports: {} }
|
||||||
|
const mockRequire = (name) => {
|
||||||
|
if (name === 'vue') return vue
|
||||||
|
if (name === '@/api/tcm') return {
|
||||||
|
prescriptionOrderEditTime: async (payload) => {
|
||||||
|
calls.push(payload)
|
||||||
|
return save(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name === '@/utils/feedback') return { default: { msgSuccess() {} } }
|
||||||
|
throw new Error(`Unexpected dependency: ${name}`)
|
||||||
|
}
|
||||||
|
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
|
||||||
|
const state = module.exports.default.setup({}, {
|
||||||
|
expose() {},
|
||||||
|
emit: (...event) => events.push(event)
|
||||||
|
})
|
||||||
|
state.formRef.value = { validate: async () => true, clearValidate() {} }
|
||||||
|
return { state, calls, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('opening and saving preserves creation time seconds and submits only the selected order', async () => {
|
||||||
|
const { state, calls, events } = dialog()
|
||||||
|
state.open({ id: 42, order_no: 'RX42', create_time: '2026-09-09 11:12:37' })
|
||||||
|
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
|
||||||
|
state.form.create_time = '2026-08-31 09:08:07'
|
||||||
|
await state.submit()
|
||||||
|
assert.deepEqual(calls, [{ id: 42, create_time: '2026-08-31 09:08:07' }])
|
||||||
|
assert.deepEqual(events, [['saved', 42]])
|
||||||
|
assert.equal(state.visible.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy seconds, milliseconds and strings populate the same local picker time', () => {
|
||||||
|
const { state } = dialog()
|
||||||
|
const date = new Date(2026, 8, 9, 11, 12, 37)
|
||||||
|
for (const value of [date.getTime() / 1000, String(date.getTime() / 1000), date.getTime(), '2026-09-09T11:12:37']) {
|
||||||
|
state.open({ id: 1, create_time: value })
|
||||||
|
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
|
||||||
|
}
|
||||||
|
state.open({ id: 1, create_time: '2026-09-09 11:12' })
|
||||||
|
assert.equal(state.form.create_time, '2026-09-09 11:12:00')
|
||||||
|
state.open({ id: 1, create_time: 0 })
|
||||||
|
assert.equal(state.form.create_time, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validation failures do not send requests, and failed saves retain editable input', async () => {
|
||||||
|
const { state, calls, events } = dialog(async () => { throw new Error('denied') })
|
||||||
|
state.open({ id: 9, create_time: '2026-09-09 11:12:37' })
|
||||||
|
state.formRef.value.validate = async () => { throw new Error('required') }
|
||||||
|
await state.submit()
|
||||||
|
assert.equal(calls.length, 0)
|
||||||
|
state.formRef.value.validate = async () => true
|
||||||
|
await state.submit()
|
||||||
|
assert.equal(state.visible.value, true)
|
||||||
|
assert.equal(state.submitting.value, false)
|
||||||
|
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
|
||||||
|
assert.deepEqual(events, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a pending request cannot submit twice or switch its target order', async () => {
|
||||||
|
let finish
|
||||||
|
const pending = new Promise((resolve) => { finish = resolve })
|
||||||
|
const { state, calls, events } = dialog(() => pending)
|
||||||
|
state.open({ id: 7, create_time: '2026-09-09 11:12:37' })
|
||||||
|
const saving = state.submit()
|
||||||
|
await state.submit()
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
state.open({ id: 8, create_time: '2026-09-08 00:00:00' })
|
||||||
|
await state.submit()
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
assert.equal(state.form.id, 7)
|
||||||
|
finish({})
|
||||||
|
await saving
|
||||||
|
assert.deepEqual(events, [['saved', 7]])
|
||||||
|
})
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
|
||||||
|
const filename = path.join(__dirname, '../src/views/first_visit/wecom_promotion/index.vue')
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: 'member-sync-test' })
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'member-sync-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'member-sync-test', scoped: true, preprocessLang: 'scss' }).errors, [])
|
||||||
|
|
||||||
|
function loadModule(source, mockRequire) {
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const module = { exports: {} }
|
||||||
|
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
|
||||||
|
return module.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const automation = loadModule(fs.readFileSync(path.join(path.dirname(filename), 'components/promotion-automation.ts'), 'utf8'), require)
|
||||||
|
const member = (id, enabled = 1) => ({ id, admin_id: id, userid: `member-${id}`, name: `医助 ${id}`, enabled, reception_available: enabled === 1, is_in_remote_range: true })
|
||||||
|
const pool = (id = 1) => ({ id, name: `方案 ${id}`, status: 1, can_operate: true, can_manage_access: true, member_admin_ids: [1, 2], member_rules: [member(1), member(2, 0)], official_link: { range_userids: ['member-1', 'member-2'] }, dispatch_sync: { status: 1 } })
|
||||||
|
|
||||||
|
function page(api = {}, pools = [pool()]) {
|
||||||
|
const messages = []
|
||||||
|
const emitMessage = (type, value) => messages.push({ type, message: typeof value === 'string' ? value : value.message })
|
||||||
|
const ElMessage = (value) => emitMessage(value.type, value)
|
||||||
|
for (const type of ['success', 'warning', 'error']) ElMessage[type] = (value) => emitMessage(type, value)
|
||||||
|
const instance = loadModule(script.content, (name) => {
|
||||||
|
if (name === 'vue') return { ...vue, onMounted() {} }
|
||||||
|
if (name === 'element-plus') return { ElMessage, ElMessageBox: {} }
|
||||||
|
if (name === '@element-plus/icons-vue') return {}
|
||||||
|
if (name === '@/api/first_visit') return { wecomPromotionOverview: async () => ({ pools }), ...api }
|
||||||
|
if (name.endsWith('.vue')) return {}
|
||||||
|
if (name === './components/promotion-automation') return automation
|
||||||
|
throw new Error(`Unexpected dependency: ${name}`)
|
||||||
|
}).default.setup({}, { expose() {} })
|
||||||
|
Object.assign(instance.overview, { pools })
|
||||||
|
instance.selectedPoolId.value = pools[0]?.id
|
||||||
|
return { state: instance, messages }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('disabled local member still in remote snapshot is explicitly pending removal', () => {
|
||||||
|
const { state } = page()
|
||||||
|
assert.equal(state.routeStatus(member(2, 0)).label, '待移出(仍在企微)')
|
||||||
|
assert.equal(state.routeStatus({ ...member(1), is_in_remote_range: '0' }).label, '待加入企微')
|
||||||
|
assert.match(state.selectedSyncState.value.description, /当前计划:医助 1;上次企微确认:医助 1、医助 2/)
|
||||||
|
state.overview.pools[0].dispatch_sync = { status: 3, last_error: '可信 IP 校验失败' }
|
||||||
|
assert.match(state.selectedSyncState.value.description, /可信 IP 校验失败/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('single toggles never infer remote success from an empty error or planned dispatch', async () => {
|
||||||
|
const calls = []
|
||||||
|
const { state, messages } = page({ wecomPromotionToggleMember: async (payload) => { calls.push(payload); return { sync_error: '', dispatch: { queued: true } } } })
|
||||||
|
await state.handleMemberToggle(member(2), false)
|
||||||
|
assert.deepEqual(calls, [{ id: 2, status: 0 }])
|
||||||
|
assert.equal(messages.at(-1).type, 'warning')
|
||||||
|
assert.match(messages.at(-1).message, /尚未确认同步/)
|
||||||
|
assert.doesNotMatch(messages.at(-1).message, /已确认同步|已重新计算/)
|
||||||
|
assert.match(state.operationResultText(state.operationResults.value[0]), /尚未确认同步/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('single rule save reports explicit remote confirmation and preserves precise failures', async () => {
|
||||||
|
const { state, messages } = page({ wecomPromotionSaveMember: async () => ({ sync_status: 'failed', sync_error: '企微成员范围不一致' }) })
|
||||||
|
Object.assign(state.memberForm, { id: 2, active_range: [] })
|
||||||
|
await state.saveMemberRule()
|
||||||
|
assert.equal(messages.at(-1).type, 'warning')
|
||||||
|
assert.match(messages.at(-1).message, /企微成员范围不一致/)
|
||||||
|
state.notifySavedResult('本地已保存', { sync_status: 'synced' })
|
||||||
|
assert.equal(messages.at(-1).type, 'success')
|
||||||
|
assert.match(messages.at(-1).message, /企微成员范围已确认同步/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pool saves with queued work retain a warning instead of implying official link completion', async () => {
|
||||||
|
const { state, messages } = page({ wecomPromotionSavePool: async () => ({ id: 1, sync_status: 'pending', sync_queued: true, sync_error: '' }) })
|
||||||
|
Object.assign(state.poolForm, { id: 1, name: '方案 1', member_admin_ids: [1] })
|
||||||
|
await state.savePool()
|
||||||
|
assert.equal(state.poolDialogVisible.value, false)
|
||||||
|
assert.equal(messages.at(-1).type, 'warning')
|
||||||
|
assert.match(messages.at(-1).message, /尚未确认同步/)
|
||||||
|
assert.equal(state.operationResults.value[0].sync_queued, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manual retry targets only its pool and does not import remote links', async () => {
|
||||||
|
const calls = []
|
||||||
|
const { state, messages } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { pool_id: 1, sync_status: 'synced', sync_error: '', sync_queued: false } } })
|
||||||
|
await state.syncMemberRange(1)
|
||||||
|
assert.deepEqual(calls, [{ pool_id: 1 }])
|
||||||
|
assert.equal(state.operationResults.value[0].sync_status, 'synced')
|
||||||
|
assert.equal(state.syncingPoolId.value, 0)
|
||||||
|
assert.equal(messages.at(-1).type, 'success')
|
||||||
|
state.overview.pools[0].can_operate = false
|
||||||
|
await state.syncMemberRange(1)
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('batch sync awaits all queued successful pools with at most two concurrent requests', async () => {
|
||||||
|
const pending = []
|
||||||
|
let active = 0
|
||||||
|
let maximum = 0
|
||||||
|
const { state } = page({ wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
|
||||||
|
active++
|
||||||
|
maximum = Math.max(maximum, active)
|
||||||
|
pending.push({ id: pool_id, finish: (result) => { active--; resolve({ pool_id, ...result }) } })
|
||||||
|
}) })
|
||||||
|
const results = [1, 2, 3].map((id) => ({ id, name: `方案 ${id}`, success: true, sync_queued: true }))
|
||||||
|
results.push({ id: 4, name: '保存失败方案', success: false, sync_queued: true, error: '无权限' })
|
||||||
|
const saving = state.syncBatchResults(results)
|
||||||
|
assert.deepEqual(pending.map((item) => item.id), [1, 2])
|
||||||
|
pending[0].finish({ sync_status: 'synced', sync_error: '' })
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
|
||||||
|
assert.equal(state.batchSyncProgress.completed, 1)
|
||||||
|
pending[1].finish({ sync_status: 'failed', sync_error: '范围校验失败' })
|
||||||
|
pending[2].finish({ sync_status: 'pending', sync_error: '' })
|
||||||
|
await saving
|
||||||
|
assert.equal(maximum, 2)
|
||||||
|
assert.equal(state.batchSyncProgress.completed, 3)
|
||||||
|
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', undefined])
|
||||||
|
assert.match(state.operationResultText(state.operationResults.value[1]), /范围校验失败/)
|
||||||
|
assert.match(state.operationResultText(state.operationResults.value[3]), /本地保存失败:无权限/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('batch save retains partial failures and never reports queued work as remote success', async () => {
|
||||||
|
const pools = [pool(1), pool(2), pool(3)]
|
||||||
|
const syncedIds = []
|
||||||
|
const { state, messages } = page({
|
||||||
|
wecomPromotionBatchUpdatePools: async () => ({ updated: 2, failed: 1, member_updated: 2, results: [
|
||||||
|
{ id: 1, name: '方案 1', success: true, sync_queued: true },
|
||||||
|
{ id: 2, name: '方案 2', success: true, sync_queued: true },
|
||||||
|
{ id: 3, name: '方案 3', success: false, error: '保存失败' }
|
||||||
|
] }),
|
||||||
|
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
|
||||||
|
syncedIds.push(pool_id)
|
||||||
|
if (pool_id === 2) throw '企微请求超时'
|
||||||
|
return { pool_id, sync_status: 'synced', sync_error: '', sync_queued: false }
|
||||||
|
}
|
||||||
|
}, pools)
|
||||||
|
Object.assign(state.batchConfigApply, { member_status: true })
|
||||||
|
Object.assign(state.batchConfigForm, { pool_ids: [1, 2, 3], member_admin_ids: [2], member_status: 0 })
|
||||||
|
state.batchConfigDialogVisible.value = true
|
||||||
|
await state.saveBatchConfig()
|
||||||
|
assert.deepEqual(syncedIds, [1, 2])
|
||||||
|
assert.equal(state.savingBatchConfig.value, false)
|
||||||
|
assert.equal(state.batchConfigDialogVisible.value, false)
|
||||||
|
assert.deepEqual(state.selectedPoolIds.value, [2, 3])
|
||||||
|
assert.match(messages.at(-1).message, /企微已确认同步 1 个,1 个尚未确认同步;1 个本地保存失败/)
|
||||||
|
assert.equal(messages.at(-1).type, 'warning')
|
||||||
|
assert.match(state.operationResults.value[1].sync_error, /企微请求超时/)
|
||||||
|
})
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 73 KiB |
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Bundled Noto Sans SC
|
||||||
|
|
||||||
|
`NotoSansSC-VF.ttf` is the unmodified Google Fonts distribution of
|
||||||
|
`NotoSansSC[wght].ttf`, stored under a filename without brackets for simpler
|
||||||
|
resource lookup. No font bytes were changed or subsetted locally.
|
||||||
|
|
||||||
|
- Qt family: `Noto Sans SC`
|
||||||
|
- Font version: `Version 2.004-H2;hotconv 1.0.118;makeotfexe 2.5.65603`
|
||||||
|
- Variable axis: `wght`, 100–900; named instances at every 100, including 600.
|
||||||
|
- Size: 17,772,300 bytes.
|
||||||
|
- SHA-256: `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da`
|
||||||
|
- Official repository revision: `google/fonts@5e35378e6bda803962ee6fd257e444a7d459660d`.
|
||||||
|
- [Pinned font source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf).
|
||||||
|
- [Pinned license source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/OFL.txt).
|
||||||
|
|
||||||
|
The font is distributed under the SIL Open Font License 1.1. Retain
|
||||||
|
`OFL-NotoSansSC.txt`, including its copyright notice, when redistributing the
|
||||||
|
font with the application. The license applies to the font, independently of
|
||||||
|
the application's license.
|
||||||
|
|
||||||
|
Load this local resource through `QFontDatabase.addApplicationFont` after
|
||||||
|
creating `QApplication`, then use the returned family name. The application
|
||||||
|
must not fetch fonts at runtime. The PyInstaller spec already copies the
|
||||||
|
entire `resources` directory, including this directory and its license.
|
||||||
|
|
||||||
|
Google Fonts supplies explicit Regular (400), Medium (500), and SemiBold (600)
|
||||||
|
instances. The Noto CJK upstream 2.004 file lacks a named 600 instance and Qt
|
||||||
|
may select Medium for a plain `font-weight: 600` request; this distribution
|
||||||
|
preserves distinct results with the application's normal QSS font weights.
|
||||||
@@ -169,8 +169,30 @@ try {
|
|||||||
& $Npm run build --prefix $CompanionRoot
|
& $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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Render the clinical reading surfaces with demo data and production fonts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
|
from PySide6.QtGui import QFontInfo, QGuiApplication, QPalette
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(app: QApplication) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
QThreadPool.globalInstance().waitForDone(3000)
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(100)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--output", type=Path, default=Path("artifacts/ui_comfort"))
|
||||||
|
parser.add_argument("--width", type=int, default=1536)
|
||||||
|
parser.add_argument("--height", type=int, default=912)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.output.mkdir(parents=True, exist_ok=True)
|
||||||
|
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||||
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
|
)
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
repo = DemoDoctorRepository()
|
||||||
|
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||||
|
shell = ShellWindow(
|
||||||
|
repo, {"session": session, "demo_mode": True}, permissions=session.permissions
|
||||||
|
)
|
||||||
|
shell.resize(args.width, args.height)
|
||||||
|
shell.show()
|
||||||
|
try:
|
||||||
|
shell.navigate("reception")
|
||||||
|
_settle(app)
|
||||||
|
page = shell.pages["reception"]
|
||||||
|
page._set_queue_filter(None)
|
||||||
|
_settle(app)
|
||||||
|
# A synthetic multiline case tests paragraph rhythm without capturing
|
||||||
|
# a live patient or connecting to a production service.
|
||||||
|
page.case_labels["present"].setText(
|
||||||
|
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
|
||||||
|
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
|
||||||
|
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
|
||||||
|
)
|
||||||
|
app.processEvents()
|
||||||
|
if not shell.grab().save(str(args.output / "reception.png")):
|
||||||
|
raise RuntimeError("Could not save reception preview")
|
||||||
|
daily = next(
|
||||||
|
index
|
||||||
|
for index in range(page.detail_tabs.count())
|
||||||
|
if page.detail_tabs.tabText(index) == "日常记录"
|
||||||
|
)
|
||||||
|
page.detail_tabs.setCurrentIndex(daily)
|
||||||
|
_settle(app)
|
||||||
|
if not shell.grab().save(str(args.output / "daily_records.png")):
|
||||||
|
raise RuntimeError("Could not save daily-record preview")
|
||||||
|
metrics = {
|
||||||
|
"family": QFontInfo(app.font()).family(),
|
||||||
|
"pixel_size": app.font().pixelSize(),
|
||||||
|
"font_strategy": app.font().styleStrategy().value,
|
||||||
|
"font_hinting": app.font().hintingPreference().name,
|
||||||
|
"text_color": app.palette().color(QPalette.ColorRole.Text).name(),
|
||||||
|
"device_pixel_ratio": shell.devicePixelRatioF(),
|
||||||
|
"window": [shell.width(), shell.height()],
|
||||||
|
"daily_table_font": QFontInfo(page.daily_panel.matrix.font()).family(),
|
||||||
|
"daily_table_size": page.daily_panel.matrix.font().pixelSize(),
|
||||||
|
}
|
||||||
|
(args.output / "render.json").write_text(
|
||||||
|
json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(args.output)
|
||||||
|
finally:
|
||||||
|
_settle(app)
|
||||||
|
shell.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||||
|
|
||||||
# Single source of truth for runtime, package, installer, and executable versions.
|
# Single source of truth for runtime, package, installer, and executable versions.
|
||||||
__version__ = "1.4.1"
|
__version__ = "1.4.2"
|
||||||
|
|
||||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||||
|
|||||||
@@ -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;")
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ class DemoDoctorRepository:
|
|||||||
return detail
|
return detail
|
||||||
raise ApiBusinessError("挂号不存在", code=0)
|
raise ApiBusinessError("挂号不存在", code=0)
|
||||||
|
|
||||||
def list_departments(self) -> list[dict[str, Any]]:
|
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||||
"""Return a small demo department tree."""
|
"""Return a small demo department tree."""
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -335,8 +335,8 @@ class DoctorRepository(Protocol):
|
|||||||
def get_appointment_detail(self, appointment_id: int) -> dict[str, Any]:
|
def get_appointment_detail(self, appointment_id: int) -> dict[str, Any]:
|
||||||
"""Return one appointment detail from ``doctor.appointment/detail``."""
|
"""Return one appointment detail from ``doctor.appointment/detail``."""
|
||||||
|
|
||||||
def list_departments(self) -> list[dict[str, Any]]:
|
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||||
"""Return the department tree used by appointment list filters."""
|
"""Return a department tree, optionally scoped to the account's data permissions."""
|
||||||
|
|
||||||
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
||||||
"""Return an editable or permission-aware readonly diagnosis detail."""
|
"""Return an editable or permission-aware readonly diagnosis detail."""
|
||||||
@@ -976,17 +976,20 @@ class RemoteDoctorRepository:
|
|||||||
payload = self.client.get("doctor.appointment/detail", {"id": appointment_id})
|
payload = self.client.get("doctor.appointment/detail", {"id": appointment_id})
|
||||||
return dict(_require_mapping(payload, "doctor.appointment/detail"))
|
return dict(_require_mapping(payload, "doctor.appointment/detail"))
|
||||||
|
|
||||||
def list_departments(self) -> list[dict[str, Any]]:
|
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||||
"""Load the department tree through ``dept.dept/all``."""
|
"""Load ``dept.dept/all`` with optional server-enforced role data scope."""
|
||||||
|
|
||||||
payload = self.client.get("dept.dept/all")
|
payload = self.client.get(
|
||||||
if isinstance(payload, list):
|
"dept.dept/all", {"apply_data_scope": 1} if apply_data_scope else None
|
||||||
return [dict(row) for row in payload if isinstance(row, Mapping)]
|
)
|
||||||
if isinstance(payload, Mapping):
|
for _depth in range(5):
|
||||||
rows = payload.get("lists", payload.get("data", payload.get("tree")))
|
if isinstance(payload, list):
|
||||||
if isinstance(rows, list):
|
return [dict(row) for row in payload if isinstance(row, Mapping)]
|
||||||
return [dict(row) for row in rows if isinstance(row, Mapping)]
|
if isinstance(payload, Mapping):
|
||||||
return []
|
payload = payload.get("lists", payload.get("data", payload.get("tree")))
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
raise ApiProtocolError("部门数据格式异常,请重试")
|
||||||
|
|
||||||
def list_reception_queue(
|
def list_reception_queue(
|
||||||
self,
|
self,
|
||||||
@@ -2057,6 +2060,9 @@ class RemoteDoctorRepository:
|
|||||||
"""List diagnosis records using ``tcm.diagnosis/lists``."""
|
"""List diagnosis records using ``tcm.diagnosis/lists``."""
|
||||||
|
|
||||||
request_filters = dict(filters)
|
request_filters = dict(filters)
|
||||||
|
department_id = request_filters.pop("department_id", None)
|
||||||
|
if department_id not in (None, ""):
|
||||||
|
request_filters.setdefault("assistant_dept_id", department_id)
|
||||||
start_date = str(request_filters.pop("start_date", "") or "").strip()
|
start_date = str(request_filters.pop("start_date", "") or "").strip()
|
||||||
end_date = str(request_filters.pop("end_date", "") or "").strip()
|
end_date = str(request_filters.pop("end_date", "") or "").strip()
|
||||||
if start_date and start_date == end_date:
|
if start_date and start_date == end_date:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Page-scoped palette and compact typography for the approved appointment list."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
def appointments_stylesheet() -> str:
|
||||||
|
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
_QSS = """
|
||||||
|
#AppointmentsPage { background: #F3F7FD; color: #273244; }
|
||||||
|
#AppointmentsPage QLabel, #AppointmentsPage QPushButton,
|
||||||
|
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox,
|
||||||
|
#AppointmentsPage QTabBar, #AppointmentsPage QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="muted"],
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumb"],
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
|
||||||
|
color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QFrame#AppointmentFilterPanel,
|
||||||
|
#AppointmentsPage QFrame#AppointmentMainCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton {
|
||||||
|
min-height: 30px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 6px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
|
||||||
|
#AppointmentsPage QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#AppointmentsPage QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#AppointmentsPage QPushButton[variant="primary"] {
|
||||||
|
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[variant="primary"]:hover { background: #155BCC; }
|
||||||
|
#AppointmentsPage QPushButton[variant="primary"]:pressed { background: #124EA9; }
|
||||||
|
#AppointmentsPage QPushButton[variant="danger"] {
|
||||||
|
color: #B84652; background: #FFFFFF; border-color: #EFC8CE;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[variant="danger"]:hover { background: #FFF0F2; }
|
||||||
|
#AppointmentsPage QPushButton[variant="ghost"] { background: transparent; border-color: transparent; }
|
||||||
|
#AppointmentsPage QPushButton:disabled {
|
||||||
|
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox {
|
||||||
|
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLineEdit:focus, #AppointmentsPage QComboBox:focus { border-color: #75A5F0; }
|
||||||
|
#AppointmentsPage QLineEdit QToolButton {
|
||||||
|
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLineEdit QToolButton:focus { background: #EAF2FF; }
|
||||||
|
#AppointmentsPage QPushButton#AppointmentSearchButton {
|
||||||
|
min-height: 39px; max-height: 39px; min-width: 52px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLineEdit#AppointmentPatientSearch { min-height: 39px; max-height: 39px; }
|
||||||
|
#AppointmentsPage QPushButton[appointmentStat="true"] {
|
||||||
|
min-height: 34px; max-height: 34px; padding: 0 10px; background: #F4F7FC;
|
||||||
|
border-color: transparent; color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: #1555B6; background: #EAF2FF; }
|
||||||
|
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
|
||||||
|
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"]:!checked {
|
||||||
|
color: #9C681F; background: #FFF6E7; border-color: #EEDCBF;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLabel#FilterRowLabel { color: #5D6B80; font-size: 13px; }
|
||||||
|
#AppointmentsPage QLabel#FilterDivider { color: #DBE5F2; padding: 0 10px; }
|
||||||
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
|
||||||
|
min-width: 52px; min-height: 32px; padding: 0 12px; color: #5D6B80;
|
||||||
|
background: transparent; border: 0; border-bottom: 2px solid transparent; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: #1555B6; background: #F7FAFE; }
|
||||||
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
|
||||||
|
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[filterChoice="true"] {
|
||||||
|
min-height: 30px; max-height: 30px; padding: 0 12px; color: #5D6B80;
|
||||||
|
background: transparent; border-color: transparent; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[filterChoice="true"]:checked { color: #1555B6; background: #EAF2FF; }
|
||||||
|
#AppointmentsPage QFrame#AppointmentToolbar { background: transparent; border: 0; }
|
||||||
|
#AppointmentsPage QPushButton[compactAction="true"] {
|
||||||
|
min-height: 36px; max-height: 36px; padding: 0 17px; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:enabled {
|
||||||
|
color: #1769E8; border-color: #ADC8F0;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QTableWidget#AppointmentTable {
|
||||||
|
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||||
|
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QTableWidget#AppointmentTable::item { padding: 0; border: 0; border-bottom: 1px solid #E6EDF6; }
|
||||||
|
#AppointmentsPage QTableWidget#AppointmentTable::item:selected { background: #EAF2FF; color: #273244; }
|
||||||
|
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
|
||||||
|
min-height: 40px; padding: 0; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-top: 1px solid #E1E9F4; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget[appointmentSelectionHost="true"] {
|
||||||
|
background: transparent; border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget[appointmentSelectionHost="true"][selected="true"] { border-left-color: #1769E8; }
|
||||||
|
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
|
||||||
|
width: 16px; height: 16px; background: #FFFFFF; border: 1px solid #C8D5E6; border-radius: 3px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||||
|
#AppointmentsPage QWidget[appointmentInfoHost="true"],
|
||||||
|
#AppointmentsPage QWidget[appointmentImHost="true"] { background: transparent; }
|
||||||
|
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
|
||||||
|
min-height: 20px; max-height: 20px; padding: 0 6px; color: #1555B6;
|
||||||
|
background: #EAF2FF; border-radius: 4px; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: #9C681F; background: #FFF3DD; }
|
||||||
|
#AppointmentsPage QLabel[tableAppointmentStatusKind="muted"] { color: #66758A; background: #EEF2F7; }
|
||||||
|
#AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: #5D6B80; font-size: 13px; }
|
||||||
|
#AppointmentsPage QPushButton[tableCancelAction="true"] {
|
||||||
|
min-height: 20px; max-height: 20px; padding: 0 5px; color: #B84652;
|
||||||
|
background: #FFF0F2; border: 0; border-radius: 4px; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[appointmentImAction="true"] {
|
||||||
|
min-width: 62px; min-height: 32px; max-height: 32px; padding: 0 10px;
|
||||||
|
color: #1555B6; background: #EAF2FF; border-color: #C9DCF7; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: #FFFFFF; background: #1769E8; }
|
||||||
|
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: #8A97A9; background: #F3F6FB; border-color: #DFE7F2; }
|
||||||
|
#AppointmentsPage QWidget#Pager { background: #FFFFFF; border: 0; border-top: 1px solid #E1E9F4; }
|
||||||
|
#AppointmentsPage QWidget#Pager QLabel { color: #5D6B80; font-size: 13px; border: 0; }
|
||||||
|
#AppointmentsPage QWidget#Pager QPushButton {
|
||||||
|
min-width: 30px; max-width: 30px; min-height: 32px; max-height: 32px;
|
||||||
|
padding: 0; border: 1px solid #DBE5F2; background: #FFFFFF; color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget#Pager QPushButton[active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||||
|
#AppointmentsPage QWidget#Pager QPushButton:disabled { color: #9AA6B7; background: #F7F9FC; }
|
||||||
|
"""
|
||||||
@@ -46,51 +46,51 @@ QFrame#ChatNotifyCard {
|
|||||||
border: 1px solid #BBF0CE;
|
border: 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; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Scoped colors and compact typography for the approved consultation list."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
def consultations_stylesheet() -> str:
|
||||||
|
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
_QSS = """
|
||||||
|
#DiagnosisIndex, #DiagnosisIndexContent, #DiagnosisPageScroll {
|
||||||
|
background: #F3F7FD; color: #273244; border: 0;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QLabel, #DiagnosisIndex QPushButton, #DiagnosisIndex QToolButton,
|
||||||
|
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit,
|
||||||
|
#DiagnosisIndex QSpinBox, #DiagnosisIndex QTableView {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QLabel[role="muted"], #DiagnosisIndex QLabel[role="breadcrumb"],
|
||||||
|
#DiagnosisIndex QLabel[role="breadcrumbCurrent"], #DiagnosisIndex QLabel[role="breadcrumbSeparator"],
|
||||||
|
#DiagnosisIndex QLabel[filterGroup="true"], #DiagnosisIndex QLabel[pagerMuted="true"] {
|
||||||
|
color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisFilterCard, #DiagnosisIndex QFrame#DiagnosisListCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisStatusCard, #DiagnosisIndex QFrame#DiagnosisQuickFilters,
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisListToolbar { background: transparent; border: 0; }
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
|
||||||
|
background: transparent; border: 0; border-top: 1px solid #E6EDF6; border-radius: 0;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QPushButton {
|
||||||
|
min-height: 34px; padding: 0 13px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 6px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
|
||||||
|
#DiagnosisIndex QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#DiagnosisIndex QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#DiagnosisIndex QPushButton[variant="primary"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||||
|
#DiagnosisIndex QPushButton[variant="primary"]:hover { background: #155BCC; }
|
||||||
|
#DiagnosisIndex QPushButton[consultationTool="true"] { color: #1555B6; border-color: #C2D5EF; }
|
||||||
|
#DiagnosisIndex QPushButton[consultationDanger="true"] { color: #BE4B58; border-color: #EBCDD2; }
|
||||||
|
#DiagnosisIndex QPushButton:disabled, #DiagnosisIndex QPushButton[consultationDanger="true"]:disabled {
|
||||||
|
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit, #DiagnosisIndex QSpinBox {
|
||||||
|
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QLineEdit:focus, #DiagnosisIndex QComboBox:focus,
|
||||||
|
#DiagnosisIndex QDateEdit:focus, #DiagnosisIndex QSpinBox:focus { border-color: #75A5F0; }
|
||||||
|
#DiagnosisIndex QLineEdit QToolButton {
|
||||||
|
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QComboBox::drop-down, #DiagnosisIndex QDateEdit::drop-down {
|
||||||
|
width: 22px; border: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QComboBox QAbstractItemView {
|
||||||
|
color: #273244; background: #FFFFFF; border: 1px solid #DBE5F2;
|
||||||
|
selection-background-color: #EAF2FF; selection-color: #1555B6; outline: 0;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit,
|
||||||
|
#DiagnosisIndex QWidget#DiagnosisStatusSearch QPushButton { min-height: 39px; max-height: 39px; }
|
||||||
|
#DiagnosisIndex QToolButton { min-width: 0; min-height: 0; border: 0; padding: 0; background: transparent; }
|
||||||
|
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
|
||||||
|
min-height: 32px; max-height: 32px; padding: 0 14px; border: 1px solid transparent;
|
||||||
|
border-radius: 5px; color: #5D6B80; background: transparent; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { background: #F2F7FF; color: #1555B6; }
|
||||||
|
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked { background: #1769E8; color: #FFFFFF; }
|
||||||
|
#DiagnosisIndex QToolButton[dateChoice="true"] { padding: 0 17px; border-color: #E1E9F4; }
|
||||||
|
#DiagnosisIndex QToolButton[dateChoice="true"]:checked { border-color: #1769E8; }
|
||||||
|
#DiagnosisIndex QToolButton[statusTab="true"] {
|
||||||
|
min-height: 44px; max-height: 44px; padding: 0 20px; border: 0;
|
||||||
|
border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[statusTab="true"]:checked {
|
||||||
|
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton#DiagnosisMoreFilter {
|
||||||
|
min-height: 32px; padding: 0 6px; color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton#DiagnosisMoreFilter:hover { color: #1769E8; background: #F2F7FF; }
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisFilterDivider { min-width: 1px; max-width: 1px; min-height: 20px; background: #E1E9F4; border: 0; }
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisDateRange {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QDateEdit[diagnosisRangePart="true"] { min-height: 30px; padding: 0 4px; border: 0; }
|
||||||
|
#DiagnosisIndex QTableView {
|
||||||
|
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||||
|
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QTableView QHeaderView::section {
|
||||||
|
min-height: 41px; padding: 0; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[rowLink], #DiagnosisIndex QToolButton[appointmentCancel="true"] {
|
||||||
|
color: #1769E8; min-height: 26px; padding: 0 4px; border: 0; background: transparent; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[appointmentCancel="true"] { color: #BE4B58; }
|
||||||
|
#DiagnosisIndex QToolButton[rowLink]:hover { color: #1555B6; background: #DCEAFF; border-radius: 4px; }
|
||||||
|
#DiagnosisIndex QToolButton[rowLink="muted"], #DiagnosisIndex QLabel[fixedMuted="true"] {
|
||||||
|
color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton#DiagnosisRowMore { padding-right: 18px; }
|
||||||
|
#DiagnosisIndex QWidget#DiagnosisFixedCell { background: transparent; }
|
||||||
|
#DiagnosisIndex QLabel#DiagnosisTableEmpty { color: #5D6B80; background: #FFFFFF; }
|
||||||
|
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { color: #BE4B58; }
|
||||||
|
#DiagnosisIndex QTableView#DiagnosisFixedTable { border-left: 1px solid #E1E9F4; }
|
||||||
|
#DiagnosisIndex QWidget#DiagnosisPager { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#DiagnosisIndex QToolButton[pagerButton="true"] {
|
||||||
|
min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 5px; color: #5D6B80; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||||
|
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #A4B0C0; background: #F6F8FC; }
|
||||||
|
#DiagnosisIndex QComboBox#DiagnosisPageSize { min-width: 95px; min-height: 30px; }
|
||||||
|
#DiagnosisIndex QSpinBox#DiagnosisPageJumper { min-height: 30px; padding: 0 8px; }
|
||||||
|
#DiagnosisIndex QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#DiagnosisIndex QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#DiagnosisIndex QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#DiagnosisIndex QScrollBar::add-line, #DiagnosisIndex QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#DiagnosisIndex QScrollBar::add-page, #DiagnosisIndex QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -134,63 +134,63 @@ def open_safe_http_url(target: str) -> bool:
|
|||||||
_INLINE_PLAYER_QSS = """
|
_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;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1103,6 +1103,11 @@ class ImagePreviewDialog(QDialog):
|
|||||||
self._invalidate_request()
|
self._invalidate_request()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
def done(self, result: int) -> None:
|
||||||
|
# QDialog.reject() (including Escape) bypasses closeEvent.
|
||||||
|
self._invalidate_request()
|
||||||
|
super().done(result)
|
||||||
|
|
||||||
|
|
||||||
def _clock(milliseconds: int) -> str:
|
def _clock(milliseconds: int) -> str:
|
||||||
seconds = max(0, int(milliseconds) // 1000)
|
seconds = max(0, int(milliseconds) // 1000)
|
||||||
|
|||||||
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()
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ from ..diagnosis_drawer import (
|
|||||||
set_tag_item,
|
set_tag_item,
|
||||||
)
|
)
|
||||||
from ..diagnosis_editors import DailyRecordEditorDialog
|
from ..diagnosis_editors import DailyRecordEditorDialog
|
||||||
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
|
from ..diagnosis_media import (
|
||||||
|
ImagePreviewDialog,
|
||||||
|
RecordingPlaybackCell,
|
||||||
|
RecordingPlayerDialog,
|
||||||
|
safe_image_sources,
|
||||||
|
)
|
||||||
|
from ..infinite_list import InfiniteList
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -190,74 +196,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;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -831,6 +837,8 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._saving = False
|
self._saving = False
|
||||||
self._load_mode = "readonly"
|
self._load_mode = "readonly"
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
|
self._dictionary_requested_generation = -1
|
||||||
|
self._dictionary_error_message = ""
|
||||||
self._save_generation = 0
|
self._save_generation = 0
|
||||||
self._orders_generation = 0
|
self._orders_generation = 0
|
||||||
self._order_detail_generation = 0
|
self._order_detail_generation = 0
|
||||||
@@ -845,6 +853,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._loading_tabs: set[str] = set()
|
self._loading_tabs: set[str] = set()
|
||||||
self._daily_todo_status: int | None = None
|
self._daily_todo_status: int | None = None
|
||||||
self._recording_players: list[RecordingPlayerDialog] = []
|
self._recording_players: list[RecordingPlayerDialog] = []
|
||||||
|
self._image_preview: ImagePreviewDialog | None = None
|
||||||
self._inline_recording_cells: list[RecordingPlaybackCell] = []
|
self._inline_recording_cells: list[RecordingPlaybackCell] = []
|
||||||
self._last_order_detail_dialog: QDialog | None = None
|
self._last_order_detail_dialog: QDialog | None = None
|
||||||
self._local_audio_dialog: LocalAudioQueueDialog | None = None
|
self._local_audio_dialog: LocalAudioQueueDialog | None = None
|
||||||
@@ -964,6 +973,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._add_readonly_section("daily", "日常记录", daily)
|
self._add_readonly_section("daily", "日常记录", daily)
|
||||||
notes = NotesTimeline(editable=False)
|
notes = NotesTimeline(editable=False)
|
||||||
notes.open_attachment_requested.connect(self._open_safe_resource)
|
notes.open_attachment_requested.connect(self._open_safe_resource)
|
||||||
|
notes.preview_images_requested.connect(self._preview_note_images)
|
||||||
self._notes_timelines.append(notes)
|
self._notes_timelines.append(notes)
|
||||||
self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes)
|
self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes)
|
||||||
orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable")
|
orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable")
|
||||||
@@ -1003,8 +1013,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)
|
||||||
@@ -1436,6 +1446,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
timeline.upload_requested.connect(self._upload_doctor_note_material)
|
timeline.upload_requested.connect(self._upload_doctor_note_material)
|
||||||
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
|
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
|
||||||
timeline.open_attachment_requested.connect(self._open_safe_resource)
|
timeline.open_attachment_requested.connect(self._open_safe_resource)
|
||||||
|
timeline.preview_images_requested.connect(self._preview_note_images)
|
||||||
self.drawer_notes_timeline = timeline
|
self.drawer_notes_timeline = timeline
|
||||||
self._notes_timelines.append(timeline)
|
self._notes_timelines.append(timeline)
|
||||||
return self._wrap_tab("DiagnosisTabNotes", timeline)
|
return self._wrap_tab("DiagnosisTabNotes", timeline)
|
||||||
@@ -1532,20 +1543,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:
|
||||||
@@ -1706,22 +1708,23 @@ class DiagnosisDialog(QDialog):
|
|||||||
if hasattr(self, "readonly_ai_button"):
|
if hasattr(self, "readonly_ai_button"):
|
||||||
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
||||||
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
|
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
|
||||||
current_key = self.tabs.tabBar().tabData(self.tabs.currentIndex())
|
previous_key = self._current_tab_key()
|
||||||
self.tabs.clear()
|
allowed_tabs = [
|
||||||
for key, label, codes in _TAB_DEFINITIONS:
|
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
|
||||||
if not self._tab_allowed(codes):
|
]
|
||||||
continue
|
if [self.tabs.tabBar().tabData(i) for i in range(self.tabs.count())] != [
|
||||||
index = self.tabs.addTab(self._tab_pages[key], label)
|
key for key, _label in allowed_tabs
|
||||||
self.tabs.tabBar().setTabData(index, key)
|
]:
|
||||||
target = next(
|
blocked = self.tabs.blockSignals(True)
|
||||||
(
|
self.tabs.clear()
|
||||||
index
|
target = 0
|
||||||
for index in range(self.tabs.count())
|
for key, label in allowed_tabs:
|
||||||
if self.tabs.tabBar().tabData(index) == current_key
|
index = self.tabs.addTab(self._tab_pages[key], label)
|
||||||
),
|
self.tabs.tabBar().setTabData(index, key)
|
||||||
0,
|
if key == previous_key:
|
||||||
)
|
target = index
|
||||||
self.tabs.setCurrentIndex(target)
|
self.tabs.setCurrentIndex(target)
|
||||||
|
self.tabs.blockSignals(blocked)
|
||||||
section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
|
section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
|
||||||
for key, section in self._readonly_sections.items():
|
for key, section in self._readonly_sections.items():
|
||||||
section.setVisible(self._tab_allowed(section_codes.get(key, ())))
|
section.setVisible(self._tab_allowed(section_codes.get(key, ())))
|
||||||
@@ -1751,6 +1754,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._sync_order_offset_actions()
|
self._sync_order_offset_actions()
|
||||||
for panel in self._chat_panels:
|
for panel in self._chat_panels:
|
||||||
panel.sync_button.setVisible(self._can_chat_sync)
|
panel.sync_button.setVisible(self._can_chat_sync)
|
||||||
|
self._sync_save_button()
|
||||||
|
if self._current_tab_key() != previous_key:
|
||||||
|
self._tab_changed(self.tabs.currentIndex())
|
||||||
|
|
||||||
def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
|
def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
|
||||||
self.drawer_banner.show_message(text, kind, action_text)
|
self.drawer_banner.show_message(text, kind, action_text)
|
||||||
@@ -1759,6 +1765,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
self.readonly_error.show()
|
self.readonly_error.show()
|
||||||
|
|
||||||
def _clear_message(self) -> None:
|
def _clear_message(self) -> None:
|
||||||
|
if self._dictionary_error_message and self._authoritative_detail_loaded:
|
||||||
|
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
|
||||||
|
return
|
||||||
self.drawer_banner.clear()
|
self.drawer_banner.clear()
|
||||||
self.readonly_error.hide()
|
self.readonly_error.hide()
|
||||||
|
|
||||||
@@ -2004,6 +2013,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self._authoritative_detail_loaded = False
|
self._authoritative_detail_loaded = False
|
||||||
self._saving = False
|
self._saving = False
|
||||||
|
self._dictionary_error_message = ""
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._save_generation += 1
|
self._save_generation += 1
|
||||||
self._orders_generation += 1
|
self._orders_generation += 1
|
||||||
@@ -2021,6 +2031,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()
|
||||||
@@ -2126,14 +2143,26 @@ class DiagnosisDialog(QDialog):
|
|||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
return {"detail": detail, "patient_id": patient_id}
|
||||||
|
|
||||||
|
def _load_dictionary_choices(self) -> dict[str, Any]:
|
||||||
|
"""Load optional editor labels without extending the authoritative-detail gate."""
|
||||||
dictionaries: dict[str, list[tuple[str, Any]]] = {}
|
dictionaries: dict[str, list[tuple[str, Any]]] = {}
|
||||||
dictionary_errors: list[str] = []
|
dictionary_errors: list[str] = []
|
||||||
if callable(getattr(self.repository, "get_dictionary", None)):
|
dictionary_types = sorted(
|
||||||
dictionary_types = {
|
{
|
||||||
"diagnosis_type",
|
"diagnosis_type",
|
||||||
*(item[0] for item in _CHOICE_DICTIONARIES.values()),
|
*(item[0] for item in _CHOICE_DICTIONARIES.values()),
|
||||||
}
|
}
|
||||||
for dictionary_type in sorted(dictionary_types):
|
)
|
||||||
|
if callable(getattr(self.repository, "get_dictionaries", None)):
|
||||||
|
raw = invoke(self.repository, "get_dictionaries", dictionary_types=dictionary_types)
|
||||||
|
dictionaries = {
|
||||||
|
key: _dictionary_choices(get_value(raw, key, []), key)
|
||||||
|
for key in dictionary_types
|
||||||
|
}
|
||||||
|
elif callable(getattr(self.repository, "get_dictionary", None)):
|
||||||
|
for dictionary_type in dictionary_types:
|
||||||
try:
|
try:
|
||||||
raw = invoke(
|
raw = invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
@@ -2144,12 +2173,41 @@ class DiagnosisDialog(QDialog):
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}")
|
dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}")
|
||||||
return {
|
return {
|
||||||
"detail": detail,
|
|
||||||
"patient_id": patient_id,
|
|
||||||
"dictionaries": dictionaries,
|
"dictionaries": dictionaries,
|
||||||
"dictionary_errors": dictionary_errors,
|
"dictionary_errors": dictionary_errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _ensure_dictionary_loaded(self, *, force: bool = False) -> None:
|
||||||
|
if self._standalone_readonly or not self._authoritative_detail_loaded:
|
||||||
|
return
|
||||||
|
generation = self._generation
|
||||||
|
if not force and self._dictionary_requested_generation == generation:
|
||||||
|
return
|
||||||
|
self._dictionary_requested_generation = generation
|
||||||
|
clear_error = self.drawer_banner.label.text() == self._dictionary_error_message
|
||||||
|
self._dictionary_error_message = ""
|
||||||
|
if clear_error:
|
||||||
|
self._clear_message()
|
||||||
|
run_async(
|
||||||
|
self._load_dictionary_choices,
|
||||||
|
on_success=lambda result: self._dictionary_choices_loaded(result, generation),
|
||||||
|
on_error=lambda error: self._dictionary_choices_loaded(
|
||||||
|
{"dictionary_errors": [f"病历选项加载失败:{friendly_error(error)}"]}, generation
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dictionary_choices_loaded(self, result: Any, generation: int) -> None:
|
||||||
|
if generation != self._generation or not self._authoritative_detail_loaded:
|
||||||
|
return
|
||||||
|
# A clinician may already have edited fields while the optional labels load.
|
||||||
|
# Update only choices; re-rendering the detail would overwrite that draft.
|
||||||
|
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {}, preserve=True)
|
||||||
|
errors = get_value(result, "dictionary_errors", []) or []
|
||||||
|
if errors:
|
||||||
|
self._dictionary_requested_generation = -1
|
||||||
|
self._dictionary_error_message = ";".join(str(item) for item in errors)
|
||||||
|
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
|
||||||
|
|
||||||
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
|
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
|
||||||
filters: dict[str, Any] = {
|
filters: dict[str, Any] = {
|
||||||
"context_diagnosis_id": diagnosis_id,
|
"context_diagnosis_id": diagnosis_id,
|
||||||
@@ -2171,12 +2229,12 @@ class DiagnosisDialog(QDialog):
|
|||||||
detail = get_value(result, "detail", None)
|
detail = get_value(result, "detail", None)
|
||||||
self._detail = detail
|
self._detail = detail
|
||||||
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
|
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
|
||||||
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {})
|
|
||||||
self._authoritative_detail_loaded = True
|
self._authoritative_detail_loaded = True
|
||||||
self._show_authoritative_content(True)
|
self._show_authoritative_content(True)
|
||||||
self._render(detail, [], [])
|
self._render(detail, [], [])
|
||||||
self._sync_form_interactivity()
|
self._sync_form_interactivity()
|
||||||
self._sync_save_button()
|
self._sync_save_button()
|
||||||
|
self._set_loading(False)
|
||||||
errors = get_value(result, "dictionary_errors", []) or []
|
errors = get_value(result, "dictionary_errors", []) or []
|
||||||
if errors:
|
if errors:
|
||||||
self._show_message(";".join(str(item) for item in errors), "warning")
|
self._show_message(";".join(str(item) for item in errors), "warning")
|
||||||
@@ -2203,7 +2261,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
if generation == self._generation:
|
if generation == self._generation:
|
||||||
self._set_loading(False)
|
self._set_loading(False)
|
||||||
|
|
||||||
def _apply_dictionary_choices(self, dictionaries: Mapping[str, Any]) -> None:
|
def _apply_dictionary_choices(
|
||||||
|
self, dictionaries: Mapping[str, Any], *, preserve: bool = False
|
||||||
|
) -> None:
|
||||||
diagnosis_type_editor = self.edit_fields.get("diagnosis_type")
|
diagnosis_type_editor = self.edit_fields.get("diagnosis_type")
|
||||||
if isinstance(diagnosis_type_editor, DiagnosisComboBox):
|
if isinstance(diagnosis_type_editor, DiagnosisComboBox):
|
||||||
labels = {
|
labels = {
|
||||||
@@ -2220,15 +2280,17 @@ class DiagnosisDialog(QDialog):
|
|||||||
("会诊", "consultation"),
|
("会诊", "consultation"),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
preserve=False,
|
preserve=preserve,
|
||||||
)
|
)
|
||||||
for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items():
|
for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items():
|
||||||
|
if dictionary_type not in dictionaries:
|
||||||
|
continue
|
||||||
editor = self._choice_fields.get(key)
|
editor = self._choice_fields.get(key)
|
||||||
choices = list(dictionaries.get(dictionary_type, []))
|
choices = list(dictionaries.get(dictionary_type, []))
|
||||||
if not _multiple:
|
if not _multiple:
|
||||||
choices.insert(0, ("无", ""))
|
choices.insert(0, ("无", ""))
|
||||||
if editor is not None:
|
if editor is not None:
|
||||||
editor.set_choices(choices, preserve=False)
|
editor.set_choices(choices, preserve=preserve)
|
||||||
# Defer until the drawer has a real width so wrapped chips get height.
|
# Defer until the drawer has a real width so wrapped chips get height.
|
||||||
QTimer.singleShot(0, self._sync_choice_field_heights)
|
QTimer.singleShot(0, self._sync_choice_field_heights)
|
||||||
|
|
||||||
@@ -2307,14 +2369,20 @@ class DiagnosisDialog(QDialog):
|
|||||||
if not self._authoritative_detail_loaded:
|
if not self._authoritative_detail_loaded:
|
||||||
self._retry_detail()
|
self._retry_detail()
|
||||||
return
|
return
|
||||||
|
if (self._dictionary_error_message
|
||||||
|
and self.drawer_banner.label.text() == self._dictionary_error_message):
|
||||||
|
self._ensure_dictionary_loaded(force=True)
|
||||||
|
return
|
||||||
self._ensure_tab_loaded(self._current_tab_key(), force=True)
|
self._ensure_tab_loaded(self._current_tab_key(), force=True)
|
||||||
|
|
||||||
def _invalidate_requests(self) -> None:
|
def _invalidate_requests(self) -> None:
|
||||||
|
self._close_image_preview()
|
||||||
self._stop_watching_local_audio_uploads()
|
self._stop_watching_local_audio_uploads()
|
||||||
self._video_reload_pending = False
|
self._video_reload_pending = False
|
||||||
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
|
||||||
@@ -2627,7 +2695,10 @@ class DiagnosisDialog(QDialog):
|
|||||||
table.set_empty_text(message)
|
table.set_empty_text(message)
|
||||||
|
|
||||||
def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None:
|
def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None:
|
||||||
if key == "basic" or not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
|
if not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
|
||||||
|
return
|
||||||
|
if key == "basic":
|
||||||
|
self._ensure_dictionary_loaded(force=force)
|
||||||
return
|
return
|
||||||
if key == "daily":
|
if key == "daily":
|
||||||
source = next(
|
source = next(
|
||||||
@@ -2639,6 +2710,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 +2806,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":
|
||||||
@@ -3419,6 +3492,27 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._sync_order_offset_actions()
|
self._sync_order_offset_actions()
|
||||||
self._mutation_error(error, diagnosis_id, generation, "offset")
|
self._mutation_error(error, diagnosis_id, generation, "offset")
|
||||||
|
|
||||||
|
def _close_image_preview(self) -> None:
|
||||||
|
preview, self._image_preview = self._image_preview, None
|
||||||
|
if preview is not None:
|
||||||
|
with suppress(RuntimeError):
|
||||||
|
preview.close()
|
||||||
|
|
||||||
|
def _image_preview_finished(self, _result: int) -> None:
|
||||||
|
if self.sender() is self._image_preview:
|
||||||
|
self._image_preview = None
|
||||||
|
|
||||||
|
def _preview_note_images(self, sources: Sequence[str], index: int) -> None:
|
||||||
|
"""Keep the diagnosis open while viewing the clicked note's image group."""
|
||||||
|
if not safe_image_sources(sources):
|
||||||
|
QMessageBox.warning(self, "无法预览", "仅支持 HTTP(S) 服务端图片。")
|
||||||
|
return
|
||||||
|
self._close_image_preview()
|
||||||
|
preview = ImagePreviewDialog(sources, index=index, title="舌象图片预览", parent=self)
|
||||||
|
self._image_preview = preview
|
||||||
|
preview.finished.connect(self._image_preview_finished)
|
||||||
|
preview.open()
|
||||||
|
|
||||||
def _open_safe_resource(self, target: str) -> None:
|
def _open_safe_resource(self, target: str) -> None:
|
||||||
url = QUrl(str(target).strip())
|
url = QUrl(str(target).strip())
|
||||||
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
|
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
|
||||||
@@ -3448,8 +3542,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 +4160,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 +4834,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 ""))
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Compact, presentation-only disclosure for page search and overview regions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QSize, Qt, Signal
|
||||||
|
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||||
|
|
||||||
|
from . import icons
|
||||||
|
|
||||||
|
|
||||||
|
class FilterDisclosure(QObject):
|
||||||
|
"""Keep query values and loading state intact while reclaiming list space.
|
||||||
|
|
||||||
|
Targets should be region containers, not individual permission-controlled
|
||||||
|
controls. Showing a container preserves its children's explicit visibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
expanded_changed = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: QWidget,
|
||||||
|
targets: Sequence[QWidget],
|
||||||
|
*,
|
||||||
|
expanded: bool = False,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._targets = tuple(targets)
|
||||||
|
self._expanded = bool(expanded)
|
||||||
|
self.button = QPushButton(parent)
|
||||||
|
self.button.setObjectName("FilterDisclosureButton")
|
||||||
|
self.button.setCheckable(True)
|
||||||
|
self.button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.button.setFixedHeight(32)
|
||||||
|
self.button.setIconSize(QSize(14, 14))
|
||||||
|
self.button.setStyleSheet("""
|
||||||
|
QPushButton#FilterDisclosureButton {
|
||||||
|
color: #1769E8; background: #FFFFFF; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 6px; padding: 0 11px; min-height: 30px; max-height: 30px;
|
||||||
|
min-width: 92px; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
QPushButton#FilterDisclosureButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
|
||||||
|
QPushButton#FilterDisclosureButton:checked { background: #EAF2FF; border-color: #ADC8F2; }
|
||||||
|
QPushButton#FilterDisclosureButton:focus { border-color: #1769E8; }
|
||||||
|
QPushButton#FilterDisclosureButton:disabled { color: #8B97A8; border-color: #E3E9F1; }
|
||||||
|
""")
|
||||||
|
self.button.toggled.connect(self.set_expanded)
|
||||||
|
self._apply()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expanded(self) -> bool:
|
||||||
|
return self._expanded
|
||||||
|
|
||||||
|
def set_expanded(self, expanded: bool) -> None:
|
||||||
|
expanded = bool(expanded)
|
||||||
|
changed = expanded != self._expanded
|
||||||
|
self._expanded = expanded
|
||||||
|
self._apply()
|
||||||
|
if changed:
|
||||||
|
self.expanded_changed.emit(expanded)
|
||||||
|
|
||||||
|
def _apply(self) -> None:
|
||||||
|
# Keep keyboard focus on a visible control when folding a focused form.
|
||||||
|
focused = QApplication.focusWidget()
|
||||||
|
if not self._expanded and focused is not None and any(
|
||||||
|
target is focused or target.isAncestorOf(focused) for target in self._targets
|
||||||
|
):
|
||||||
|
self.button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
for target in self._targets:
|
||||||
|
target.setVisible(self._expanded)
|
||||||
|
blocked = self.button.blockSignals(True)
|
||||||
|
self.button.setChecked(self._expanded)
|
||||||
|
self.button.blockSignals(blocked)
|
||||||
|
label = "收起筛选" if self._expanded else "展开筛选"
|
||||||
|
self.button.setText(label)
|
||||||
|
self.button.setAccessibleName(label)
|
||||||
|
self.button.setAccessibleDescription("显示或收起检索条件和统计信息;收起保留当前筛选条件")
|
||||||
|
self.button.setToolTip("收起保留当前筛选条件" if self._expanded else "展开检索条件和统计信息,当前筛选条件保持不变")
|
||||||
|
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", "#1769E8", 14))
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
"""Single source of truth for every line icon in the workstation.
|
||||||
|
|
||||||
|
Before this module the application drew its icons from nine independent
|
||||||
|
painters (``ui/shell.py`` had two, ``ui/login.py`` two,
|
||||||
|
``ui/pages/reception.py`` four, and ``ui/pages/prescriptions.py``,
|
||||||
|
``ui/pages/patients.py`` and ``ui/diagnosis_index_widgets.py`` one each). They disagreed on everything that
|
||||||
|
makes an icon set read as one family:
|
||||||
|
|
||||||
|
* seven stroke weights - 1.4, 1.5, 1.55, 1.6, 1.7, 2.0 and ``size / 11.5`` px;
|
||||||
|
* four design grids - geometry authored against 14, 16, 18 and 24 px boxes, so
|
||||||
|
the same glyph asked for at another size came out off-centre or clipped;
|
||||||
|
* mixed fills and strokes inside one row of icons (a stroked ``search`` beside a
|
||||||
|
solid ``down`` triangle);
|
||||||
|
* integer ``QRect`` coordinates in the menu painter, which put a 1.6 px stroke
|
||||||
|
across a pixel boundary and rendered visibly softer than its neighbours;
|
||||||
|
* six near-identical indigos and two near-identical reds picked per call site
|
||||||
|
instead of from the palette.
|
||||||
|
|
||||||
|
Everything here is authored once on a 24-unit grid with a 20-unit optical safe
|
||||||
|
area, stroked with one weight formula, and scaled to the requested size by the
|
||||||
|
painter transform. Glyphs are pure stroke unless a filled counter is part of
|
||||||
|
the mark (a list bullet, the dot on an "i"), which keeps the whole set at a
|
||||||
|
single apparent weight.
|
||||||
|
|
||||||
|
Icons are cached as well. List pages build one icon per action button per row,
|
||||||
|
so the previous code re-ran a ``QPainter`` for every visible row on every
|
||||||
|
refresh; the cache turns that into one paint per (kind, colour, size).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||||
|
from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from .theme import COLORS, crisp_pixmap
|
||||||
|
|
||||||
|
#: Every glyph is drawn inside this box. Nothing is authored against the pixel
|
||||||
|
#: size the caller asks for, which is what keeps a 14 px and a 24 px request
|
||||||
|
#: optically identical instead of merely proportional.
|
||||||
|
GRID = 24.0
|
||||||
|
|
||||||
|
#: Ideal stroke at the reference grid. ``2 / 24`` is the Feather/Lucide ratio;
|
||||||
|
#: the clamp keeps the line from vanishing at 12 px or turning into a slab at
|
||||||
|
#: 36 px, which is the range the shell actually asks for.
|
||||||
|
_STROKE_RATIO = 2.0 / GRID
|
||||||
|
_STROKE_MIN_PX = 1.25
|
||||||
|
_STROKE_MAX_PX = 2.25
|
||||||
|
|
||||||
|
|
||||||
|
def stroke_px(size: float) -> float:
|
||||||
|
"""Return the on-screen stroke width used for an icon of ``size`` px."""
|
||||||
|
|
||||||
|
return max(_STROKE_MIN_PX, min(_STROKE_MAX_PX, size * _STROKE_RATIO))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Semantic colour roles ------------------------------------------------
|
||||||
|
# Call sites name a role instead of a hex value. The seven painters replaced
|
||||||
|
# here between them hardcoded #5265F6, #5761F4, #5469F0, #5E69F6, #5365F5,
|
||||||
|
# #4965F5 and #6675F5 for what was always meant to be one accent.
|
||||||
|
ROLES = {
|
||||||
|
"default": COLORS["muted"],
|
||||||
|
"muted": COLORS["muted"],
|
||||||
|
"soft": COLORS["text_soft"],
|
||||||
|
"strong": COLORS["text"],
|
||||||
|
"accent": COLORS["indigo"],
|
||||||
|
"on_accent": "#FFFFFF",
|
||||||
|
"success": COLORS["success"],
|
||||||
|
"warning": COLORS["warning"],
|
||||||
|
"danger": COLORS["danger"],
|
||||||
|
"info": COLORS["info"],
|
||||||
|
"disabled": COLORS["disabled_text"],
|
||||||
|
"inverse": "#FFFFFF",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_color(color: str) -> str:
|
||||||
|
"""Accept either a semantic role name or a literal colour string."""
|
||||||
|
|
||||||
|
return ROLES.get(color, color)
|
||||||
|
|
||||||
|
|
||||||
|
_GLYPHS: dict[str, Callable[[QPainter, float], None]] = {}
|
||||||
|
|
||||||
|
_Glyph = Callable[[QPainter, float], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _glyph(*names: str) -> Callable[[_Glyph], _Glyph]:
|
||||||
|
def register(fn: _Glyph) -> _Glyph:
|
||||||
|
for name in names:
|
||||||
|
_GLYPHS[name] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return register
|
||||||
|
|
||||||
|
|
||||||
|
def _line(p: QPainter, x1: float, y1: float, x2: float, y2: float) -> None:
|
||||||
|
p.drawLine(QPointF(x1, y1), QPointF(x2, y2))
|
||||||
|
|
||||||
|
|
||||||
|
def _polyline(p: QPainter, *points: tuple[float, float]) -> None:
|
||||||
|
path = QPainterPath(QPointF(*points[0]))
|
||||||
|
for point in points[1:]:
|
||||||
|
path.lineTo(QPointF(*point))
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _circle(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||||
|
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||||
|
|
||||||
|
|
||||||
|
def _dot(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||||
|
"""Filled counter - used only where the mark itself is solid."""
|
||||||
|
|
||||||
|
pen = p.pen()
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(pen.color())
|
||||||
|
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||||
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
p.setPen(pen)
|
||||||
|
|
||||||
|
|
||||||
|
def _page(p: QPainter, *, fold: bool = True) -> None:
|
||||||
|
"""Shared document silhouette so every file-like glyph has one outline."""
|
||||||
|
|
||||||
|
path = QPainterPath(QPointF(14.0, 2.5))
|
||||||
|
path.lineTo(QPointF(6.5, 2.5))
|
||||||
|
path.quadTo(QPointF(5.0, 2.5), QPointF(5.0, 4.0))
|
||||||
|
path.lineTo(QPointF(5.0, 20.0))
|
||||||
|
path.quadTo(QPointF(5.0, 21.5), QPointF(6.5, 21.5))
|
||||||
|
path.lineTo(QPointF(17.5, 21.5))
|
||||||
|
path.quadTo(QPointF(19.0, 21.5), QPointF(19.0, 20.0))
|
||||||
|
path.lineTo(QPointF(19.0, 7.5))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
if fold:
|
||||||
|
_polyline(p, (14.0, 2.5), (14.0, 7.5), (19.0, 7.5))
|
||||||
|
|
||||||
|
|
||||||
|
def _sparkle(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||||
|
"""Four-point concave star - the one AI mark used across the product."""
|
||||||
|
|
||||||
|
path = QPainterPath(QPointF(cx, cy - r))
|
||||||
|
path.quadTo(QPointF(cx, cy), QPointF(cx + r, cy))
|
||||||
|
path.quadTo(QPointF(cx, cy), QPointF(cx, cy + r))
|
||||||
|
path.quadTo(QPointF(cx, cy), QPointF(cx - r, cy))
|
||||||
|
path.quadTo(QPointF(cx, cy), QPointF(cx, cy - r))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _panel(p: QPainter) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 4.0, 19.0, 16.0), 3.0, 3.0)
|
||||||
|
_line(p, 9.5, 4.0, 9.5, 20.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Navigation -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("reception", "workbench", "monitor")
|
||||||
|
def _reception(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 3.5, 19.0, 13.5), 3.0, 3.0)
|
||||||
|
_polyline(p, (6.0, 10.5), (8.8, 10.5), (10.6, 7.5), (13.4, 13.5), (15.2, 10.5), (18.0, 10.5))
|
||||||
|
_line(p, 12.0, 17.0, 12.0, 20.5)
|
||||||
|
_line(p, 8.0, 20.5, 16.0, 20.5)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("appointments", "calendar")
|
||||||
|
def _calendar(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 16.5), 3.0, 3.0)
|
||||||
|
_line(p, 3.0, 10.0, 21.0, 10.0)
|
||||||
|
_line(p, 8.0, 2.75, 8.0, 7.0)
|
||||||
|
_line(p, 16.0, 2.75, 16.0, 7.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("prescription_library", "library", "layers")
|
||||||
|
def _layers(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(12.0, 2.5))
|
||||||
|
path.lineTo(QPointF(21.0, 7.0))
|
||||||
|
path.lineTo(QPointF(12.0, 11.5))
|
||||||
|
path.lineTo(QPointF(3.0, 7.0))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
_polyline(p, (3.0, 12.0), (12.0, 16.5), (21.0, 12.0))
|
||||||
|
_polyline(p, (3.0, 16.75), (12.0, 21.25), (21.0, 16.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("prescriptions", "file_check")
|
||||||
|
def _file_check(p: QPainter, w: float) -> None:
|
||||||
|
_page(p)
|
||||||
|
_polyline(p, (8.5, 15.0), (10.9, 17.4), (15.5, 12.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("patients", "users")
|
||||||
|
def _users(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 9.0, 8.0, 3.5)
|
||||||
|
path = QPainterPath(QPointF(2.5, 20.5))
|
||||||
|
path.quadTo(QPointF(2.5, 14.5), QPointF(9.0, 14.5))
|
||||||
|
path.quadTo(QPointF(15.5, 14.5), QPointF(15.5, 20.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
_circle(p, 17.6, 8.0, 2.8)
|
||||||
|
tail = QPainterPath(QPointF(17.0, 13.9))
|
||||||
|
tail.quadTo(QPointF(21.5, 14.6), QPointF(21.5, 20.5))
|
||||||
|
p.drawPath(tail)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("consultations", "consult", "message", "other")
|
||||||
|
def _message(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(6.5, 3.5))
|
||||||
|
path.lineTo(QPointF(17.5, 3.5))
|
||||||
|
path.quadTo(QPointF(20.5, 3.5), QPointF(20.5, 6.5))
|
||||||
|
path.lineTo(QPointF(20.5, 13.5))
|
||||||
|
path.quadTo(QPointF(20.5, 16.5), QPointF(17.5, 16.5))
|
||||||
|
path.lineTo(QPointF(11.5, 16.5))
|
||||||
|
path.lineTo(QPointF(7.0, 20.5))
|
||||||
|
path.lineTo(QPointF(7.0, 16.5))
|
||||||
|
path.quadTo(QPointF(3.5, 16.5), QPointF(3.5, 13.5))
|
||||||
|
path.lineTo(QPointF(3.5, 6.5))
|
||||||
|
path.quadTo(QPointF(3.5, 3.5), QPointF(6.5, 3.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 7.75, 8.25, 16.25, 8.25)
|
||||||
|
_line(p, 7.75, 11.75, 13.0, 11.75)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Shell chrome ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("fold", "panel_close")
|
||||||
|
def _fold(p: QPainter, w: float) -> None:
|
||||||
|
_panel(p)
|
||||||
|
_polyline(p, (17.0, 9.0), (14.0, 12.0), (17.0, 15.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("expand", "panel_open")
|
||||||
|
def _expand(p: QPainter, w: float) -> None:
|
||||||
|
_panel(p)
|
||||||
|
_polyline(p, (14.0, 9.0), (17.0, 12.0), (14.0, 15.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("search")
|
||||||
|
def _search(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 10.5, 10.5, 6.25)
|
||||||
|
_line(p, 15.15, 15.15, 19.75, 19.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("refresh", "rotate")
|
||||||
|
def _refresh(p: QPainter, w: float) -> None:
|
||||||
|
# The arc terminates exactly on the arrow corner so the mark reads as one
|
||||||
|
# continuous stroke. The painters replaced here left a detached triangle
|
||||||
|
# (shell) or two stray lines that never formed a head at all (prescriptions).
|
||||||
|
radius = math.hypot(8.5, 3.5)
|
||||||
|
start = math.degrees(math.atan2(3.5, 8.5))
|
||||||
|
p.drawArc(
|
||||||
|
QRectF(12.0 - radius, 12.0 - radius, radius * 2, radius * 2),
|
||||||
|
round(start * 16),
|
||||||
|
round((360.0 - start) * 16),
|
||||||
|
)
|
||||||
|
_polyline(p, (20.5, 3.5), (20.5, 8.5), (15.5, 8.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("ai", "spark", "sparkle", "assistant")
|
||||||
|
def _ai(p: QPainter, w: float) -> None:
|
||||||
|
_sparkle(p, 10.2, 11.8, 7.2)
|
||||||
|
_sparkle(p, 18.0, 6.0, 3.2)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("fullscreen", "expand-corners", "maximize")
|
||||||
|
def _fullscreen(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (9.0, 3.5), (3.5, 3.5), (3.5, 9.0))
|
||||||
|
_polyline(p, (15.0, 3.5), (20.5, 3.5), (20.5, 9.0))
|
||||||
|
_polyline(p, (3.5, 15.0), (3.5, 20.5), (9.0, 20.5))
|
||||||
|
_polyline(p, (20.5, 15.0), (20.5, 20.5), (15.0, 20.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("minimize")
|
||||||
|
def _minimize(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 5.0, 12.0, 19.0, 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("close", "cross")
|
||||||
|
def _close(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 5.75, 5.75, 18.25, 18.25)
|
||||||
|
_line(p, 18.25, 5.75, 5.75, 18.25)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("down", "chevron_down")
|
||||||
|
def _down(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (5.5, 9.0), (12.0, 15.5), (18.5, 9.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("up", "chevron_up")
|
||||||
|
def _up(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (5.5, 15.0), (12.0, 8.5), (18.5, 15.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("left", "chevron_left")
|
||||||
|
def _left(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (15.0, 5.5), (8.5, 12.0), (15.0, 18.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("right", "chevron_right")
|
||||||
|
def _right(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (9.0, 5.5), (15.5, 12.0), (9.0, 18.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("notification", "bell")
|
||||||
|
def _bell(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(6.75, 17.5))
|
||||||
|
path.lineTo(QPointF(6.75, 10.75))
|
||||||
|
path.arcTo(QRectF(6.75, 5.0, 10.5, 11.5), 180.0, -180.0)
|
||||||
|
path.lineTo(QPointF(17.25, 17.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 4.5, 17.5, 19.5, 17.5)
|
||||||
|
p.drawArc(QRectF(10.0, 17.4, 4.0, 3.6), 180 * 16, 180 * 16)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("settings", "sliders")
|
||||||
|
def _settings(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 3.5, 8.5, 20.5, 8.5)
|
||||||
|
_line(p, 3.5, 15.5, 20.5, 15.5)
|
||||||
|
_circle(p, 9.0, 8.5, 2.4)
|
||||||
|
_circle(p, 15.0, 15.5, 2.4)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Row and toolbar actions ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("eye", "view")
|
||||||
|
def _eye(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(2.5, 12.0))
|
||||||
|
path.quadTo(QPointF(12.0, 2.5), QPointF(21.5, 12.0))
|
||||||
|
path.quadTo(QPointF(12.0, 21.5), QPointF(2.5, 12.0))
|
||||||
|
p.drawPath(path)
|
||||||
|
_circle(p, 12.0, 12.0, 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("pencil", "edit")
|
||||||
|
def _pencil(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(16.25, 3.0))
|
||||||
|
path.lineTo(QPointF(20.75, 7.5))
|
||||||
|
path.lineTo(QPointF(8.5, 19.75))
|
||||||
|
path.lineTo(QPointF(3.0, 21.0))
|
||||||
|
path.lineTo(QPointF(4.25, 15.5))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 13.0, 6.25, 17.5, 10.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("trash", "delete")
|
||||||
|
def _trash(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 3.5, 6.25, 20.5, 6.25)
|
||||||
|
_polyline(p, (9.0, 6.25), (9.0, 3.5), (15.0, 3.5), (15.0, 6.25))
|
||||||
|
path = QPainterPath(QPointF(5.75, 6.25))
|
||||||
|
path.lineTo(QPointF(6.6, 19.4))
|
||||||
|
path.quadTo(QPointF(6.7, 20.5), QPointF(7.8, 20.5))
|
||||||
|
path.lineTo(QPointF(16.2, 20.5))
|
||||||
|
path.quadTo(QPointF(17.3, 20.5), QPointF(17.4, 19.4))
|
||||||
|
path.lineTo(QPointF(18.25, 6.25))
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 10.0, 10.0, 10.0, 16.75)
|
||||||
|
_line(p, 14.0, 10.0, 14.0, 16.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("plus", "add")
|
||||||
|
def _plus(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 12.0, 4.75, 12.0, 19.25)
|
||||||
|
_line(p, 4.75, 12.0, 19.25, 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("check")
|
||||||
|
def _check(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (4.75, 12.5), (9.75, 17.5), (19.25, 7.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("check_circle", "health")
|
||||||
|
def _check_circle(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_polyline(p, (7.75, 12.25), (10.75, 15.25), (16.25, 9.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("checkbox")
|
||||||
|
def _checkbox(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(3.75, 3.75, 16.5, 16.5), 3.5, 3.5)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("lock")
|
||||||
|
def _lock(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(4.5, 10.5, 15.0, 10.0), 2.75, 2.75)
|
||||||
|
p.drawArc(QRectF(8.0, 3.75, 8.0, 13.5), 0, 180 * 16)
|
||||||
|
_dot(p, 12.0, 15.5, 1.35)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("document", "file")
|
||||||
|
def _document(p: QPainter, w: float) -> None:
|
||||||
|
_page(p)
|
||||||
|
_line(p, 8.5, 12.75, 15.5, 12.75)
|
||||||
|
_line(p, 8.5, 16.75, 13.25, 16.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("report")
|
||||||
|
def _report(p: QPainter, w: float) -> None:
|
||||||
|
_page(p)
|
||||||
|
_line(p, 8.75, 17.75, 8.75, 14.25)
|
||||||
|
_line(p, 12.0, 17.75, 12.0, 10.5)
|
||||||
|
_line(p, 15.25, 17.75, 15.25, 12.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("list")
|
||||||
|
def _list(p: QPainter, w: float) -> None:
|
||||||
|
for y in (6.5, 12.0, 17.5):
|
||||||
|
_dot(p, 4.5, y, 1.2)
|
||||||
|
_line(p, 8.75, y, 19.5, y)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("user", "person")
|
||||||
|
def _user(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 8.0, 4.0)
|
||||||
|
path = QPainterPath(QPointF(4.25, 20.5))
|
||||||
|
path.quadTo(QPointF(4.25, 14.5), QPointF(12.0, 14.5))
|
||||||
|
path.quadTo(QPointF(19.75, 14.5), QPointF(19.75, 20.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("remove", "minus_circle")
|
||||||
|
def _remove(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_line(p, 8.0, 12.0, 16.0, 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("stop", "close_circle")
|
||||||
|
def _stop(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_line(p, 9.0, 9.0, 15.0, 15.0)
|
||||||
|
_line(p, 15.0, 9.0, 9.0, 15.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("info")
|
||||||
|
def _info(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_line(p, 12.0, 11.25, 12.0, 16.5)
|
||||||
|
_dot(p, 12.0, 7.75, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("picture", "image")
|
||||||
|
def _picture(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(3.0, 4.5, 18.0, 15.0), 3.0, 3.0)
|
||||||
|
_circle(p, 7.75, 8.75, 1.6)
|
||||||
|
_polyline(p, (3.5, 17.75), (9.75, 12.25), (13.25, 15.5), (15.75, 13.25), (20.5, 17.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("meds", "pill")
|
||||||
|
def _meds(p: QPainter, w: float) -> None:
|
||||||
|
p.save()
|
||||||
|
p.translate(12.0, 12.0)
|
||||||
|
p.rotate(-45.0)
|
||||||
|
p.drawRoundedRect(QRectF(-9.25, -4.5, 18.5, 9.0), 4.5, 4.5)
|
||||||
|
_line(p, 0.0, -4.5, 0.0, 4.5)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("daily", "clipboard")
|
||||||
|
def _clipboard(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(8.5, 4.5))
|
||||||
|
path.lineTo(QPointF(6.75, 4.5))
|
||||||
|
path.quadTo(QPointF(4.25, 4.5), QPointF(4.25, 7.0))
|
||||||
|
path.lineTo(QPointF(4.25, 19.0))
|
||||||
|
path.quadTo(QPointF(4.25, 21.5), QPointF(6.75, 21.5))
|
||||||
|
path.lineTo(QPointF(17.25, 21.5))
|
||||||
|
path.quadTo(QPointF(19.75, 21.5), QPointF(19.75, 19.0))
|
||||||
|
path.lineTo(QPointF(19.75, 7.0))
|
||||||
|
path.quadTo(QPointF(19.75, 4.5), QPointF(17.25, 4.5))
|
||||||
|
path.lineTo(QPointF(15.5, 4.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 4.0), 1.5, 1.5)
|
||||||
|
_line(p, 8.0, 12.0, 16.0, 12.0)
|
||||||
|
_line(p, 8.0, 16.25, 13.5, 16.25)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("followup", "calendar_clock")
|
||||||
|
def _followup(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(13.0, 20.0))
|
||||||
|
path.lineTo(QPointF(5.5, 20.0))
|
||||||
|
path.quadTo(QPointF(3.0, 20.0), QPointF(3.0, 17.5))
|
||||||
|
path.lineTo(QPointF(3.0, 7.5))
|
||||||
|
path.quadTo(QPointF(3.0, 5.0), QPointF(5.5, 5.0))
|
||||||
|
path.lineTo(QPointF(14.5, 5.0))
|
||||||
|
path.quadTo(QPointF(17.0, 5.0), QPointF(17.0, 7.5))
|
||||||
|
path.lineTo(QPointF(17.0, 9.0))
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 3.0, 9.5, 17.0, 9.5)
|
||||||
|
_line(p, 7.0, 2.75, 7.0, 6.75)
|
||||||
|
_line(p, 13.0, 2.75, 13.0, 6.75)
|
||||||
|
_circle(p, 16.75, 16.75, 4.5)
|
||||||
|
_polyline(p, (16.75, 14.25), (16.75, 16.75), (18.9, 16.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("brand", "logo")
|
||||||
|
def _brand(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_polyline(p, (7.0, 12.0), (10.0, 12.0), (11.5, 8.5), (13.5, 15.5), (15.0, 12.0), (17.0, 12.0))
|
||||||
|
|
||||||
|
|
||||||
|
# --- AI consultation ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("chart", "analytics")
|
||||||
|
def _analytics(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (3.5, 3.0), (3.5, 20.5), (21.0, 20.5))
|
||||||
|
_polyline(p, (7.0, 16.5), (10.5, 8.5), (14.0, 13.0), (20.0, 5.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("trend")
|
||||||
|
def _trend(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (3.0, 18.0), (8.5, 9.5), (12.5, 13.5), (21.0, 5.5))
|
||||||
|
_polyline(p, (15.5, 5.5), (21.0, 5.5), (21.0, 11.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("alert", "warning")
|
||||||
|
def _alert(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(12.0, 3.0))
|
||||||
|
path.lineTo(QPointF(21.5, 20.0))
|
||||||
|
path.lineTo(QPointF(2.5, 20.0))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 12.0, 9.5, 12.0, 14.5)
|
||||||
|
_dot(p, 12.0, 17.4, 1.05)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("mic", "microphone")
|
||||||
|
def _mic(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 12.0), 3.5, 3.5)
|
||||||
|
p.drawArc(QRectF(5.0, 6.0, 14.0, 14.0), 0, -180 * 16)
|
||||||
|
_line(p, 12.0, 17.5, 12.0, 21.0)
|
||||||
|
_line(p, 8.25, 21.0, 15.75, 21.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("send")
|
||||||
|
def _send(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(21.0, 3.0))
|
||||||
|
path.lineTo(QPointF(2.5, 10.5))
|
||||||
|
path.lineTo(QPointF(10.25, 13.75))
|
||||||
|
path.lineTo(QPointF(13.5, 21.5))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 10.25, 13.75, 21.0, 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("qr", "qrcode")
|
||||||
|
def _qr(p: QPainter, w: float) -> None:
|
||||||
|
"""Three finder squares plus a few modules - the shape people scan for."""
|
||||||
|
|
||||||
|
for x, y in ((3.0, 3.0), (14.0, 3.0), (3.0, 14.0)):
|
||||||
|
p.drawRoundedRect(QRectF(x, y, 7.0, 7.0), 1.5, 1.5)
|
||||||
|
_dot(p, x + 3.5, y + 3.5, 1.15)
|
||||||
|
_line(p, 14.5, 14.5, 14.5, 17.0)
|
||||||
|
_line(p, 18.0, 14.5, 21.0, 14.5)
|
||||||
|
_line(p, 17.5, 18.0, 17.5, 21.0)
|
||||||
|
_dot(p, 20.75, 20.75, 1.15)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("video", "call")
|
||||||
|
def _video(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 6.0, 13.5, 12.0), 3.0, 3.0)
|
||||||
|
path = QPainterPath(QPointF(16.0, 10.5))
|
||||||
|
path.lineTo(QPointF(21.5, 7.25))
|
||||||
|
path.lineTo(QPointF(21.5, 16.75))
|
||||||
|
path.lineTo(QPointF(16.0, 13.5))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Clinical measures ----------------------------------------------------
|
||||||
|
# The vital-sign tiles and the lifestyle row used to carry two more bespoke
|
||||||
|
# painters (22 px / 1.35 px stroke and 16 px / 1.3 px stroke). Beyond the extra
|
||||||
|
# weights, two of their glyphs were simply wrong: "weight" read as a padlock and
|
||||||
|
# "BMI" as the Venus symbol.
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("height")
|
||||||
|
def _height(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 6.0, 3.5, 18.0, 3.5)
|
||||||
|
_line(p, 6.0, 20.5, 18.0, 20.5)
|
||||||
|
_line(p, 12.0, 5.75, 12.0, 18.25)
|
||||||
|
_polyline(p, (9.5, 8.25), (12.0, 5.75), (14.5, 8.25))
|
||||||
|
_polyline(p, (9.5, 15.75), (12.0, 18.25), (14.5, 15.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("weight", "scale")
|
||||||
|
def _weight(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 14.5), 3.5, 3.5)
|
||||||
|
p.drawArc(QRectF(7.0, 10.0, 10.0, 10.0), 25 * 16, 130 * 16)
|
||||||
|
_line(p, 12.0, 15.0, 9.9, 10.9)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("bmi", "body")
|
||||||
|
def _bmi(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 5.0, 2.75)
|
||||||
|
_line(p, 12.0, 7.75, 12.0, 15.0)
|
||||||
|
_line(p, 7.25, 11.0, 16.75, 11.0)
|
||||||
|
_polyline(p, (8.5, 20.75), (12.0, 15.0), (15.5, 20.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("blood_pressure", "gauge", "bp")
|
||||||
|
def _blood_pressure(p: QPainter, w: float) -> None:
|
||||||
|
p.drawArc(QRectF(3.0, 5.5, 18.0, 18.0), 0, 180 * 16)
|
||||||
|
_line(p, 3.0, 14.5, 21.0, 14.5)
|
||||||
|
_line(p, 12.0, 14.5, 16.4, 9.4)
|
||||||
|
_dot(p, 12.0, 14.5, 1.15)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("pulse", "heart")
|
||||||
|
def _pulse(p: QPainter, w: float) -> None:
|
||||||
|
heart = QPainterPath(QPointF(12.0, 20.25))
|
||||||
|
heart.cubicTo(QPointF(3.2, 13.6), QPointF(2.2, 9.6), QPointF(4.7, 6.7))
|
||||||
|
heart.cubicTo(QPointF(7.2, 4.0), QPointF(10.5, 4.8), QPointF(12.0, 7.7))
|
||||||
|
heart.cubicTo(QPointF(13.5, 4.8), QPointF(16.8, 4.0), QPointF(19.3, 6.7))
|
||||||
|
heart.cubicTo(QPointF(21.8, 9.6), QPointF(20.8, 13.6), QPointF(12.0, 20.25))
|
||||||
|
heart.closeSubpath()
|
||||||
|
p.drawPath(heart)
|
||||||
|
_polyline(
|
||||||
|
p, (5.6, 12.4), (9.0, 12.4), (10.6, 9.7), (13.2, 15.1), (14.7, 12.4), (18.4, 12.4)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("smoke", "cigarette")
|
||||||
|
def _smoke(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 14.0, 14.0, 5.0), 1.75, 1.75)
|
||||||
|
_line(p, 13.0, 14.0, 13.0, 19.0)
|
||||||
|
curl = QPainterPath(QPointF(19.0, 12.0))
|
||||||
|
curl.quadTo(QPointF(21.5, 9.5), QPointF(19.0, 7.5))
|
||||||
|
curl.quadTo(QPointF(16.5, 5.5), QPointF(19.0, 3.5))
|
||||||
|
p.drawPath(curl)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("drink", "glass")
|
||||||
|
def _drink(p: QPainter, w: float) -> None:
|
||||||
|
bowl = QPainterPath(QPointF(6.5, 3.5))
|
||||||
|
bowl.lineTo(QPointF(17.5, 3.5))
|
||||||
|
bowl.lineTo(QPointF(13.75, 12.5))
|
||||||
|
bowl.lineTo(QPointF(10.25, 12.5))
|
||||||
|
bowl.closeSubpath()
|
||||||
|
p.drawPath(bowl)
|
||||||
|
_line(p, 7.75, 7.5, 16.25, 7.5)
|
||||||
|
_line(p, 12.0, 12.5, 12.0, 20.0)
|
||||||
|
_line(p, 8.0, 20.0, 16.0, 20.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("exercise", "run")
|
||||||
|
def _exercise(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 15.75, 4.75, 2.5)
|
||||||
|
_polyline(p, (14.5, 9.0), (9.75, 12.5), (6.0, 20.5))
|
||||||
|
_polyline(p, (14.5, 9.0), (18.75, 12.75), (21.0, 10.75))
|
||||||
|
_polyline(p, (11.75, 11.0), (15.25, 16.0), (13.25, 20.75))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Painting -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _device_ratio() -> float:
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return 1.0
|
||||||
|
screen = app.primaryScreen()
|
||||||
|
if screen is None:
|
||||||
|
return 1.0
|
||||||
|
return max(1.0, float(screen.devicePixelRatio()))
|
||||||
|
|
||||||
|
|
||||||
|
def _render(kind: str, color: str, size: int) -> QPixmap:
|
||||||
|
canvas = crisp_pixmap(size)
|
||||||
|
draw = _GLYPHS.get(kind)
|
||||||
|
if draw is None:
|
||||||
|
return canvas
|
||||||
|
painter = QPainter(canvas)
|
||||||
|
try:
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
# Inset the grid by half a stroke on every edge. Without it a glyph that
|
||||||
|
# legitimately reaches grid unit 24 loses the outer half of its line to
|
||||||
|
# the pixmap boundary at the smaller sizes - which is exactly how the old
|
||||||
|
# painters lost the shell star's companion dot and flattened the top of
|
||||||
|
# the calendar. Insetting here means every glyph can use the full grid.
|
||||||
|
weight = stroke_px(size)
|
||||||
|
scale = (size - weight) / GRID
|
||||||
|
painter.translate(weight / 2.0, weight / 2.0)
|
||||||
|
painter.scale(scale, scale)
|
||||||
|
# The pen width is expressed on the design grid, so the on-screen weight
|
||||||
|
# stays the same fraction of the box at every size the shell asks for.
|
||||||
|
width = weight / scale
|
||||||
|
pen = QPen(QColor(color), width)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
draw(painter, width)
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1024)
|
||||||
|
def _cached_pixmap(kind: str, color: str, size: int, ratio: float) -> QPixmap:
|
||||||
|
del ratio # part of the cache key only; crisp_pixmap reads it back itself
|
||||||
|
return _render(kind, color, size)
|
||||||
|
|
||||||
|
|
||||||
|
def pixmap(kind: str, color: str = "default", size: int = 18) -> QPixmap:
|
||||||
|
"""Return a cached, device-pixel-correct pixmap for ``kind``."""
|
||||||
|
|
||||||
|
return _cached_pixmap(kind, resolve_color(color), int(size), _device_ratio())
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1024)
|
||||||
|
def _cached_icon(kind: str, color: str, size: int, ratio: float) -> QIcon:
|
||||||
|
result = QIcon(_cached_pixmap(kind, color, size, ratio))
|
||||||
|
result.addPixmap(
|
||||||
|
_cached_pixmap(kind, ROLES["disabled"], size, ratio),
|
||||||
|
QIcon.Mode.Disabled,
|
||||||
|
QIcon.State.Off,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def icon(kind: str, color: str = "default", size: int = 18) -> QIcon:
|
||||||
|
"""Return a cached icon with a matching disabled variant already attached.
|
||||||
|
|
||||||
|
``color`` accepts a role name from :data:`ROLES` or a literal colour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _cached_icon(kind, resolve_color(color), int(size), _device_ratio())
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def _cached_state_icon(
|
||||||
|
kind: str,
|
||||||
|
size: int,
|
||||||
|
normal: str,
|
||||||
|
active: str,
|
||||||
|
checked: str,
|
||||||
|
disabled: str,
|
||||||
|
ratio: float,
|
||||||
|
) -> QIcon:
|
||||||
|
result = QIcon()
|
||||||
|
result.addPixmap(_cached_pixmap(kind, normal, size, ratio), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
|
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Normal, QIcon.State.On)
|
||||||
|
result.addPixmap(_cached_pixmap(kind, active, size, ratio), QIcon.Mode.Active, QIcon.State.Off)
|
||||||
|
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Active, QIcon.State.On)
|
||||||
|
result.addPixmap(
|
||||||
|
_cached_pixmap(kind, disabled, size, ratio), QIcon.Mode.Disabled, QIcon.State.Off
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def state_icon(
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
size: int = 18,
|
||||||
|
normal: str = "muted",
|
||||||
|
active: str = "strong",
|
||||||
|
checked: str = "inverse",
|
||||||
|
disabled: str = "disabled",
|
||||||
|
) -> QIcon:
|
||||||
|
"""Return an icon carrying its own hover / selected / disabled colours.
|
||||||
|
|
||||||
|
Qt only tints an icon when a widget asks it to, so a single-pixmap icon on a
|
||||||
|
selected navigation row keeps its resting grey and reads as switched off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _cached_state_icon(
|
||||||
|
kind,
|
||||||
|
int(size),
|
||||||
|
resolve_color(normal),
|
||||||
|
resolve_color(active),
|
||||||
|
resolve_color(checked),
|
||||||
|
resolve_color(disabled),
|
||||||
|
_device_ratio(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def available_kinds() -> tuple[str, ...]:
|
||||||
|
"""Every glyph name this module answers to, aliases included."""
|
||||||
|
|
||||||
|
return tuple(sorted(_GLYPHS))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache() -> None:
|
||||||
|
"""Drop cached pixmaps - used when the display scale factor changes."""
|
||||||
|
|
||||||
|
_cached_pixmap.cache_clear()
|
||||||
|
_cached_icon.cache_clear()
|
||||||
|
_cached_state_icon.cache_clear()
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""Incremental server lists with a compact status footer and stable view state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QEvent, QSignalBlocker, Qt, QTimer
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractScrollArea,
|
||||||
|
QCheckBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .widgets import first_value, get_value, page_items, page_total, run_async
|
||||||
|
|
||||||
|
|
||||||
|
def record_key(row: Any) -> str:
|
||||||
|
value = first_value(
|
||||||
|
row, "id", "prescription_id", "appointment_id", "diagnosis_id", "order_id", "patient_id"
|
||||||
|
)
|
||||||
|
return str(value) if value is not None else repr(row)
|
||||||
|
|
||||||
|
|
||||||
|
class ListSnapshot:
|
||||||
|
"""Keep repository metadata available while replacing only the list payload."""
|
||||||
|
|
||||||
|
def __init__(self, rows: list[Any], total: int, source: Any) -> None:
|
||||||
|
self.items = rows
|
||||||
|
self.total = total
|
||||||
|
self.source = source
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
return get_value(self.source, name, None)
|
||||||
|
|
||||||
|
|
||||||
|
class InfiniteList(QWidget):
|
||||||
|
"""Bind to a scrolling view; fetch pages only as the visible list needs them.
|
||||||
|
|
||||||
|
Reloads use captured query arguments. Refreshing the same query rebuilds the
|
||||||
|
loaded prefix atomically, so polling neither drops appended rows nor mixes
|
||||||
|
an updated first page with an old tail. Failed appends retain the prior page
|
||||||
|
and can be retried explicitly without an automatic request loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("InfiniteList")
|
||||||
|
self.setFixedHeight(24)
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(12, 0, 12, 0)
|
||||||
|
self.summary_label = QLabel("", self)
|
||||||
|
self.summary_label.setStyleSheet(
|
||||||
|
"color: #5D6B80; font-size: 12px; background: transparent;"
|
||||||
|
)
|
||||||
|
layout.addWidget(self.summary_label)
|
||||||
|
layout.addStretch(1)
|
||||||
|
self.retry_button = QPushButton("加载失败,点击重试", self)
|
||||||
|
self.retry_button.setFlat(True)
|
||||||
|
self.retry_button.setStyleSheet(
|
||||||
|
"color: #1769E8; font-size: 12px; padding: 0 4px; border: none; background: transparent; min-height: 20px; max-height: 20px; min-width: 0;"
|
||||||
|
)
|
||||||
|
self.retry_button.hide()
|
||||||
|
self.retry_button.clicked.connect(self.retry)
|
||||||
|
layout.addWidget(self.retry_button)
|
||||||
|
self.page_size = page_size
|
||||||
|
self.page = 0
|
||||||
|
self.total = 0
|
||||||
|
self.rows: list[Any] = []
|
||||||
|
self.loading = False
|
||||||
|
self.has_more = False
|
||||||
|
self._generation = 0
|
||||||
|
self._query_key: Any = object()
|
||||||
|
self._view: QAbstractScrollArea | None = None
|
||||||
|
self._views: list[QAbstractScrollArea] = []
|
||||||
|
self._error = False
|
||||||
|
self._configured = False
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setSingleShot(True)
|
||||||
|
self._timer.timeout.connect(self._maybe_load_more)
|
||||||
|
|
||||||
|
def bind(self, view: QAbstractScrollArea) -> None:
|
||||||
|
if view in self._views:
|
||||||
|
return
|
||||||
|
self._views.append(view)
|
||||||
|
self._view = view
|
||||||
|
view.verticalScrollBar().valueChanged.connect(self._schedule_check)
|
||||||
|
view.verticalScrollBar().rangeChanged.connect(self._schedule_check)
|
||||||
|
view.viewport().installEventFilter(self)
|
||||||
|
|
||||||
|
def eventFilter(self, watched: Any, event: Any) -> bool:
|
||||||
|
if event.type() in (QEvent.Type.Show, QEvent.Type.Resize):
|
||||||
|
self._schedule_check()
|
||||||
|
return super().eventFilter(watched, event)
|
||||||
|
|
||||||
|
def _schedule_check(self, *_: Any) -> None:
|
||||||
|
self._timer.start(30)
|
||||||
|
|
||||||
|
def _maybe_load_more(self) -> None:
|
||||||
|
view = next((v for v in self._views if v.isVisible()), None)
|
||||||
|
if view is None or self.loading or self._error or not self.has_more:
|
||||||
|
return
|
||||||
|
bar = view.verticalScrollBar()
|
||||||
|
# pageStep respects both per-item and per-pixel Qt scrolling modes.
|
||||||
|
if bar.maximum() - bar.value() <= max(1, bar.pageStep() // 4):
|
||||||
|
self.load_more()
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
"""Disarm callbacks when a reusable dialog switches to another record."""
|
||||||
|
self._generation += 1
|
||||||
|
self._configured = False
|
||||||
|
self.loading = self.has_more = self._error = False
|
||||||
|
self.rows, self.page, self.total = [], 0, 0
|
||||||
|
self._timer.stop()
|
||||||
|
self.retry_button.hide()
|
||||||
|
self._status()
|
||||||
|
|
||||||
|
reset = invalidate
|
||||||
|
|
||||||
|
def reload(
|
||||||
|
self,
|
||||||
|
fetch: Callable[[int], Any],
|
||||||
|
apply: Callable[[Any], None],
|
||||||
|
on_error: Callable[[Exception], None],
|
||||||
|
*,
|
||||||
|
runner: Callable[..., Any] = run_async,
|
||||||
|
query_key: Any = None,
|
||||||
|
on_finished: Callable[[], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
same_query = self._configured and query_key == self._query_key
|
||||||
|
if same_query and self.loading:
|
||||||
|
# Polling must not restart a slow prefix refresh indefinitely. The
|
||||||
|
# caller may have advanced its own generation, so use its latest
|
||||||
|
# render/error closures while the captured request finishes.
|
||||||
|
self._apply, self._on_error = apply, on_error
|
||||||
|
self._on_finished = on_finished
|
||||||
|
return
|
||||||
|
self._generation += 1
|
||||||
|
self._query_key = query_key
|
||||||
|
self._configured = True
|
||||||
|
self._fetch, self._apply, self._on_error = fetch, apply, on_error
|
||||||
|
self._runner, self._on_finished = runner, on_finished
|
||||||
|
self._target = max(1, self.page) if same_query else 1
|
||||||
|
self._reset_view = not same_query
|
||||||
|
if not same_query:
|
||||||
|
self.rows, self.page, self.total = [], 0, 0
|
||||||
|
self.has_more = False
|
||||||
|
self._render(ListSnapshot([], 0, None), preserve=False)
|
||||||
|
self._begin(1, [], refresh=True)
|
||||||
|
|
||||||
|
def load_more(self) -> None:
|
||||||
|
if not self._configured or self.loading or self._error or not self.has_more:
|
||||||
|
return
|
||||||
|
self._reset_view = False
|
||||||
|
self._target = self.page + 1
|
||||||
|
self._begin(self.page + 1, list(self.rows), refresh=False)
|
||||||
|
|
||||||
|
def retry(self) -> None:
|
||||||
|
if self.loading or not self._error:
|
||||||
|
return
|
||||||
|
self._begin(self._failed_page, list(self._failed_rows), refresh=self._failed_refresh)
|
||||||
|
|
||||||
|
def _begin(self, page: int, rows: list[Any], *, refresh: bool) -> None:
|
||||||
|
self.loading = True
|
||||||
|
self._error = False
|
||||||
|
self.retry_button.hide()
|
||||||
|
self.summary_label.setText(
|
||||||
|
f"已加载 {len(self.rows)} 条 · 正在加载…" if self.rows else "正在加载…"
|
||||||
|
)
|
||||||
|
generation = self._generation
|
||||||
|
fetch = self._fetch
|
||||||
|
self._runner(
|
||||||
|
lambda: fetch(page),
|
||||||
|
on_success=lambda result: self._received(result, generation, page, rows, refresh),
|
||||||
|
on_error=lambda error: self._failed(error, generation, page, rows, refresh),
|
||||||
|
on_finished=lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _received(
|
||||||
|
self, result: Any, generation: int, page: int, prior: list[Any], refresh: bool
|
||||||
|
) -> None:
|
||||||
|
if generation != self._generation:
|
||||||
|
return
|
||||||
|
incoming = page_items(result)
|
||||||
|
merged = {record_key(row): row for row in prior}
|
||||||
|
before = len(merged)
|
||||||
|
for row in incoming:
|
||||||
|
merged[record_key(row)] = row
|
||||||
|
rows = list(merged.values())
|
||||||
|
total = page_total(result, -1)
|
||||||
|
more = (
|
||||||
|
bool(incoming)
|
||||||
|
and len(rows) > before
|
||||||
|
and (len(rows) < total if total >= 0 else len(incoming) >= self.page_size)
|
||||||
|
)
|
||||||
|
# Retain first-page metadata (scope, counts, filter choices) on refresh.
|
||||||
|
if page == 1:
|
||||||
|
self._refresh_source = result
|
||||||
|
source = self._refresh_source
|
||||||
|
if refresh and page < self._target and more:
|
||||||
|
self._begin(page + 1, rows, refresh=True)
|
||||||
|
return
|
||||||
|
self.rows, self.page = rows, page
|
||||||
|
self.total = max(len(rows), total)
|
||||||
|
self.has_more = more
|
||||||
|
self._render(ListSnapshot(rows, self.total, source), preserve=not self._reset_view)
|
||||||
|
self.loading = False
|
||||||
|
self._status()
|
||||||
|
if self._on_finished is not None:
|
||||||
|
self._on_finished()
|
||||||
|
self._schedule_check()
|
||||||
|
|
||||||
|
def _failed(
|
||||||
|
self, error: Exception, generation: int, page: int, rows: list[Any], refresh: bool
|
||||||
|
) -> None:
|
||||||
|
if generation != self._generation:
|
||||||
|
return
|
||||||
|
self.loading = False
|
||||||
|
self._error = True
|
||||||
|
self._failed_page, self._failed_rows, self._failed_refresh = page, rows, refresh
|
||||||
|
self.summary_label.setText(f"已加载 {len(self.rows)} 条" if self.rows else "暂未加载数据")
|
||||||
|
self.retry_button.show()
|
||||||
|
self._on_error(error)
|
||||||
|
if self._on_finished is not None:
|
||||||
|
self._on_finished()
|
||||||
|
|
||||||
|
def _status(self) -> None:
|
||||||
|
if self.has_more:
|
||||||
|
self.summary_label.setText(f"已加载 {len(self.rows)} / {self.total} 条 · 下拉加载更多")
|
||||||
|
else:
|
||||||
|
if self.total > len(self.rows):
|
||||||
|
self.summary_label.setText(
|
||||||
|
f"已加载 {len(self.rows)} / {self.total} 条 · 暂无更多数据"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.summary_label.setText(
|
||||||
|
f"共 {len(self.rows)} 条 · 已全部加载" if self.rows else "暂无数据"
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_state(self, page: int, total: int) -> None:
|
||||||
|
"""Compatibility for existing render callbacks; requests own the state."""
|
||||||
|
del page, total
|
||||||
|
self._status()
|
||||||
|
|
||||||
|
def _render(self, snapshot: ListSnapshot, *, preserve: bool) -> None:
|
||||||
|
view = next((v for v in self._views if v.isVisible()), self._view)
|
||||||
|
if view is None:
|
||||||
|
self._apply(snapshot)
|
||||||
|
return
|
||||||
|
bar = view.verticalScrollBar()
|
||||||
|
scroll = bar.value()
|
||||||
|
horizontal_scroll = view.horizontalScrollBar().value()
|
||||||
|
selected: set[str] = set()
|
||||||
|
checks: dict[tuple[str, int], Qt.CheckState] = {}
|
||||||
|
widget_checks: dict[tuple[str, int, int], bool] = {}
|
||||||
|
if preserve and isinstance(view, QTableWidget):
|
||||||
|
for row in range(view.rowCount()):
|
||||||
|
first = view.item(row, 0)
|
||||||
|
if first is None:
|
||||||
|
continue
|
||||||
|
key = record_key(first.data(Qt.ItemDataRole.UserRole))
|
||||||
|
if first.isSelected():
|
||||||
|
selected.add(key)
|
||||||
|
for column in range(view.columnCount()):
|
||||||
|
item = view.item(row, column)
|
||||||
|
if (
|
||||||
|
item is not None
|
||||||
|
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable
|
||||||
|
and item.data(Qt.ItemDataRole.CheckStateRole) is not None
|
||||||
|
):
|
||||||
|
checks[key, column] = item.checkState()
|
||||||
|
widget = view.cellWidget(row, column)
|
||||||
|
if widget is not None:
|
||||||
|
boxes = (
|
||||||
|
[widget]
|
||||||
|
if isinstance(widget, QCheckBox)
|
||||||
|
else widget.findChildren(QCheckBox)
|
||||||
|
)
|
||||||
|
for index, box in enumerate(boxes):
|
||||||
|
widget_checks[key, column, index] = box.isChecked()
|
||||||
|
blocker = QSignalBlocker(view)
|
||||||
|
try:
|
||||||
|
self._apply(snapshot)
|
||||||
|
if preserve and isinstance(view, QTableWidget):
|
||||||
|
if selected:
|
||||||
|
view.clearSelection()
|
||||||
|
for row in range(view.rowCount()):
|
||||||
|
first = view.item(row, 0)
|
||||||
|
if first is None:
|
||||||
|
continue
|
||||||
|
key = record_key(first.data(Qt.ItemDataRole.UserRole))
|
||||||
|
if key in selected:
|
||||||
|
view.selectRow(row)
|
||||||
|
for column in range(view.columnCount()):
|
||||||
|
item = view.item(row, column)
|
||||||
|
if item is not None and (key, column) in checks:
|
||||||
|
item.setCheckState(checks[key, column])
|
||||||
|
widget = view.cellWidget(row, column)
|
||||||
|
if widget is not None:
|
||||||
|
boxes = (
|
||||||
|
[widget]
|
||||||
|
if isinstance(widget, QCheckBox)
|
||||||
|
else widget.findChildren(QCheckBox)
|
||||||
|
)
|
||||||
|
for index, box in enumerate(boxes):
|
||||||
|
if (key, column, index) in widget_checks:
|
||||||
|
box.setChecked(widget_checks[key, column, index])
|
||||||
|
bar.setValue(min(scroll, bar.maximum()) if preserve else bar.minimum())
|
||||||
|
view.horizontalScrollBar().setValue(horizontal_scroll)
|
||||||
|
finally:
|
||||||
|
blocker.unblock()
|
||||||
|
if isinstance(view, QTableWidget):
|
||||||
|
view.itemSelectionChanged.emit()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["InfiniteList", "ListSnapshot"]
|
||||||
@@ -21,6 +21,7 @@ from PySide6.QtGui import (
|
|||||||
QResizeEvent,
|
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", "")
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Motion tokens and helpers.
|
||||||
|
|
||||||
|
The product had two `QGraphicsOpacityEffect` uses and no `QPropertyAnimation`
|
||||||
|
at all, so every state change was an instant cut: pages replaced each other
|
||||||
|
between one frame and the next, drawers appeared fully formed, toasts blinked
|
||||||
|
in and out. Nothing was slow - it just gave the eye no continuity to follow,
|
||||||
|
which is what reads as "not smooth" however fast the code underneath is.
|
||||||
|
|
||||||
|
Everything here is short. A workstation is used all day, so transitions are
|
||||||
|
tuned to be felt rather than watched: 110-260 ms, ease-out on entry, and travel
|
||||||
|
measured in single-digit pixels. Anything longer starts costing the user time.
|
||||||
|
|
||||||
|
Qt stylesheets have no `transition` property, so this is `QPropertyAnimation`
|
||||||
|
throughout. Two rules keep that safe:
|
||||||
|
|
||||||
|
* an animation must be owned, or PySide garbage-collects it mid-flight and the
|
||||||
|
widget freezes half-faded - :func:`_own` parks it on the target;
|
||||||
|
* a `QGraphicsOpacityEffect` forces the whole widget subtree through an
|
||||||
|
offscreen render path, which would make a table scroll badly for the rest of
|
||||||
|
the session - every fade here removes its effect when it finishes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
QAbstractAnimation,
|
||||||
|
QEasingCurve,
|
||||||
|
QEvent,
|
||||||
|
QObject,
|
||||||
|
QPoint,
|
||||||
|
QPropertyAnimation,
|
||||||
|
Qt,
|
||||||
|
QTimer,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractScrollArea,
|
||||||
|
QGraphicsOpacityEffect,
|
||||||
|
QStackedWidget,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Durations in milliseconds.
|
||||||
|
FAST = 110 # hover-scale feedback, small fades
|
||||||
|
BASE = 170 # the default: page and panel transitions
|
||||||
|
SLOW = 260 # large travel, e.g. a drawer crossing the workspace
|
||||||
|
|
||||||
|
#: Entering elements decelerate; elements that move between two known places
|
||||||
|
#: ease in and out; large travel gets a longer tail so it never looks linear.
|
||||||
|
EASE_ENTER = QEasingCurve.Type.OutCubic
|
||||||
|
EASE_MOVE = QEasingCurve.Type.InOutCubic
|
||||||
|
EASE_TRAVEL = QEasingCurve.Type.OutQuint
|
||||||
|
|
||||||
|
#: How far an entering surface rises, in device-independent pixels. Kept small
|
||||||
|
#: on purpose: a page that slides a long way reads as a slideshow, not an app.
|
||||||
|
RISE = 8
|
||||||
|
|
||||||
|
|
||||||
|
def reduced_motion() -> bool:
|
||||||
|
"""Whether animation should be skipped entirely.
|
||||||
|
|
||||||
|
Off by default under the offscreen platform so widget grabs in tests and in
|
||||||
|
the packaging smoke checks capture a settled frame rather than a frame from
|
||||||
|
the middle of a fade. ``DOCTOR_MOTION=on`` / ``off`` overrides either way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
override = os.getenv("DOCTOR_MOTION", "").strip().lower()
|
||||||
|
if override in {"off", "0", "false", "none", "reduce"}:
|
||||||
|
return True
|
||||||
|
if override in {"on", "1", "true", "full"}:
|
||||||
|
return False
|
||||||
|
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
|
||||||
|
|
||||||
|
|
||||||
|
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
|
||||||
|
"""Park an animation on its target so Python does not collect it early."""
|
||||||
|
|
||||||
|
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
|
||||||
|
previous = running.get(key)
|
||||||
|
if previous is not None:
|
||||||
|
previous.stop()
|
||||||
|
running[key] = animation
|
||||||
|
target._doctor_motion = running
|
||||||
|
return animation
|
||||||
|
|
||||||
|
|
||||||
|
def animate(
|
||||||
|
target: Any,
|
||||||
|
prop: bytes,
|
||||||
|
start: Any,
|
||||||
|
end: Any,
|
||||||
|
*,
|
||||||
|
duration: int = BASE,
|
||||||
|
easing: QEasingCurve.Type = EASE_ENTER,
|
||||||
|
key: str | None = None,
|
||||||
|
owner: QWidget | None = None,
|
||||||
|
on_finished: Callable[[], None] | None = None,
|
||||||
|
) -> QPropertyAnimation | None:
|
||||||
|
"""Animate one Qt property, or apply the end value outright if motion is off.
|
||||||
|
|
||||||
|
``owner`` keeps the animation alive independently of ``target``. Fades
|
||||||
|
animate a ``QGraphicsOpacityEffect`` that is deleted the moment the fade
|
||||||
|
ends, so parenting the animation to the effect would destroy the animation
|
||||||
|
from inside its own ``finished`` emission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if reduced_motion():
|
||||||
|
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
return None
|
||||||
|
animation = QPropertyAnimation(target, prop, owner if owner is not None else target)
|
||||||
|
animation.setDuration(duration)
|
||||||
|
animation.setEasingCurve(easing)
|
||||||
|
animation.setStartValue(start)
|
||||||
|
animation.setEndValue(end)
|
||||||
|
if on_finished is not None:
|
||||||
|
animation.finished.connect(on_finished)
|
||||||
|
_own(owner if owner is not None else target, key or prop.decode(), animation)
|
||||||
|
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
|
||||||
|
return animation
|
||||||
|
|
||||||
|
|
||||||
|
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
|
||||||
|
effect = widget.graphicsEffect()
|
||||||
|
if not isinstance(effect, QGraphicsOpacityEffect):
|
||||||
|
effect = QGraphicsOpacityEffect(widget)
|
||||||
|
widget.setGraphicsEffect(effect)
|
||||||
|
effect.setEnabled(True)
|
||||||
|
return effect
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_effect(widget: QWidget) -> None:
|
||||||
|
"""Detach the opacity effect once a fade is done.
|
||||||
|
|
||||||
|
Leaving it attached keeps the widget on Qt's offscreen composite path, which
|
||||||
|
is exactly the sort of quiet, permanent frame-rate tax this module exists to
|
||||||
|
avoid introducing.
|
||||||
|
|
||||||
|
The detach is deferred by one event-loop turn on purpose. ``finished`` is
|
||||||
|
emitted from inside the animation, and ``setGraphicsEffect(None)`` deletes
|
||||||
|
the old effect immediately - tearing down the object graph underneath a
|
||||||
|
signal that is still being delivered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def detach() -> None:
|
||||||
|
try:
|
||||||
|
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
|
||||||
|
widget.setGraphicsEffect(None)
|
||||||
|
except RuntimeError: # the widget went away while the fade was running
|
||||||
|
pass
|
||||||
|
|
||||||
|
QTimer.singleShot(0, detach)
|
||||||
|
|
||||||
|
|
||||||
|
def fade_in(
|
||||||
|
widget: QWidget,
|
||||||
|
*,
|
||||||
|
duration: int = BASE,
|
||||||
|
start: float = 0.0,
|
||||||
|
easing: QEasingCurve.Type = EASE_ENTER,
|
||||||
|
) -> None:
|
||||||
|
"""Fade a widget up to full opacity, showing it first if needed."""
|
||||||
|
|
||||||
|
if reduced_motion():
|
||||||
|
widget.show()
|
||||||
|
return
|
||||||
|
effect = _opacity_effect(widget)
|
||||||
|
effect.setOpacity(start)
|
||||||
|
widget.show()
|
||||||
|
animate(
|
||||||
|
effect,
|
||||||
|
b"opacity",
|
||||||
|
start,
|
||||||
|
1.0,
|
||||||
|
duration=duration,
|
||||||
|
easing=easing,
|
||||||
|
key="fade",
|
||||||
|
owner=widget,
|
||||||
|
on_finished=lambda: _drop_effect(widget),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fade_out(
|
||||||
|
widget: QWidget,
|
||||||
|
*,
|
||||||
|
duration: int = FAST,
|
||||||
|
hide: bool = True,
|
||||||
|
on_finished: Callable[[], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fade a widget down, optionally hiding it when the fade completes."""
|
||||||
|
|
||||||
|
if reduced_motion():
|
||||||
|
if hide:
|
||||||
|
widget.hide()
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
return
|
||||||
|
effect = _opacity_effect(widget)
|
||||||
|
|
||||||
|
def done() -> None:
|
||||||
|
if hide:
|
||||||
|
widget.hide()
|
||||||
|
_drop_effect(widget)
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
animate(
|
||||||
|
effect,
|
||||||
|
b"opacity",
|
||||||
|
float(effect.opacity()),
|
||||||
|
0.0,
|
||||||
|
duration=duration,
|
||||||
|
easing=EASE_MOVE,
|
||||||
|
key="fade",
|
||||||
|
owner=widget,
|
||||||
|
on_finished=done,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
|
||||||
|
"""Fade a surface in while it settles upward by a few pixels.
|
||||||
|
|
||||||
|
The rise is what makes a swap read as one surface replacing another rather
|
||||||
|
than as a repaint; keeping it under ten pixels stops it becoming a gesture
|
||||||
|
the user has to wait out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if reduced_motion():
|
||||||
|
widget.show()
|
||||||
|
return
|
||||||
|
fade_in(widget, duration=duration)
|
||||||
|
if rise:
|
||||||
|
origin = widget.pos()
|
||||||
|
widget.move(origin + QPoint(0, rise))
|
||||||
|
animate(
|
||||||
|
widget,
|
||||||
|
b"pos",
|
||||||
|
widget.pos(),
|
||||||
|
origin,
|
||||||
|
duration=duration,
|
||||||
|
easing=EASE_ENTER,
|
||||||
|
key="enter",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None:
|
||||||
|
"""Change the current page of a stack with a short cross-fade.
|
||||||
|
|
||||||
|
``QStackedWidget`` swaps pages between two frames with nothing in between,
|
||||||
|
which is the single most-seen transition in this product - it happens on
|
||||||
|
every sidebar click and on every list that toggles to its empty state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if index < 0 or index >= stack.count() or stack.currentIndex() == index:
|
||||||
|
stack.setCurrentIndex(index)
|
||||||
|
return
|
||||||
|
stack.setCurrentIndex(index)
|
||||||
|
page = stack.currentWidget()
|
||||||
|
if page is None or reduced_motion():
|
||||||
|
return
|
||||||
|
enter(page, rise=rise)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Smooth scrolling -----------------------------------------------------
|
||||||
|
|
||||||
|
#: One wheel notch travels this far, and takes this long to get there. Qt's
|
||||||
|
#: default is an instant jump of three lines per notch, which on a long clinical
|
||||||
|
#: record is the single jerkiest thing in the interface.
|
||||||
|
SCROLL_STEP = 120
|
||||||
|
SCROLL_MS = 190
|
||||||
|
|
||||||
|
|
||||||
|
class _SmoothScroller(QObject):
|
||||||
|
"""Animate a scroll area's wheel movement instead of jumping to it."""
|
||||||
|
|
||||||
|
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
|
||||||
|
super().__init__(area)
|
||||||
|
self._bar = (
|
||||||
|
area.verticalScrollBar()
|
||||||
|
if orientation is Qt.Orientation.Vertical
|
||||||
|
else area.horizontalScrollBar()
|
||||||
|
)
|
||||||
|
self._target = self._bar.value()
|
||||||
|
self._animation = QPropertyAnimation(self._bar, b"value", self)
|
||||||
|
self._animation.setEasingCurve(EASE_ENTER)
|
||||||
|
self._animation.setDuration(SCROLL_MS)
|
||||||
|
# Keyboard, programmatic and drag movements must not be fought over: when
|
||||||
|
# nothing is animating, the wheel target follows wherever the bar went.
|
||||||
|
self._bar.valueChanged.connect(self._sync_target)
|
||||||
|
area.viewport().installEventFilter(self)
|
||||||
|
|
||||||
|
def _sync_target(self, value: int) -> None:
|
||||||
|
if self._animation.state() != QAbstractAnimation.State.Running:
|
||||||
|
self._target = value
|
||||||
|
|
||||||
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||||||
|
del watched
|
||||||
|
if event.type() is not QEvent.Type.Wheel or reduced_motion():
|
||||||
|
return False
|
||||||
|
delta = event.angleDelta().y() or event.angleDelta().x()
|
||||||
|
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||||
|
return False
|
||||||
|
lower, upper = self._bar.minimum(), self._bar.maximum()
|
||||||
|
# Re-clamp first: the range can shrink underneath a running animation
|
||||||
|
# when the content behind it reloads, which would otherwise leave the
|
||||||
|
# pending target past the end of the new content.
|
||||||
|
self._target = max(lower, min(upper, self._target))
|
||||||
|
target = self._target - round(delta / 120.0 * SCROLL_STEP)
|
||||||
|
target = max(lower, min(upper, target))
|
||||||
|
# At either end, hand the wheel back so an enclosing scroll area still
|
||||||
|
# gets it - swallowing it there is what makes nested panes feel stuck.
|
||||||
|
if target == self._target:
|
||||||
|
return False
|
||||||
|
self._target = target
|
||||||
|
self._animation.stop()
|
||||||
|
self._animation.setStartValue(self._bar.value())
|
||||||
|
self._animation.setEndValue(target)
|
||||||
|
self._animation.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def install_smooth_scroll(
|
||||||
|
area: QAbstractScrollArea,
|
||||||
|
*,
|
||||||
|
orientation: Qt.Orientation = Qt.Orientation.Vertical,
|
||||||
|
) -> None:
|
||||||
|
"""Give a scroll area eased wheel scrolling."""
|
||||||
|
|
||||||
|
if getattr(area, "_doctor_smooth_scroll", None) is not None:
|
||||||
|
return
|
||||||
|
area._doctor_smooth_scroll = _SmoothScroller(area, orientation=orientation)
|
||||||
|
|
||||||
|
|
||||||
|
def press_feedback(widget: QWidget) -> None:
|
||||||
|
"""Mark a widget so the shared stylesheet can give it a pressed transform.
|
||||||
|
|
||||||
|
Qt has no CSS transitions, so the visual step itself lives in the palette's
|
||||||
|
pressed state; this only tags the widget as one that should get it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
widget.setProperty("motionPress", True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BASE",
|
||||||
|
"EASE_ENTER",
|
||||||
|
"EASE_MOVE",
|
||||||
|
"EASE_TRAVEL",
|
||||||
|
"FAST",
|
||||||
|
"RISE",
|
||||||
|
"SLOW",
|
||||||
|
"animate",
|
||||||
|
"enter",
|
||||||
|
"fade_in",
|
||||||
|
"fade_out",
|
||||||
|
"install_smooth_scroll",
|
||||||
|
"press_feedback",
|
||||||
|
"reduced_motion",
|
||||||
|
"switch_stack",
|
||||||
|
]
|
||||||
@@ -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,11 +10,12 @@ 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 (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QCompleter,
|
||||||
QDateEdit,
|
QDateEdit,
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
@@ -22,7 +23,6 @@ from PySide6.QtWidgets import (
|
|||||||
QFormLayout,
|
QFormLayout,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
@@ -38,11 +38,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 +56,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 +74,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"),
|
||||||
@@ -206,6 +99,31 @@ def _as_int(value: Any, default: int = 0) -> int:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _department_options(rows: Any) -> list[tuple[int, str]]:
|
||||||
|
"""Flatten the server-scoped tree without deriving extra options from profiles."""
|
||||||
|
if not isinstance(rows, (list, tuple)):
|
||||||
|
raise ValueError("部门数据格式异常,请重试")
|
||||||
|
options: list[tuple[int, str]] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
|
||||||
|
def visit(nodes: Sequence[Any], parents: tuple[str, ...] = ()) -> None:
|
||||||
|
for node in nodes:
|
||||||
|
if not isinstance(node, Mapping):
|
||||||
|
continue
|
||||||
|
label = str(node.get("name") or "").strip()
|
||||||
|
department_id = _as_int(node.get("id"))
|
||||||
|
path = (*parents, label) if label else parents
|
||||||
|
if department_id > 0 and label and department_id not in seen:
|
||||||
|
options.append((department_id, " / ".join(path)))
|
||||||
|
seen.add(department_id)
|
||||||
|
children = node.get("children")
|
||||||
|
if isinstance(children, (list, tuple)):
|
||||||
|
visit(children, path)
|
||||||
|
|
||||||
|
visit(rows)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
def _as_bool(value: Any) -> bool:
|
def _as_bool(value: Any) -> bool:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
@@ -829,8 +747,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 +891,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 +900,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 +911,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 +921,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 +948,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
|
||||||
@@ -1026,6 +957,9 @@ class ConsultationsPage(QWidget):
|
|||||||
self._generation = 0
|
self._generation = 0
|
||||||
self._count_generation = 0
|
self._count_generation = 0
|
||||||
self._options_generation = 0
|
self._options_generation = 0
|
||||||
|
self._departments_generation = 0
|
||||||
|
self._departments_loaded = False
|
||||||
|
self._departments_loading = False
|
||||||
self._prescription_generation = 0
|
self._prescription_generation = 0
|
||||||
self._mutation_generation = 0
|
self._mutation_generation = 0
|
||||||
self._menu_generation = 0
|
self._menu_generation = 0
|
||||||
@@ -1076,15 +1010,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 +1031,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 +1056,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 +1097,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 +1140,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 +1148,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 +1187,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(
|
||||||
@@ -1244,13 +1203,19 @@ class ConsultationsPage(QWidget):
|
|||||||
self.confirmed_combo = self.diagnosis_confirmed_combo
|
self.confirmed_combo = self.diagnosis_confirmed_combo
|
||||||
self.department_combo = self._fixed_combo((("全部部门", ""),))
|
self.department_combo = self._fixed_combo((("全部部门", ""),))
|
||||||
self.department_combo.setObjectName("DiagnosisDepartmentFilter")
|
self.department_combo.setObjectName("DiagnosisDepartmentFilter")
|
||||||
department_name = display_text(
|
self.department_combo.setEditable(True)
|
||||||
first_value(current_user, "department_name", "department", "dept_name", default=""),
|
self.department_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||||||
"",
|
self.department_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||||
)
|
self.department_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains)
|
||||||
department_id = first_value(current_user, "department_id", "dept_id", default="")
|
self.department_combo.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||||
if department_name:
|
self.department_combo.lineEdit().setPlaceholderText("输入部门名称搜索")
|
||||||
self.department_combo.addItem(department_name, department_id or department_name)
|
self.department_combo.lineEdit().returnPressed.connect(self._search)
|
||||||
|
self.department_retry_button = QToolButton()
|
||||||
|
self.department_retry_button.setObjectName("DiagnosisDepartmentRetry")
|
||||||
|
self.department_retry_button.setText("重试")
|
||||||
|
self.department_retry_button.setAccessibleName("重新加载部门")
|
||||||
|
self.department_retry_button.clicked.connect(lambda: self._load_departments(force=True))
|
||||||
|
self.department_retry_button.hide()
|
||||||
self.unserved_sort_combo = self._fixed_combo(
|
self.unserved_sort_combo = self._fixed_combo(
|
||||||
(
|
(
|
||||||
("未服务天数默认排序", ""),
|
("未服务天数默认排序", ""),
|
||||||
@@ -1261,26 +1226,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)
|
||||||
|
)
|
||||||
|
department_field = self._filter_field("部门:", self.department_combo)
|
||||||
|
department_field.layout().addWidget(self.department_retry_button)
|
||||||
|
self.secondary_filter_flow.flow.addWidget(department_field)
|
||||||
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 +1345,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 +1360,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 +1380,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 +1417,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 +1468,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 +1524,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 +1537,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 +1588,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 +1710,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 +1852,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)
|
||||||
@@ -1858,6 +1900,15 @@ class ConsultationsPage(QWidget):
|
|||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def _validate_ranges(self) -> bool:
|
def _validate_ranges(self) -> bool:
|
||||||
|
if (
|
||||||
|
self.department_combo.isEnabled()
|
||||||
|
and self.department_combo.currentText().strip()
|
||||||
|
and self.department_combo.currentText() != self.department_combo.itemText(
|
||||||
|
self.department_combo.currentIndex()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self.banner.show_message("请从部门搜索结果中选择部门,或清空部门筛选。", "warning")
|
||||||
|
return False
|
||||||
pairs = (
|
pairs = (
|
||||||
(
|
(
|
||||||
self._date_value(self.latest_appointment_start_date),
|
self._date_value(self.latest_appointment_start_date),
|
||||||
@@ -1906,23 +1957,16 @@ 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(),
|
||||||
"patient_name": self.patient_name_edit.text().strip(),
|
"patient_name": self.patient_name_edit.text().strip(),
|
||||||
"doctor_name": self.doctor_edit.text().strip(),
|
"doctor_name": self.doctor_edit.text().strip(),
|
||||||
"department_id": self._combo_value(self.department_combo),
|
"assistant_dept_id": (
|
||||||
|
self.department_combo.currentData() or ""
|
||||||
|
if self.department_combo.currentText().strip()
|
||||||
|
else ""
|
||||||
|
),
|
||||||
"has_appointment": self._combo_value(self.has_appointment_combo),
|
"has_appointment": self._combo_value(self.has_appointment_combo),
|
||||||
"diagnosis_confirmed": self._combo_value(self.diagnosis_confirmed_combo),
|
"diagnosis_confirmed": self._combo_value(self.diagnosis_confirmed_combo),
|
||||||
"diagnosis_type": self._combo_value(self.diagnosis_type_combo),
|
"diagnosis_type": self._combo_value(self.diagnosis_type_combo),
|
||||||
@@ -1978,7 +2022,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 +2030,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 +2050,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()
|
||||||
@@ -2136,6 +2178,59 @@ class ConsultationsPage(QWidget):
|
|||||||
}
|
}
|
||||||
self._update_quick_buttons()
|
self._update_quick_buttons()
|
||||||
|
|
||||||
|
def _load_departments(self, *, force: bool = False) -> None:
|
||||||
|
if not force and (self._departments_loaded or self._departments_loading):
|
||||||
|
return
|
||||||
|
self._departments_generation += 1
|
||||||
|
generation = self._departments_generation
|
||||||
|
self._departments_loading = True
|
||||||
|
self.department_combo.setEnabled(False)
|
||||||
|
self.department_combo.setToolTip("正在加载可选部门…")
|
||||||
|
self.department_retry_button.hide()
|
||||||
|
|
||||||
|
def load() -> list[tuple[int, str]]:
|
||||||
|
# Call directly: invoke() can discard unsupported kwargs, which would
|
||||||
|
# silently remove the permission scope on an older repository.
|
||||||
|
return _department_options(self.repository.list_departments(apply_data_scope=True))
|
||||||
|
|
||||||
|
run_async(
|
||||||
|
load,
|
||||||
|
on_success=lambda options: self._apply_departments(options, generation),
|
||||||
|
on_error=lambda error: self._departments_error(error, generation),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _apply_departments(self, options: list[tuple[int, str]], generation: int) -> None:
|
||||||
|
if generation != self._departments_generation:
|
||||||
|
return
|
||||||
|
current = self.department_combo.currentData()
|
||||||
|
blocked = self.department_combo.blockSignals(True)
|
||||||
|
self.department_combo.clear()
|
||||||
|
self.department_combo.addItem("全部部门" if options else "暂无可选部门", "")
|
||||||
|
for department_id, label in options:
|
||||||
|
self.department_combo.addItem(label, department_id)
|
||||||
|
self.department_combo.setCurrentIndex(max(0, self.department_combo.findData(current)))
|
||||||
|
self.department_combo.blockSignals(blocked)
|
||||||
|
self._departments_loaded = True
|
||||||
|
self._departments_loading = False
|
||||||
|
self.department_combo.setEnabled(bool(options))
|
||||||
|
self.department_combo.setToolTip(
|
||||||
|
"输入部门名称搜索;选择父级包含下级部门" if options else "当前账号暂无可选部门"
|
||||||
|
)
|
||||||
|
self.department_retry_button.setVisible(not options)
|
||||||
|
|
||||||
|
def _departments_error(self, error: Exception, generation: int) -> None:
|
||||||
|
if generation != self._departments_generation:
|
||||||
|
return
|
||||||
|
self._departments_loading = False
|
||||||
|
self._departments_loaded = False
|
||||||
|
self.department_combo.clear()
|
||||||
|
self.department_combo.addItem("部门加载失败", "")
|
||||||
|
self.department_combo.setEnabled(False)
|
||||||
|
message = f"部门加载失败:{friendly_error(error)}。点击重试。"
|
||||||
|
self.department_combo.setToolTip(message)
|
||||||
|
self.department_retry_button.setToolTip(message)
|
||||||
|
self.department_retry_button.show()
|
||||||
|
|
||||||
def _load_filter_options(self) -> None:
|
def _load_filter_options(self) -> None:
|
||||||
if self._options_loaded:
|
if self._options_loaded:
|
||||||
return
|
return
|
||||||
@@ -2958,6 +3053,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 +3515,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
|
||||||
@@ -3421,6 +3524,7 @@ class ConsultationsPage(QWidget):
|
|||||||
|
|
||||||
def showEvent(self, event: Any) -> None:
|
def showEvent(self, event: Any) -> None:
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
|
self._load_departments()
|
||||||
self._load_filter_options()
|
self._load_filter_options()
|
||||||
if not self.poll_timer.isActive():
|
if not self.poll_timer.isActive():
|
||||||
self.poll_timer.start()
|
self.poll_timer.start()
|
||||||
|
|||||||
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
@@ -0,0 +1,119 @@
|
|||||||
|
"""Scoped surfaces for the approved patient order-management workspace."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family
|
||||||
|
|
||||||
|
|
||||||
|
def patient_orders_stylesheet() -> str:
|
||||||
|
return Template(_ORDERS).substitute(body=body_family())
|
||||||
|
|
||||||
|
|
||||||
|
_ORDERS = """
|
||||||
|
#PatientOrdersWorkspace { background: transparent; color: #273244; }
|
||||||
|
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
|
||||||
|
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
|
||||||
|
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
|
||||||
|
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
|
||||||
|
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderFilterCard,
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderTableCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
|
||||||
|
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
|
||||||
|
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
|
||||||
|
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
|
||||||
|
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
|
||||||
|
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
|
||||||
|
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
|
||||||
|
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
|
||||||
|
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
|
||||||
|
#PatientOrdersWorkspace QDateEdit {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||||
|
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
|
||||||
|
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||||
|
#PatientOrdersWorkspace QLineEdit QToolButton {
|
||||||
|
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
|
||||||
|
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
|
||||||
|
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PatientOrdersWorkspace QComboBox QAbstractItemView {
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||||
|
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
|
||||||
|
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
|
||||||
|
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget {
|
||||||
|
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget QToolButton {
|
||||||
|
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
|
||||||
|
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
|
||||||
|
#PatientOrdersWorkspace QCheckBox::indicator {
|
||||||
|
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||||
|
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
|
||||||
|
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
|
||||||
|
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
|
||||||
|
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
|
||||||
|
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
|
||||||
|
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||||
|
#PatientOrdersWorkspace QTableWidget#OrderTable {
|
||||||
|
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
|
||||||
|
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
|
||||||
|
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
|
||||||
|
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
|
||||||
|
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
|
||||||
|
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; padding: 5px;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
|
||||||
|
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Local typography and surfaces for the approved consultation-progress page."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family
|
||||||
|
|
||||||
|
|
||||||
|
def patient_progress_stylesheet() -> str:
|
||||||
|
return Template(_PROGRESS).substitute(body=body_family())
|
||||||
|
|
||||||
|
|
||||||
|
_PROGRESS = """
|
||||||
|
#PatientProgressWorkspace { background: transparent; color: #273244; }
|
||||||
|
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
|
||||||
|
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
|
||||||
|
#PatientProgressWorkspace QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||||
|
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||||
|
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
|
||||||
|
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressQueueCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
|
||||||
|
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
|
||||||
|
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
|
||||||
|
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
|
||||||
|
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
|
||||||
|
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
|
||||||
|
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
|
||||||
|
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
|
||||||
|
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
|
||||||
|
#PatientProgressWorkspace QHeaderView::section {
|
||||||
|
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
def patients_chrome_stylesheet() -> str:
|
||||||
|
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
def patient_list_stylesheet() -> str:
|
||||||
|
return Template(_LIST).substitute(body=body_family())
|
||||||
|
|
||||||
|
|
||||||
|
_CHROME = """
|
||||||
|
#PatientsPage { background: #F3F7FD; }
|
||||||
|
#PatientsPage QWidget#PageHeader QLabel {
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
|
||||||
|
}
|
||||||
|
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#PatientsPage QPushButton#PatientRefreshButton {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 20px;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
|
||||||
|
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
|
||||||
|
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
|
||||||
|
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
|
||||||
|
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LIST = """
|
||||||
|
#PatientListWorkspace { background: transparent; color: #273244; }
|
||||||
|
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
|
||||||
|
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
|
||||||
|
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
|
||||||
|
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
|
||||||
|
#PatientSearchToolbar QPushButton {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
|
||||||
|
color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QFrame#PatientFilterCard,
|
||||||
|
#PatientListWorkspace QFrame#PatientSummaryStrip,
|
||||||
|
#PatientListWorkspace QFrame#PatientListCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 16px;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
|
||||||
|
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#PatientSearchToolbar QPushButton#PatientSearchButton {
|
||||||
|
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
|
||||||
|
#PatientSearchToolbar QLineEdit {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
|
||||||
|
}
|
||||||
|
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
|
||||||
|
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
|
||||||
|
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
|
||||||
|
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
|
||||||
|
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
|
||||||
|
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
|
||||||
|
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
|
||||||
|
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
|
||||||
|
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
|
||||||
|
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QDateEdit {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||||
|
selection-background-color: #DCEAFF;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||||
|
#PatientListWorkspace QDateEdit:disabled {
|
||||||
|
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
|
||||||
|
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PatientListWorkspace QPushButton[summaryCard="true"] {
|
||||||
|
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
|
||||||
|
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
|
||||||
|
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
|
||||||
|
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
|
||||||
|
#PatientListWorkspace QTableWidget#PatientTable {
|
||||||
|
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||||
|
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
|
||||||
|
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
|
||||||
|
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
|
||||||
|
#PatientListWorkspace QPushButton[rowAction="true"],
|
||||||
|
#PatientListWorkspace QToolButton#RowActionsMore {
|
||||||
|
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
|
||||||
|
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
|
||||||
|
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
|
||||||
|
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
|
||||||
|
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
|
||||||
|
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
|
||||||
|
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
|
||||||
|
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
|
||||||
|
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||||
|
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""Local technology-blue rendering for the approved prescription library."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from string import Template
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QPushButton,
|
||||||
|
QStyle,
|
||||||
|
QStyledItemDelegate,
|
||||||
|
QToolTip,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
from .widgets import SortableTable, first_value
|
||||||
|
|
||||||
|
|
||||||
|
def library_stylesheet() -> str:
|
||||||
|
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
|
||||||
|
# Defer the page helper lookup so importing this module alone does not
|
||||||
|
# recurse through pages.__init__ and the library page's own style import.
|
||||||
|
from .pages.prescriptions import _painted_icon
|
||||||
|
|
||||||
|
result = QIcon()
|
||||||
|
for mode, tint in (
|
||||||
|
(QIcon.Mode.Normal, color),
|
||||||
|
(QIcon.Mode.Active, color),
|
||||||
|
(QIcon.Mode.Selected, color),
|
||||||
|
(QIcon.Mode.Disabled, "#A4ADBA"),
|
||||||
|
):
|
||||||
|
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
|
||||||
|
for state in (QIcon.State.Off, QIcon.State.On):
|
||||||
|
result.addPixmap(pixmap, mode, state)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryComboBox(QComboBox):
|
||||||
|
"""Keep native combo interaction while painting the local caret."""
|
||||||
|
|
||||||
|
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||||
|
super().paintEvent(event)
|
||||||
|
painter = QPainter(self)
|
||||||
|
try:
|
||||||
|
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
|
||||||
|
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
|
||||||
|
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryTable(SortableTable):
|
||||||
|
"""Repaint existing action widgets and restore selection by template ID."""
|
||||||
|
|
||||||
|
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||||
|
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
|
||||||
|
super().set_rows(rows)
|
||||||
|
# The shared table restores an unsorted row index after enabling sort.
|
||||||
|
# Locate the same source object in the finished visual order instead.
|
||||||
|
if selected_id is not None:
|
||||||
|
for row_index in range(self.rowCount()):
|
||||||
|
item = self.item(row_index, 0)
|
||||||
|
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||||
|
row_id = first_value(row, "id", "template_id", default=None)
|
||||||
|
if row_id is not None and str(row_id) == str(selected_id):
|
||||||
|
self.selectRow(row_index)
|
||||||
|
return
|
||||||
|
self.clearSelection()
|
||||||
|
self.setCurrentCell(-1, -1)
|
||||||
|
|
||||||
|
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
|
||||||
|
super().setCellWidget(row, column, widget)
|
||||||
|
if widget is None or column != 9:
|
||||||
|
return
|
||||||
|
widget.setObjectName("PrescriptionLibraryTableCellHost")
|
||||||
|
widget.setAutoFillBackground(False)
|
||||||
|
glyphs = {
|
||||||
|
"查看处方模板": "eye",
|
||||||
|
"AI 解释": "spark",
|
||||||
|
"编辑处方模板": "pencil",
|
||||||
|
"删除处方模板": "trash",
|
||||||
|
}
|
||||||
|
for button in widget.findChildren(QPushButton):
|
||||||
|
if not button.property("rowAction"):
|
||||||
|
continue
|
||||||
|
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
|
||||||
|
if glyph is not None:
|
||||||
|
color = "#BE4657" if button.property("danger") else "#1769E8"
|
||||||
|
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
|
||||||
|
|
||||||
|
|
||||||
|
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
|
||||||
|
font = QFont(heading_family() if medium else body_family())
|
||||||
|
font.setPixelSize(size)
|
||||||
|
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
|
||||||
|
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
|
||||||
|
|
||||||
|
|
||||||
|
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||||
|
"""Wrap between complete herb entries; elide only the displayed lines."""
|
||||||
|
entries = text.split("、")
|
||||||
|
first = entries[0]
|
||||||
|
next_index = 1
|
||||||
|
while next_index < min(2, len(entries)):
|
||||||
|
candidate = first + "、" + entries[next_index]
|
||||||
|
if metrics.horizontalAdvance(candidate) > width:
|
||||||
|
break
|
||||||
|
first = candidate
|
||||||
|
next_index += 1
|
||||||
|
lines = [_elide(first, metrics, width)]
|
||||||
|
if next_index < len(entries):
|
||||||
|
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||||
|
if metrics.horizontalAdvance(text) <= width:
|
||||||
|
return [text]
|
||||||
|
split = 1
|
||||||
|
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
|
||||||
|
split += 1
|
||||||
|
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryItemDelegate(QStyledItemDelegate):
|
||||||
|
"""Paint the ten existing columns without changing display or source roles."""
|
||||||
|
|
||||||
|
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
|
||||||
|
size = super().sizeHint(option, index)
|
||||||
|
size.setHeight(78)
|
||||||
|
return size
|
||||||
|
|
||||||
|
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
|
||||||
|
if event is not None and index.isValid() and index.column() != 9:
|
||||||
|
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
|
||||||
|
if text:
|
||||||
|
QToolTip.showText(event.globalPos(), str(text), view)
|
||||||
|
return True
|
||||||
|
return super().helpEvent(event, view, option, index)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _paint_lines(
|
||||||
|
painter: QPainter,
|
||||||
|
rect: QRectF,
|
||||||
|
lines: list[str],
|
||||||
|
*,
|
||||||
|
secondary_muted: bool = False,
|
||||||
|
) -> None:
|
||||||
|
line_height = 26
|
||||||
|
top = rect.center().y() - len(lines) * line_height / 2
|
||||||
|
for line_index, line in enumerate(lines):
|
||||||
|
if secondary_muted and line_index:
|
||||||
|
painter.setFont(_library_font(13))
|
||||||
|
painter.setPen(QColor("#5D6B80"))
|
||||||
|
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
|
||||||
|
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
||||||
|
painter.save()
|
||||||
|
try:
|
||||||
|
painter.setClipRect(option.rect)
|
||||||
|
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||||
|
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||||
|
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
|
||||||
|
painter.fillRect(option.rect, QColor(background))
|
||||||
|
painter.setPen(QColor("#E6EDF6"))
|
||||||
|
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||||
|
if selected and index.column() == 0:
|
||||||
|
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
|
||||||
|
painter.fillRect(stripe, QColor("#1769E8"))
|
||||||
|
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||||
|
painter.setPen(QColor("#75A5F0"))
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
||||||
|
|
||||||
|
column = index.column()
|
||||||
|
if column == 9:
|
||||||
|
return
|
||||||
|
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
|
||||||
|
if rect.width() <= 0:
|
||||||
|
return
|
||||||
|
value = index.data(Qt.ItemDataRole.DisplayRole)
|
||||||
|
text = "" if value is None else str(value)
|
||||||
|
font = _library_font(medium=column == 1)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.setPen(QColor("#273244"))
|
||||||
|
metrics = QFontMetrics(font)
|
||||||
|
width = int(rect.width())
|
||||||
|
|
||||||
|
if column == 2:
|
||||||
|
painter.setFont(_library_font(13))
|
||||||
|
metrics = painter.fontMetrics()
|
||||||
|
label = _elide(text, metrics, width - 12)
|
||||||
|
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
|
||||||
|
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor("#EAF2FF"))
|
||||||
|
painter.drawRoundedRect(pill, 4, 4)
|
||||||
|
painter.setPen(QColor("#1769E8"))
|
||||||
|
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
||||||
|
elif column == 6:
|
||||||
|
glyph = "users" if text == "所有人可见" else "lock"
|
||||||
|
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
|
||||||
|
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
|
||||||
|
label_rect = rect.adjusted(22, 0, 0, 0)
|
||||||
|
painter.drawText(
|
||||||
|
label_rect,
|
||||||
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
_elide(text, metrics, int(label_rect.width())),
|
||||||
|
)
|
||||||
|
elif column == 4:
|
||||||
|
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
|
||||||
|
elif column == 8:
|
||||||
|
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
|
||||||
|
lines = [_elide(parts[0], metrics, width)]
|
||||||
|
if len(parts) > 1:
|
||||||
|
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
|
||||||
|
self._paint_lines(painter, rect, lines, secondary_muted=True)
|
||||||
|
elif column == 1:
|
||||||
|
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
|
||||||
|
else:
|
||||||
|
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
|
||||||
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
)
|
||||||
|
painter.drawText(rect, alignment, _elide(text, metrics, width))
|
||||||
|
finally:
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
|
||||||
|
_LIBRARY = """
|
||||||
|
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
|
||||||
|
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
|
||||||
|
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
|
||||||
|
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
|
||||||
|
background: transparent; border: 0;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
|
||||||
|
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
|
||||||
|
#PrescriptionLibraryPage QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
|
||||||
|
#PrescriptionLibraryPage QFrame#MetricCard,
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
|
||||||
|
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
|
||||||
|
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
|
||||||
|
border: 0; border-radius: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||||
|
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||||
|
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
|
||||||
|
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
|
||||||
|
background: #155BCC; border-color: #155BCC;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
|
||||||
|
background: #124EA9; border-color: #124EA9;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
|
||||||
|
#PrescriptionLibraryPage QPushButton:disabled,
|
||||||
|
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
|
||||||
|
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||||
|
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
|
||||||
|
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
|
||||||
|
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
|
||||||
|
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
|
||||||
|
#PrescriptionLibraryPage QComboBox QAbstractItemView {
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
|
||||||
|
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
|
||||||
|
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 0; color: #273244; font-size: 14px;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
|
||||||
|
color: #1769E8; border-bottom-color: #1769E8;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
|
||||||
|
color: #1555B6; background: #F2F7FF;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QTableWidget {
|
||||||
|
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
|
||||||
|
#PrescriptionLibraryPage QHeaderView::section {
|
||||||
|
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
|
||||||
|
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||||
|
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
|
||||||
|
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
|
||||||
|
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
def prescriptions_stylesheet() -> str:
|
||||||
|
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
_PRESCRIPTIONS = """
|
||||||
|
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
|
||||||
|
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
|
||||||
|
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
|
||||||
|
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLabel[role="sectionTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 14px; font-weight: 600;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
|
||||||
|
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
|
||||||
|
#PrescriptionsPage QPushButton {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||||
|
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||||
|
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
|
||||||
|
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
|
||||||
|
background: #155BCC; border-color: #155BCC;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
|
||||||
|
background: #124EA9; border-color: #124EA9;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
|
||||||
|
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
|
||||||
|
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||||
|
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
|
||||||
|
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
|
||||||
|
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
|
||||||
|
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||||
|
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
|
||||||
|
#PrescriptionsPage QComboBox QAbstractItemView {
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||||
|
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
|
||||||
|
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||||
|
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
|
||||||
|
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
|
||||||
|
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||||
|
#PrescriptionsPage QTableWidget#PrescriptionTable {
|
||||||
|
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
|
||||||
|
#PrescriptionsPage QHeaderView::section {
|
||||||
|
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"] {
|
||||||
|
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||||
|
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
|
||||||
|
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
|
||||||
|
background: transparent; border-color: transparent; color: #A4ADBA;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
|
||||||
|
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||||
|
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Palette and real font families for the approved reception page only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtGui import QFontDatabase
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.resources import resource_path
|
||||||
|
|
||||||
|
TECH_BLUE = {
|
||||||
|
"accent": "#1769E8",
|
||||||
|
"accent_hover": "#155BCC",
|
||||||
|
"accent_pressed": "#124EA9",
|
||||||
|
"selection": "#EAF2FF",
|
||||||
|
"selected_text": "#1555B6",
|
||||||
|
"canvas": "#F3F7FD",
|
||||||
|
"sidebar": "#EDF4FF",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"raised": "#F7FAFE",
|
||||||
|
"line": "#DBE5F2",
|
||||||
|
"text": "#273244",
|
||||||
|
"heading": "#202C3F",
|
||||||
|
"muted": "#5D6B80",
|
||||||
|
"focus": "#75A5F0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _families() -> dict[str, str]:
|
||||||
|
"""Resolve after Qt starts; font registration does not change its theme."""
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
raise RuntimeError("Reception font families require a QApplication")
|
||||||
|
cached = getattr(app, "_reception_font_families", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
heading = getattr(app, "_doctor_bundled_font_family", None)
|
||||||
|
if not heading:
|
||||||
|
font_path = resource_path("fonts", "NotoSansSC-VF.ttf")
|
||||||
|
if font_path.is_file():
|
||||||
|
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||||
|
registered = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
|
||||||
|
if registered:
|
||||||
|
heading = registered[0]
|
||||||
|
app._doctor_bundled_font_family = heading
|
||||||
|
|
||||||
|
available = set(QFontDatabase.families())
|
||||||
|
# Qt's offscreen platform does not enumerate Windows fonts automatically.
|
||||||
|
# Register only files already installed on this machine, never substitutes
|
||||||
|
# downloaded or installed into the user's Windows font registry.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
font_dir = (
|
||||||
|
Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or "C:/Windows") / "Fonts"
|
||||||
|
)
|
||||||
|
for family, filenames in (
|
||||||
|
("Microsoft YaHei UI", ("msyh.ttc",)),
|
||||||
|
("Segoe UI", ("segoeui.ttf", "seguisb.ttf")),
|
||||||
|
):
|
||||||
|
if family in available:
|
||||||
|
continue
|
||||||
|
for filename in filenames:
|
||||||
|
font_path = font_dir / filename
|
||||||
|
if not font_path.is_file():
|
||||||
|
continue
|
||||||
|
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||||
|
if font_id >= 0:
|
||||||
|
available.update(QFontDatabase.applicationFontFamilies(font_id))
|
||||||
|
|
||||||
|
fallback = heading or next(
|
||||||
|
(
|
||||||
|
family
|
||||||
|
for family in ("Noto Sans SC", "Noto Sans CJK SC", "PingFang SC")
|
||||||
|
if family in available
|
||||||
|
),
|
||||||
|
app.font().family(),
|
||||||
|
)
|
||||||
|
resolved = {
|
||||||
|
"body": "Microsoft YaHei UI" if "Microsoft YaHei UI" in available else fallback,
|
||||||
|
"heading": heading or fallback,
|
||||||
|
"number": "Segoe UI" if "Segoe UI" in available else fallback,
|
||||||
|
}
|
||||||
|
app._reception_font_families = resolved
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def body_family() -> str:
|
||||||
|
"""Regular Chinese body copy; YaHei UI has no genuine Medium face."""
|
||||||
|
|
||||||
|
return _families()["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def heading_family() -> str:
|
||||||
|
"""Bundled Noto Sans SC supplies genuine Medium and Semibold faces."""
|
||||||
|
|
||||||
|
return _families()["heading"]
|
||||||
|
|
||||||
|
|
||||||
|
def number_family() -> str:
|
||||||
|
"""Segoe UI supplies Regular and Semibold for numeric labels."""
|
||||||
|
|
||||||
|
return _families()["number"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,4 @@
|
|||||||
"""Application-wide visual system for the AI consultation workstation.
|
"""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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Scrolling, query isolation and refresh contracts for the two clinic queues."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QComboBox, QSpinBox, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||||
|
from doctor_workstation.ui.pages import appointments, consultations
|
||||||
|
|
||||||
|
|
||||||
|
class _DiagnosisDialog(QWidget):
|
||||||
|
saved = Signal()
|
||||||
|
|
||||||
|
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
self.fail_page: int | None = None
|
||||||
|
self.revision = 0
|
||||||
|
|
||||||
|
def _list(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(dict(query))
|
||||||
|
page = query["page_no"]
|
||||||
|
if page == self.fail_page:
|
||||||
|
raise RuntimeError("暂时无法加载")
|
||||||
|
second = bool(query.get("keyword") or query.get("patient_name"))
|
||||||
|
offset = 1000 if second else 0
|
||||||
|
total = 4 if second else 34
|
||||||
|
start = (page - 1) * query["page_size"]
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": offset + index,
|
||||||
|
"diagnosis_id": offset + index,
|
||||||
|
"source_patient_id": index + 2000,
|
||||||
|
"patient_id": index + 2000,
|
||||||
|
"patient_name": f"患者{offset + index} · {self.revision}",
|
||||||
|
"status": 1,
|
||||||
|
"status_desc": "待接诊",
|
||||||
|
"appointment_id": offset + index,
|
||||||
|
"appointment_status": 1,
|
||||||
|
"appointment_date": date.today().isoformat(),
|
||||||
|
"appointment_time": "09:00-09:30",
|
||||||
|
"patient_phone": "13800001234",
|
||||||
|
"doctor_name": "测试医生",
|
||||||
|
"doctor_id": 30,
|
||||||
|
"diagnosis_confirmed": True,
|
||||||
|
"appointments": [],
|
||||||
|
}
|
||||||
|
for index in range(start + 1, min(total, start + query["page_size"]) + 1)
|
||||||
|
]
|
||||||
|
result = {"lists": rows, "count": total}
|
||||||
|
if page == 1:
|
||||||
|
result["extend"] = {
|
||||||
|
"status_count": {"1": total, "3": 7},
|
||||||
|
"date_counts": {"today": total, "tomorrow": 9},
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
list_appointments = _list
|
||||||
|
list_consultations = _list
|
||||||
|
|
||||||
|
|
||||||
|
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
application.processEvents()
|
||||||
|
QTest.qWait(35)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=["appointments", "consultations"])
|
||||||
|
def queue_page(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
module = appointments if request.param == "appointments" else consultations
|
||||||
|
monkeypatch.setattr(module, "run_async", _inline)
|
||||||
|
monkeypatch.setattr(consultations, "DiagnosisDialog", _DiagnosisDialog)
|
||||||
|
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||||
|
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||||
|
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||||
|
repository = _Repository()
|
||||||
|
page_class = appointments.AppointmentsPage if module is appointments else consultations.ConsultationsPage
|
||||||
|
page = page_class(repository, permissions={"*"}, current_user={"role_id": 1})
|
||||||
|
page.resize(1280, 800)
|
||||||
|
page.show()
|
||||||
|
page.poll_timer.stop()
|
||||||
|
_settle(application)
|
||||||
|
yield page, repository, module
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def _scroll_bottom(page: Any, application: QApplication) -> None:
|
||||||
|
scrollbar = page.table.verticalScrollBar()
|
||||||
|
assert scrollbar.maximum() > 0
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_appends_and_preserves_selection(queue_page: Any, application: QApplication) -> None:
|
||||||
|
page, repository, _module = queue_page
|
||||||
|
assert page.table.rowCount() == 15
|
||||||
|
page.table.selectRow(6)
|
||||||
|
if hasattr(page, "table_host"):
|
||||||
|
model = page.table_host.model
|
||||||
|
model.setData(model.index(6, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
|
||||||
|
assert page.table.rowCount() == 30
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert page.table.current_data()["id"] == 7
|
||||||
|
assert page.table.verticalScrollBar().value() > 0
|
||||||
|
if hasattr(page, "table_host"):
|
||||||
|
assert [row["id"] for row in page.table_host.selected_records()] == [7]
|
||||||
|
else:
|
||||||
|
assert page._status_counts[3] == 7
|
||||||
|
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||||
|
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
assert page.table.rowCount() == 34
|
||||||
|
assert len({row["id"] for row in page.pager.rows}) == 34
|
||||||
|
assert not page.pager.has_more
|
||||||
|
assert "已全部加载" in page.pager.summary_label.text()
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_refresh_keeps_the_loaded_prefix(queue_page: Any, application: QApplication) -> None:
|
||||||
|
page, repository, _module = queue_page
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
page.table.selectRow(19)
|
||||||
|
old_scroll = page.table.verticalScrollBar().value()
|
||||||
|
repository.calls.clear()
|
||||||
|
repository.revision = 2
|
||||||
|
page.refresh(silent=True)
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert page.table.rowCount() == 30
|
||||||
|
assert page.table.current_data()["id"] == 20
|
||||||
|
assert page.table.current_data()["patient_name"].endswith(" · 2")
|
||||||
|
assert page.table.verticalScrollBar().value() == old_scroll
|
||||||
|
if isinstance(page, appointments.AppointmentsPage):
|
||||||
|
assert page._status_counts[3] == 7
|
||||||
|
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_change_supersedes_pending_append(
|
||||||
|
queue_page: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
page, _repository, module = queue_page
|
||||||
|
jobs: list[tuple[Any, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def deferred(function: Any, **callbacks: Any) -> None:
|
||||||
|
jobs.append((function, callbacks))
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "run_async", deferred)
|
||||||
|
# Reconfigure the shared controller to use the deferred runner, then finish
|
||||||
|
# that refresh before simulating a slow next page.
|
||||||
|
page.refresh(silent=True)
|
||||||
|
function, callbacks = jobs.pop()
|
||||||
|
callbacks["on_success"](function())
|
||||||
|
page.pager.load_more()
|
||||||
|
append_function, append_callbacks = jobs.pop()
|
||||||
|
stale_result = deepcopy(append_function())
|
||||||
|
|
||||||
|
search = page.patient_input if module is appointments else page.keyword_edit
|
||||||
|
search.setText("第二组")
|
||||||
|
page.refresh(silent=True)
|
||||||
|
assert len(jobs) == 1
|
||||||
|
function, callbacks = jobs.pop()
|
||||||
|
assert function()["lists"][0]["id"] == 1001
|
||||||
|
callbacks["on_success"](function())
|
||||||
|
append_callbacks["on_success"](stale_result)
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
assert page.table.rowCount() == 4
|
||||||
|
assert [row["id"] for row in page.pager.rows] == [1001, 1002, 1003, 1004]
|
||||||
|
assert page.pager.page == 1
|
||||||
|
assert not page.pager.loading
|
||||||
|
assert page.table.verticalScrollBar().value() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_append_retries_without_losing_rows(queue_page: Any, application: QApplication) -> None:
|
||||||
|
page, repository, _module = queue_page
|
||||||
|
repository.fail_page = 2
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
assert page.table.rowCount() == 15
|
||||||
|
assert page.pager.page == 1
|
||||||
|
assert page.pager.retry_button.isVisible()
|
||||||
|
calls = len(repository.calls)
|
||||||
|
_settle(application)
|
||||||
|
assert len(repository.calls) == calls
|
||||||
|
|
||||||
|
repository.fail_page = None
|
||||||
|
page.pager.retry_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.rowCount() == 30
|
||||||
|
assert page.pager.page == 2
|
||||||
|
assert page.pager.retry_button.isHidden()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_footer_is_compact_without_page_controls(queue_page: Any, application: QApplication) -> None:
|
||||||
|
page, _repository, _module = queue_page
|
||||||
|
for height in (768, 960):
|
||||||
|
page.resize(1280, height)
|
||||||
|
_settle(application)
|
||||||
|
assert isinstance(page.pager, InfiniteList)
|
||||||
|
assert page.pager.height() == 24
|
||||||
|
assert not page.pager.findChildren(QComboBox)
|
||||||
|
assert not page.pager.findChildren(QSpinBox)
|
||||||
|
content = page.page_scroll.widget() if hasattr(page, "page_scroll") else page
|
||||||
|
assert content.layout().contentsMargins().bottom() == 8
|
||||||
@@ -10,6 +10,8 @@ from typing import Any
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
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
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Native Qt interaction and layout checks for the approved appointment surface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import date
|
||||||
|
from itertools import combinations
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QTabBar,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from doctor_workstation.ui import shell as shell_module
|
||||||
|
from doctor_workstation.ui.pages import appointments as appointments_module
|
||||||
|
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||||
|
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.rows = [
|
||||||
|
{
|
||||||
|
"id": identifier,
|
||||||
|
"patient_name": f"测试患者{identifier}",
|
||||||
|
"patient_phone": "13800001234",
|
||||||
|
"gender": 1,
|
||||||
|
"age": 36,
|
||||||
|
"status": 1,
|
||||||
|
"status_desc": "待接诊",
|
||||||
|
"appointment_date": date.today().isoformat(),
|
||||||
|
"appointment_time": "09:00-09:30",
|
||||||
|
"doctor_name": "测试医生",
|
||||||
|
"doctor_id": 21,
|
||||||
|
"diagnosis_id": identifier + 100,
|
||||||
|
"source_patient_id": identifier + 200,
|
||||||
|
"diagnosis_confirmed": True,
|
||||||
|
"channel_name": "测试渠道",
|
||||||
|
}
|
||||||
|
for identifier in (401, 402, 403)
|
||||||
|
]
|
||||||
|
self.queries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
self.queries.append(kwargs)
|
||||||
|
return {
|
||||||
|
"lists": deepcopy(self.rows),
|
||||||
|
"count": len(self.rows),
|
||||||
|
"extend": {"status_count": {"1": len(self.rows)}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_departments(self) -> list[dict[str, Any]]:
|
||||||
|
return [{"id": 10, "name": "测试部门", "children": []}]
|
||||||
|
|
||||||
|
|
||||||
|
class _QuietPage(QWidget):
|
||||||
|
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
application.processEvents()
|
||||||
|
QTest.qWait(5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def window_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def run_inline(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args: Any, **_kwargs: Any):
|
||||||
|
pytest.fail("The appointment visual tests must stay offline")
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
monkeypatch.setattr(appointments_module, "run_async", run_inline)
|
||||||
|
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||||
|
navigation = [
|
||||||
|
NavigationItem("appointments", "挂号列表", "号", AppointmentsPage, ("doctor.appointment/lists",)),
|
||||||
|
NavigationItem("reception", "接诊台", "◎", _QuietPage, ("doctor.appointment/lists",)),
|
||||||
|
NavigationItem("patients", "我的患者", "患", _QuietPage, ("firstvisit.myPatient/lists",)),
|
||||||
|
NavigationItem("prescriptions", "已开处方", "笺", _QuietPage, ("tcm.prescription/lists",)),
|
||||||
|
NavigationItem("legacy_reference", "参考页", "参", _QuietPage, ()),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
shell_module, "_resolve_navigation",
|
||||||
|
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
|
||||||
|
)
|
||||||
|
windows = []
|
||||||
|
|
||||||
|
def create(*, admin: bool = False, width: int = 1536, height: int = 960):
|
||||||
|
repository = _Repository()
|
||||||
|
shell = ShellWindow(
|
||||||
|
repository,
|
||||||
|
{"user": {"name": "测试医生", "role_id": 3 if admin else 1}, "demo_mode": True},
|
||||||
|
permissions={"*"},
|
||||||
|
)
|
||||||
|
windows.append(shell)
|
||||||
|
shell.resize(width, height)
|
||||||
|
shell.show()
|
||||||
|
_settle(application)
|
||||||
|
page = shell.pages["appointments"]
|
||||||
|
page.poll_timer.stop()
|
||||||
|
return shell, page, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for shell in windows:
|
||||||
|
shell.close()
|
||||||
|
shell.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def _selector(page: AppointmentsPage, row: int) -> QCheckBox:
|
||||||
|
host = page.table.cellWidget(row, 0)
|
||||||
|
assert host is not None
|
||||||
|
selector = host.findChild(QCheckBox)
|
||||||
|
assert selector is not None
|
||||||
|
return selector
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_selection_matches(page: AppointmentsPage) -> None:
|
||||||
|
table = page.table
|
||||||
|
selected = {index.row() for index in table.selectionModel().selectedRows()}
|
||||||
|
assert selected == {table.currentRow()}
|
||||||
|
assert sum(_selector(page, row).isChecked() for row in range(table.rowCount())) == 1
|
||||||
|
for row in range(table.rowCount()):
|
||||||
|
assert _selector(page, row).isChecked() == (row in selected)
|
||||||
|
assert table.cellWidget(row, 0).property("selected") == (row in selected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_selector_tracks_initial_row_click_repeat_click_and_refresh(
|
||||||
|
application: QApplication, window_factory
|
||||||
|
) -> None:
|
||||||
|
shell, page, repository = window_factory()
|
||||||
|
assert page.table.rowCount() == 3
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
checkbox = _selector(page, 1)
|
||||||
|
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.currentRow() == 1
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
|
||||||
|
# Clicking the selected checkbox cannot leave the selected patient unchecked.
|
||||||
|
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
assert page.table.currentRow() == 1
|
||||||
|
|
||||||
|
target = page.table.item(2, 2)
|
||||||
|
QTest.mouseClick(
|
||||||
|
page.table.viewport(), Qt.MouseButton.LeftButton,
|
||||||
|
pos=page.table.visualItemRect(target).center(),
|
||||||
|
)
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.currentRow() == 2
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
selected_id = page.table.current_data()["id"]
|
||||||
|
|
||||||
|
shell.refresh_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.current_data()["id"] == selected_id
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
|
||||||
|
# Changed server data rebuilds the cells, unlike unchanged polling.
|
||||||
|
repository.rows[0]["channel_name"] = "更新后的测试渠道"
|
||||||
|
shell.refresh_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.current_data()["id"] == selected_id
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
|
||||||
|
|
||||||
|
def test_selector_selects_its_visible_patient_after_sorting(
|
||||||
|
application: QApplication, window_factory
|
||||||
|
) -> None:
|
||||||
|
_shell, page, _repository = window_factory()
|
||||||
|
# A user can change sorting after cell widgets have already been installed.
|
||||||
|
first_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||||
|
header = page.table.horizontalHeader()
|
||||||
|
header_position = QPoint(
|
||||||
|
header.sectionViewportPosition(1) + header.sectionSize(1) // 2,
|
||||||
|
header.height() // 2,
|
||||||
|
)
|
||||||
|
for _ in range(2):
|
||||||
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=header_position)
|
||||||
|
_settle(application)
|
||||||
|
if page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id:
|
||||||
|
break
|
||||||
|
expected_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||||
|
assert expected_id != first_id
|
||||||
|
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.current_data()["id"] == expected_id
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.current_data()["id"] == expected_id
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
|
||||||
|
|
||||||
|
def _rect(widget: QWidget, ancestor: QWidget) -> QRect:
|
||||||
|
return QRect(widget.mapTo(ancestor, QPoint()), widget.size())
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_filter_layout(page: AppointmentsPage) -> None:
|
||||||
|
types = (QPushButton, QComboBox, QLineEdit, QTabBar, QLabel)
|
||||||
|
controls = [widget for widget in page.filter_panel.findChildren(QWidget)
|
||||||
|
if isinstance(widget, types) and widget.isVisible()]
|
||||||
|
panel = page.filter_panel.rect()
|
||||||
|
for widget in controls:
|
||||||
|
assert panel.contains(_rect(widget, page.filter_panel)), widget.objectName()
|
||||||
|
for first, second in combinations(controls, 2):
|
||||||
|
if first.isAncestorOf(second) or second.isAncestorOf(first):
|
||||||
|
continue
|
||||||
|
overlap = _rect(first, page).intersected(_rect(second, page))
|
||||||
|
assert overlap.isEmpty(), (first.objectName(), second.objectName(), overlap)
|
||||||
|
assert not _rect(page.filter_panel, page).intersects(_rect(page.table_card, page))
|
||||||
|
assert page.table.viewport().height() > 80
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("admin", [False, True], ids=["doctor", "admin"])
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 640)])
|
||||||
|
def test_more_filters_fit_exact_window_and_preserve_all_columns(
|
||||||
|
application: QApplication, window_factory, admin: bool, width: int, height: int
|
||||||
|
) -> None:
|
||||||
|
shell, page, _repository = window_factory(admin=admin, width=width, height=height)
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
for expanded in (False, True, False):
|
||||||
|
if page.more_filters_button.isChecked() != expanded:
|
||||||
|
QTest.mouseClick(page.more_filters_button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert shell.size() == QSize(width, height)
|
||||||
|
assert page.advanced_filters.isVisible() == expanded
|
||||||
|
assert page.dept_filter.isVisible() == expanded
|
||||||
|
assert page.doctor_input.isVisible() == (expanded and admin)
|
||||||
|
assert page.custom_date_button.isVisible() == expanded
|
||||||
|
assert page.reset_filter_button.isVisible() == expanded
|
||||||
|
_assert_filter_layout(page)
|
||||||
|
assert page.table.columnCount() == 11
|
||||||
|
assert all(not page.table.isColumnHidden(column) for column in range(11))
|
||||||
|
assert [page.table.horizontalHeaderItem(column).text() for column in range(11)] == [
|
||||||
|
"", "ID", "患者", "性别 / 年龄", "挂号信息", "确认", "复诊", "助理", "开方", "未服务天数", "IM 问诊",
|
||||||
|
]
|
||||||
|
if width == 1024:
|
||||||
|
assert page.table.horizontalScrollBar().maximum() > 0
|
||||||
|
page.table.horizontalScrollBar().setValue(page.table.horizontalScrollBar().maximum())
|
||||||
|
_settle(application)
|
||||||
|
right = page.table.columnViewportPosition(10) + page.table.columnWidth(10)
|
||||||
|
assert right <= page.table.viewport().width()
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_typography_remains_compact_and_chrome_restores(
|
||||||
|
application: QApplication, window_factory
|
||||||
|
) -> None:
|
||||||
|
shell, page, _repository = window_factory()
|
||||||
|
original_qss = application.styleSheet()
|
||||||
|
assert page.header.title_label.font().pixelSize() == 20
|
||||||
|
assert page.patient_input.font().pixelSize() == 14
|
||||||
|
assert page.table.font().pixelSize() == 14
|
||||||
|
assert page.table.item(0, 2).font().pixelSize() == 14
|
||||||
|
assert page.header.subtitle_label.font().pixelSize() == 13
|
||||||
|
assert page.more_filters_button.font().pixelSize() == 13
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert shell.navigate("reception")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert shell.navigate("patients")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert shell.navigate("prescriptions")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert shell.navigate("legacy_reference")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 190
|
||||||
|
assert shell.topbar.height() == 62
|
||||||
|
assert shell.workspace.pos() == QPoint(203, 13)
|
||||||
|
assert shell.fold_button.isVisible()
|
||||||
|
assert not shell.menu_sidebar_action.isVisible()
|
||||||
|
for widget, stylesheet in shell._legacy_chrome_styles.items():
|
||||||
|
assert widget.styleSheet() == stylesheet
|
||||||
|
assert shell.navigate("appointments")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert page.header.title_label.font().pixelSize() == 20
|
||||||
|
assert application.styleSheet() == original_qss
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Native Qt coverage for presentation-only filter folding on both queues."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, QRect, Qt, Signal
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui import shell as shell_module
|
||||||
|
from doctor_workstation.ui.pages import appointments, consultations
|
||||||
|
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Dialog(QWidget):
|
||||||
|
saved = Signal()
|
||||||
|
|
||||||
|
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _list(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(query)
|
||||||
|
rows = [{
|
||||||
|
"id": index, "diagnosis_id": index, "patient_id": index + 2000,
|
||||||
|
"source_patient_id": index + 2000, "patient_name": f"演示患者{index:02d}",
|
||||||
|
"patient_phone": "13800001234", "gender": 2, "age": 38,
|
||||||
|
"status": 1, "status_desc": "待接诊", "doctor_name": "演示医生",
|
||||||
|
"doctor_id": 30, "assistant_name": "演示医助", "has_appointment": True,
|
||||||
|
"appointment_id": index + 1000, "appointment_status": 1,
|
||||||
|
"appointment_date": date.today().isoformat(), "appointment_time": "09:00-09:30",
|
||||||
|
"diagnosis_confirmed": True, "has_prescription": False,
|
||||||
|
"appointments": [{"id": index + 1000, "status": 1,
|
||||||
|
"doctor_name": "演示医生", "time_text": "09:00-09:30"}],
|
||||||
|
} for index in range(1, 16)]
|
||||||
|
return {"lists": rows, "count": 15, "extend": {"status_count": {"1": 15}}}
|
||||||
|
|
||||||
|
list_appointments = _list
|
||||||
|
list_consultations = _list
|
||||||
|
|
||||||
|
|
||||||
|
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
QTest.qWait(10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def list_window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr(appointments, "run_async", _inline)
|
||||||
|
monkeypatch.setattr(consultations, "run_async", _inline)
|
||||||
|
monkeypatch.setattr(consultations, "DiagnosisDialog", _Dialog)
|
||||||
|
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||||
|
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||||
|
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||||
|
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||||
|
navigation = [
|
||||||
|
NavigationItem("appointments", "挂号列表", "号", appointments.AppointmentsPage,
|
||||||
|
("doctor.appointment/lists",)),
|
||||||
|
NavigationItem("consultations", "问诊列表", "询", consultations.ConsultationsPage,
|
||||||
|
("tcm.diagnosis/lists",)),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(shell_module, "_resolve_navigation",
|
||||||
|
lambda *_args, **_kwargs: [(item, item.title) for item in navigation])
|
||||||
|
repository = _Repository()
|
||||||
|
window = ShellWindow(repository, {"user": {"name": "演示医生", "role_id": 1},
|
||||||
|
"demo_mode": True}, permissions={"*"})
|
||||||
|
yield window, repository
|
||||||
|
window.close()
|
||||||
|
window.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["appointments", "consultations"])
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1366, 768), (1536, 960)])
|
||||||
|
def test_folding_reclaims_rows_and_retains_query_refresh_and_navigation_state(
|
||||||
|
application: QApplication, list_window: Any, kind: str, width: int, height: int,
|
||||||
|
) -> None:
|
||||||
|
window, repository = list_window
|
||||||
|
window.resize(width, height)
|
||||||
|
window.show()
|
||||||
|
assert window.navigate(kind)
|
||||||
|
_settle(application)
|
||||||
|
page = window.pages[kind]
|
||||||
|
page.poll_timer.stop()
|
||||||
|
panel = page.filter_panel if kind == "appointments" else page.filters_card
|
||||||
|
header = page.header if kind == "appointments" else page.page_header
|
||||||
|
search = page.patient_input if kind == "appointments" else page.patient_name_edit
|
||||||
|
nested = page.more_filters_button if kind == "appointments" else page.more_filter_button
|
||||||
|
action = page.toolbar_edit_button if kind == "appointments" else page.add_button
|
||||||
|
disclosure = page.filter_disclosure
|
||||||
|
filters = page._query_filters if kind == "appointments" else page._filters
|
||||||
|
assert not disclosure.expanded
|
||||||
|
assert panel.isHidden() and not search.isVisible()
|
||||||
|
assert header.height() <= 44
|
||||||
|
assert not header.subtitle_label.isVisible()
|
||||||
|
assert disclosure.button.isVisible() and action.isVisible()
|
||||||
|
assert 30 <= disclosure.button.height() <= 34
|
||||||
|
assert window.refresh_button.isVisible()
|
||||||
|
assert any(button.isVisible() and button.text() == "刷新"
|
||||||
|
for button in page.findChildren(QPushButton))
|
||||||
|
collapsed_height = page.table.viewport().height()
|
||||||
|
capture_dir = os.environ.get("COLLAPSIBLE_LIST_SCREENSHOTS")
|
||||||
|
if capture_dir:
|
||||||
|
Path(capture_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-collapsed.png"))
|
||||||
|
|
||||||
|
calls_before = len(repository.calls)
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert len(repository.calls) == calls_before
|
||||||
|
assert panel.isVisible() and search.isVisible()
|
||||||
|
assert collapsed_height >= page.table.viewport().height() + 100
|
||||||
|
assert panel.rect().contains(QRect(search.mapTo(panel, QPoint()), search.size()))
|
||||||
|
assert search.parentWidget().rect().contains(search.geometry())
|
||||||
|
assert not page.advanced_filters.isVisible()
|
||||||
|
if capture_dir:
|
||||||
|
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-expanded.png"))
|
||||||
|
(Path(capture_dir) / f"{kind}-{width}-metrics.json").write_text(
|
||||||
|
json.dumps({"window": [width, height], "header_height": header.height(),
|
||||||
|
"toggle_height": disclosure.button.height(),
|
||||||
|
"collapsed_viewport_height": collapsed_height,
|
||||||
|
"expanded_viewport_height": page.table.viewport().height(),
|
||||||
|
"viewport_gain": collapsed_height - page.table.viewport().height()},
|
||||||
|
indent=2), encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
search.setText("演示患者")
|
||||||
|
query_button = (page.findChild(QPushButton, "AppointmentSearchButton")
|
||||||
|
if kind == "appointments" else page.search_button)
|
||||||
|
QTest.mouseClick(query_button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert len(repository.calls) > calls_before
|
||||||
|
assert repository.calls[-1]["patient_name"] == "演示患者"
|
||||||
|
QTest.mouseClick(nested, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.advanced_filters.isVisible()
|
||||||
|
query = dict(filters())
|
||||||
|
calls_before = len(repository.calls)
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert len(repository.calls) == calls_before
|
||||||
|
assert filters() == query
|
||||||
|
assert search.text() == "演示患者"
|
||||||
|
assert panel.isHidden()
|
||||||
|
assert page.table.viewport().height() == collapsed_height
|
||||||
|
|
||||||
|
window.resize(width - 50, height - 20)
|
||||||
|
window.refresh_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert not disclosure.expanded and panel.isHidden()
|
||||||
|
assert filters() == query
|
||||||
|
other = "consultations" if kind == "appointments" else "appointments"
|
||||||
|
assert window.navigate(other)
|
||||||
|
assert window.navigate(kind)
|
||||||
|
_settle(application)
|
||||||
|
assert not disclosure.expanded and panel.isHidden()
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.advanced_filters.isVisible() and nested.isChecked()
|
||||||
|
window.refresh_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert disclosure.expanded and panel.isVisible()
|
||||||
|
assert filters() == query
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Page disclosures reclaim list space without changing queries or tab state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
|
from doctor_workstation.ui.pages.patients import PatientsPage
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def _result(self, kind: str, query: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self.calls.append((kind, deepcopy(query)))
|
||||||
|
return {
|
||||||
|
"lists": [{
|
||||||
|
"id": 101 + index, "diagnosis_id": 501 + index,
|
||||||
|
"patient_id": 301 + index, "patient_name": f"患者 {index + 1}",
|
||||||
|
"order_no": f"TEST-20260907-{index + 1}", "queue_no": index + 1,
|
||||||
|
"appointment_status": 1, "queue_status": "waiting",
|
||||||
|
"queue_status_text": "等待中", "doctor_name": "测试医生",
|
||||||
|
} for index in range(4)],
|
||||||
|
"count": 4,
|
||||||
|
"extend": {
|
||||||
|
"scope": {"label": "本人患者"}, "schedule_mode": "ownership",
|
||||||
|
"summary": {"waiting": 4, "today": 4, "orders": 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||||
|
return self._result("patients", query)
|
||||||
|
|
||||||
|
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||||
|
return self._result("orders", query)
|
||||||
|
|
||||||
|
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||||
|
return self._result("progress", query)
|
||||||
|
|
||||||
|
|
||||||
|
def settle(application: QApplication) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page_factory(application, monkeypatch):
|
||||||
|
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args, **_kwargs):
|
||||||
|
pytest.fail("Disclosure tests must use local fixture data")
|
||||||
|
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(width=1328, height=884):
|
||||||
|
repository = Repository()
|
||||||
|
page = PatientsPage(repository, PermissionSet(["*"]))
|
||||||
|
opened.append(page)
|
||||||
|
page.resize(width, height)
|
||||||
|
page.show()
|
||||||
|
settle(application)
|
||||||
|
return page, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for page in opened:
|
||||||
|
for timer in page.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def regions(page, index):
|
||||||
|
return (
|
||||||
|
(page.patient_workspace.search_toolbar, page.patient_workspace.filter_card,
|
||||||
|
page.patient_workspace.summary_strip),
|
||||||
|
(page.order_workspace.filter_card, page.order_workspace.summary_strip),
|
||||||
|
(page.progress_workspace.overview_card, page.progress_workspace.schedule_card),
|
||||||
|
)[index]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("index", [0, 1, 2], ids=["patients", "orders", "progress"])
|
||||||
|
def test_tabs_default_collapsed_and_keyboard_expansion_gives_space_to_lists(
|
||||||
|
application, page_factory, index
|
||||||
|
):
|
||||||
|
page, repository = page_factory()
|
||||||
|
page.tabs.setCurrentIndex(index)
|
||||||
|
settle(application)
|
||||||
|
workspace = page.tabs.currentWidget()
|
||||||
|
table = workspace.queue_table if index == 2 else workspace.table
|
||||||
|
disclosure = page.filter_disclosure
|
||||||
|
assert not disclosure.expanded
|
||||||
|
assert all(widget.isHidden() for widget in regions(page, index))
|
||||||
|
assert sum(item.button.isVisible() for item in page.filter_disclosures) == 1
|
||||||
|
assert disclosure.button.text() == disclosure.button.accessibleName() == "展开筛选"
|
||||||
|
assert disclosure.button.height() == 32
|
||||||
|
assert page.header.height() <= 48
|
||||||
|
assert not page.header.subtitle_label.isVisible()
|
||||||
|
assert page.refresh_button.isVisible() and page.tabs.tabBar().isVisible()
|
||||||
|
collapsed_height = table.viewport().height()
|
||||||
|
before = deepcopy(repository.calls)
|
||||||
|
disclosure.button.setFocus()
|
||||||
|
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||||
|
settle(application)
|
||||||
|
assert disclosure.expanded and disclosure.button.text() == "收起筛选"
|
||||||
|
assert all(widget.isVisible() for widget in regions(page, index))
|
||||||
|
assert collapsed_height >= table.viewport().height() + 100
|
||||||
|
assert repository.calls == before
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
assert table.viewport().height() == collapsed_height
|
||||||
|
assert table.isVisible() and workspace.pager.isVisible()
|
||||||
|
assert repository.calls == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("index", [0, 1], ids=["patient-filters", "order-filters"])
|
||||||
|
def test_search_values_survive_collapse_refresh_and_tab_switch(
|
||||||
|
application, page_factory, index
|
||||||
|
):
|
||||||
|
page, repository = page_factory()
|
||||||
|
page.tabs.setCurrentIndex(index)
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
settle(application)
|
||||||
|
workspace = page.tabs.currentWidget()
|
||||||
|
workspace.keyword_edit.setText(" 林青 ")
|
||||||
|
if index == 0:
|
||||||
|
QTest.mouseClick(workspace.custom_date_button, Qt.MouseButton.LeftButton)
|
||||||
|
else:
|
||||||
|
workspace.use_dates.setChecked(True)
|
||||||
|
workspace.rx_audit.setCurrentIndex(2)
|
||||||
|
workspace.start_date.setDate(QDate(2026, 9, 1))
|
||||||
|
workspace.end_date.setDate(QDate(2026, 9, 7))
|
||||||
|
QTest.mouseClick(workspace.search_button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
query = deepcopy(repository.calls[-1])
|
||||||
|
before = deepcopy(repository.calls)
|
||||||
|
page.filter_disclosure.set_expanded(False)
|
||||||
|
settle(application)
|
||||||
|
assert repository.calls == before
|
||||||
|
assert workspace.keyword_edit.text() == " 林青 "
|
||||||
|
assert workspace.start_date.date() == QDate(2026, 9, 1)
|
||||||
|
assert workspace.end_date.date() == QDate(2026, 9, 7)
|
||||||
|
QTest.mouseClick(page.refresh_button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
assert repository.calls[-1] == query
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
page.tabs.setCurrentIndex(2)
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
page.tabs.setCurrentIndex(index)
|
||||||
|
settle(application)
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert repository.calls[-1] == query
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
settle(application)
|
||||||
|
assert workspace.keyword_edit.isVisible() and workspace.start_date.isEnabled()
|
||||||
|
assert (workspace.custom_date_button.isChecked() if index == 0 else
|
||||||
|
workspace.rx_audit.currentIndex() == 2 and workspace.use_dates.isChecked())
|
||||||
|
page.tabs.setCurrentIndex(2)
|
||||||
|
assert page.filter_disclosure.expanded
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1328, 884), (1158, 692), (816, 564)])
|
||||||
|
def test_resize_keeps_collapsed_regions_hidden_and_queue_reachable(
|
||||||
|
application, page_factory, width, height
|
||||||
|
):
|
||||||
|
page, _repository = page_factory()
|
||||||
|
page.resize(width, height)
|
||||||
|
for index in range(3):
|
||||||
|
page.tabs.setCurrentIndex(index)
|
||||||
|
settle(application)
|
||||||
|
workspace = page.tabs.currentWidget()
|
||||||
|
assert all(widget.isHidden() for widget in regions(page, index))
|
||||||
|
for widget in (page.filter_disclosure.button, page.refresh_button):
|
||||||
|
assert page.rect().contains(QRect(widget.mapTo(page, QPoint()), widget.size()))
|
||||||
|
assert workspace.scroll.verticalScrollBar().maximum() == 0
|
||||||
|
assert workspace.pager.isVisibleTo(page)
|
||||||
|
if index == 2:
|
||||||
|
assert workspace.splitter.minimumHeight() == 222
|
||||||
|
assert workspace.queue_card.isVisible()
|
||||||
|
assert workspace.queue_card.height() == workspace.splitter.height()
|
||||||
|
page.hide()
|
||||||
|
page.show()
|
||||||
|
settle(application)
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert all(widget.isHidden() for widget in regions(page, 2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapsed_refresh_coalesces_identical_in_flight_queries(
|
||||||
|
application, page_factory, monkeypatch
|
||||||
|
):
|
||||||
|
page, _repository = page_factory()
|
||||||
|
pending = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
patients_module, "run_async",
|
||||||
|
lambda function, **callbacks: pending.append((function, callbacks)),
|
||||||
|
)
|
||||||
|
page.refresh()
|
||||||
|
page.refresh()
|
||||||
|
assert len(pending) == 1
|
||||||
|
function, callbacks = pending[0]
|
||||||
|
callbacks["on_success"](function())
|
||||||
|
settle(application)
|
||||||
|
assert page.patient_workspace.table.rowCount() == 4
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert not page.patient_workspace.search_toolbar.isVisible()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, QPoint, Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import reception as reception_module
|
||||||
|
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(application):
|
||||||
|
for _ in range(8):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page(application, monkeypatch):
|
||||||
|
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||||
|
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||||
|
widget.resize(1280, 800)
|
||||||
|
widget.show()
|
||||||
|
widget.poll_timer.stop()
|
||||||
|
widget.detail_stack.setCurrentIndex(1)
|
||||||
|
settle(application)
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_fold_reclaims_queue_space_and_keeps_clinical_workspace(page, application):
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert page.filter_disclosure.button.text() == "展开筛选"
|
||||||
|
assert page.refresh_button.isVisible()
|
||||||
|
assert page.queue_filter_summary.isVisible()
|
||||||
|
assert not page.queue_filter_panel.isVisible()
|
||||||
|
assert not page.queue_date_button.isVisible()
|
||||||
|
assert not page.search_edit.isVisible()
|
||||||
|
assert all(not button.isVisible() for button in page._queue_filter_buttons.values())
|
||||||
|
assert page.detail_tabs.isVisible()
|
||||||
|
assert page.patient_name_label.isVisible()
|
||||||
|
assert page.clinical_info_group.isVisible()
|
||||||
|
clinical_geometry = page.detail_stack.geometry()
|
||||||
|
collapsed_height = page.queue_stack.height()
|
||||||
|
|
||||||
|
QTest.mouseClick(page.filter_disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
assert page.filter_disclosure.expanded
|
||||||
|
assert page.filter_disclosure.button.text() == "收起筛选"
|
||||||
|
assert page.queue_date_button.isVisible()
|
||||||
|
assert page.search_edit.isVisible()
|
||||||
|
assert all(button.isVisible() for button in page._queue_filter_buttons.values())
|
||||||
|
assert collapsed_height - page.queue_stack.height() >= 64
|
||||||
|
assert page.detail_stack.geometry() == clinical_geometry
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_keeps_queries_patient_draft_loaded_pages_and_resize_state(page, application, monkeypatch):
|
||||||
|
requests = []
|
||||||
|
monkeypatch.setattr(page, "_request_queue_page", lambda query, **options: requests.append((query, options)))
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
page.queue_date_button._pick_date(QDate(2026, 8, 3))
|
||||||
|
page._queue_filter_buttons[None].click()
|
||||||
|
page.search_edit.setText(" 折叠测试患者 ")
|
||||||
|
QTest.keyClick(page.search_edit, Qt.Key.Key_Return)
|
||||||
|
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||||
|
assert requests[-1][0]["status"] is None
|
||||||
|
assert requests[-1][0]["start_date"] == requests[-1][0]["end_date"] == "2026-08-03"
|
||||||
|
assert page.queue_filter_summary.text() == "2026-08-03 · 全部状态 · 姓名已筛选"
|
||||||
|
assert "折叠测试患者" in page.queue_filter_summary.toolTip()
|
||||||
|
|
||||||
|
page._selected_appointment_id = 51
|
||||||
|
page._queue_records = [{"id": index} for index in range(1, 31)]
|
||||||
|
page._queue_page = 2
|
||||||
|
page._queue_total = 40
|
||||||
|
page.note_edit.setPlainText("尚未保存的接诊备注")
|
||||||
|
page.detail_tabs.setCurrentIndex(4)
|
||||||
|
query_snapshot = dict(page._queue_query)
|
||||||
|
request_count = len(requests)
|
||||||
|
loading = page._queue_loading
|
||||||
|
|
||||||
|
for expanded in (False, True, False):
|
||||||
|
page.filter_disclosure.set_expanded(expanded)
|
||||||
|
page.resize(816 if expanded else 1280, 800)
|
||||||
|
settle(application)
|
||||||
|
assert page.filter_disclosure.expanded is expanded
|
||||||
|
assert page.queue_filter_panel.isVisible() is expanded
|
||||||
|
assert len(requests) == request_count
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
assert page._queue_page == 2
|
||||||
|
assert len(page._queue_records) == 30
|
||||||
|
assert page._queue_loading is loading
|
||||||
|
assert page._queue_query == query_snapshot
|
||||||
|
assert page.search_edit.text() == " 折叠测试患者 "
|
||||||
|
assert page.note_edit.toPlainText() == "尚未保存的接诊备注"
|
||||||
|
assert page.detail_tabs.currentIndex() == 4
|
||||||
|
|
||||||
|
page.refresh(workspace_refresh=True)
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||||
|
assert requests[-1][0]["status"] is None
|
||||||
|
assert requests[-1][0]["start_date"] == "2026-08-03"
|
||||||
|
assert requests[-1][0]["page_size"] == 30
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width", [816, 1280])
|
||||||
|
def test_expanded_queue_controls_fit_at_supported_page_widths(page, application, width):
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
page.resize(width, 800)
|
||||||
|
page._update_queue_filter_counts({"extend": {"status_count": {"1": 23, "2": 11, "3": 42}}}, [])
|
||||||
|
settle(application)
|
||||||
|
panel = page.queue_panel
|
||||||
|
controls = [
|
||||||
|
page.refresh_button,
|
||||||
|
page.filter_disclosure.button,
|
||||||
|
page.queue_date_button,
|
||||||
|
page.search_edit,
|
||||||
|
*page._queue_filter_buttons.values(),
|
||||||
|
]
|
||||||
|
for widget in controls:
|
||||||
|
position = widget.mapTo(panel, QPoint())
|
||||||
|
assert position.x() >= 0
|
||||||
|
assert position.x() + widget.width() <= panel.width()
|
||||||
|
assert widget.width() >= widget.minimumSizeHint().width()
|
||||||
|
assert page.queue_date_button.geometry().right() < page.search_edit.geometry().left()
|
||||||
|
assert page.filter_disclosure.button.height() == page.search_edit.height() == 32
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Permission-scoped department loading and diagnosis HTTP filter contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
|
||||||
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
|
from doctor_workstation.ui.pages import consultations as module
|
||||||
|
|
||||||
|
TREE = [
|
||||||
|
{"id": 10, "name": "医助部", "children": [
|
||||||
|
{"id": "11", "name": "一组", "children": [
|
||||||
|
{"id": 12, "name": "专病组", "children": []},
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{"id": 20, "name": "另一部门", "children": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, departments: Any = TREE) -> None:
|
||||||
|
self.departments = departments
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.calls.append((endpoint, dict(params or {})))
|
||||||
|
if endpoint == "dept.dept/all":
|
||||||
|
if isinstance(self.departments, Exception):
|
||||||
|
raise self.departments
|
||||||
|
return self.departments
|
||||||
|
return {"lists": [], "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class DetailStub(QWidget):
|
||||||
|
saved = Signal()
|
||||||
|
|
||||||
|
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
|
||||||
|
# Test only this page's controls; loading a global stylesheet is unrelated.
|
||||||
|
client = Client()
|
||||||
|
result = module.ConsultationsPage(
|
||||||
|
RemoteDoctorRepository(client),
|
||||||
|
current_user={"department_id": 99, "department_name": "个人所属部门"},
|
||||||
|
)
|
||||||
|
yield result, client
|
||||||
|
result.poll_timer.stop()
|
||||||
|
result.close()
|
||||||
|
result.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def immediate(function: Any, *, on_success: Any = None, on_error: Any = None,
|
||||||
|
on_finished: Any = None, **_kwargs: Any) -> None:
|
||||||
|
try:
|
||||||
|
value = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(value)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [TREE, {"lists": TREE}, {"tree": TREE}, {"data": {"lists": TREE}}])
|
||||||
|
def test_repository_scoped_tree_shapes(payload: Any) -> None:
|
||||||
|
client = Client(payload)
|
||||||
|
assert RemoteDoctorRepository(client).list_departments(apply_data_scope=True) == TREE
|
||||||
|
assert client.calls == [("dept.dept/all", {"apply_data_scope": 1})]
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_malformed_is_not_a_permission_empty_result() -> None:
|
||||||
|
with pytest.raises(ApiProtocolError):
|
||||||
|
RemoteDoctorRepository(Client({"unexpected": 1})).list_departments(apply_data_scope=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_maps_legacy_department_key_and_preserves_canonical() -> None:
|
||||||
|
client = Client()
|
||||||
|
repository = RemoteDoctorRepository(client)
|
||||||
|
repository.list_consultations(department_id=12)
|
||||||
|
assert client.calls[-1][1]["assistant_dept_id"] == 12
|
||||||
|
assert "department_id" not in client.calls[-1][1]
|
||||||
|
repository.list_consultations(department_id=12, assistant_dept_id=20)
|
||||||
|
assert client.calls[-1][1]["assistant_dept_id"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_options_search_and_selected_id_reach_list_and_counts(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
widget._load_departments()
|
||||||
|
combo = widget.department_combo
|
||||||
|
assert combo.count() == 5
|
||||||
|
assert combo.itemText(3) == "医助部 / 一组 / 专病组"
|
||||||
|
assert combo.findData(99) == -1
|
||||||
|
assert combo.isEditable()
|
||||||
|
combo.completer().setCompletionPrefix("专病")
|
||||||
|
assert combo.completer().completionCount() == 1
|
||||||
|
combo.setCurrentIndex(combo.findData(12))
|
||||||
|
widget._search()
|
||||||
|
calls = [params for endpoint, params in client.calls if endpoint == "tcm.diagnosis/lists"]
|
||||||
|
assert calls
|
||||||
|
assert all(params["assistant_dept_id"] == 12 for params in calls)
|
||||||
|
assert all("department_id" not in params for params in calls)
|
||||||
|
assert calls[-1]["page_size"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_unselected_search_text_never_becomes_an_id_or_silently_searches_all(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
widget._load_departments()
|
||||||
|
widget.department_combo.setEditText("不存在的部门")
|
||||||
|
widget._search()
|
||||||
|
assert not any(endpoint == "tcm.diagnosis/lists" for endpoint, _ in client.calls)
|
||||||
|
widget.department_combo.setEditText("")
|
||||||
|
widget._search()
|
||||||
|
assert widget._shared_filters()["assistant_dept_id"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_empty_has_no_profile_fallback_and_can_retry(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
client.departments = []
|
||||||
|
widget._load_departments()
|
||||||
|
assert widget.department_combo.count() == 1
|
||||||
|
assert widget.department_combo.currentData() == ""
|
||||||
|
assert widget.department_combo.currentText() == "暂无可选部门"
|
||||||
|
assert not widget.department_combo.isEnabled()
|
||||||
|
assert not widget.department_retry_button.isHidden()
|
||||||
|
client.departments = TREE
|
||||||
|
widget.department_retry_button.click()
|
||||||
|
assert widget.department_combo.isEnabled()
|
||||||
|
assert widget.department_combo.findData(12) > 0
|
||||||
|
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
client.departments = ApiBusinessError("无权限访问部门")
|
||||||
|
widget._load_departments()
|
||||||
|
assert not widget._departments_loaded
|
||||||
|
assert not widget._departments_loading
|
||||||
|
assert widget.department_combo.currentText() == "部门加载失败"
|
||||||
|
assert "无权限" in widget.department_combo.toolTip()
|
||||||
|
assert not widget.department_retry_button.isHidden()
|
||||||
|
client.departments = TREE
|
||||||
|
widget.department_retry_button.click()
|
||||||
|
assert widget._departments_loaded
|
||||||
|
assert widget.department_retry_button.isHidden()
|
||||||
|
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, _client = page
|
||||||
|
jobs: list[dict[str, Any]] = []
|
||||||
|
monkeypatch.setattr(module, "run_async", lambda function, **callbacks: jobs.append(callbacks))
|
||||||
|
widget._load_departments()
|
||||||
|
widget._load_departments()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
widget._load_departments(force=True)
|
||||||
|
assert len(jobs) == 2
|
||||||
|
jobs[1]["on_success"]([(12, "当前部门")])
|
||||||
|
widget.department_combo.setCurrentIndex(1)
|
||||||
|
jobs[0]["on_success"]([(99, "旧部门")])
|
||||||
|
jobs[0]["on_error"](RuntimeError("旧请求失败"))
|
||||||
|
assert widget.department_combo.currentData() == 12
|
||||||
|
assert widget.department_combo.findData(99) == -1
|
||||||
|
assert widget._departments_loaded
|
||||||
|
assert widget.department_combo.isEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_of_options_preserves_selected_id(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, _client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
widget._load_departments()
|
||||||
|
widget.department_combo.setCurrentIndex(widget.department_combo.findData(12))
|
||||||
|
widget._load_departments(force=True)
|
||||||
|
assert widget.department_combo.currentData() == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_repository_is_not_called_without_scope(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, _client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
calls: list[bool] = []
|
||||||
|
|
||||||
|
class OldRepository:
|
||||||
|
def list_departments(self):
|
||||||
|
calls.append(True)
|
||||||
|
return TREE
|
||||||
|
|
||||||
|
widget.repository = OldRepository()
|
||||||
|
widget._load_departments()
|
||||||
|
assert calls == []
|
||||||
|
assert not widget._departments_loaded
|
||||||
|
assert not widget.department_retry_button.isHidden()
|
||||||
@@ -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})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Critical-path request counts and late metadata safety, without global Qt styling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
|
from doctor_workstation.ui.dialogs import diagnosis as module
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, delay: float = 0) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
self.delay = delay
|
||||||
|
|
||||||
|
def get(self, endpoint: str, params: dict[str, Any]) -> Any:
|
||||||
|
self.calls.append((endpoint, params))
|
||||||
|
time.sleep(self.delay)
|
||||||
|
if endpoint == "config/dict":
|
||||||
|
return {
|
||||||
|
key: [{"name": "口干", "value": "dry"}, {"name": "口苦", "value": "bitter"}]
|
||||||
|
for key in params["type"].split(",")
|
||||||
|
}
|
||||||
|
if endpoint.endswith("Detail") or endpoint.endswith("/detail"):
|
||||||
|
return {
|
||||||
|
"id": params["id"], "patient_id": params["id"] + 1000,
|
||||||
|
"patient_name": f"Patient {params['id']}", "appetite": ["dry"],
|
||||||
|
"diagnosis_type": "first_visit", "chief_complaint": "Original",
|
||||||
|
}
|
||||||
|
return {"lists": [], "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def queued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||||
|
jobs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def enqueue(function: Any, **callbacks: Any) -> object:
|
||||||
|
jobs.append({"function": function, **callbacks})
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "run_async", enqueue)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def complete(job: dict[str, Any]) -> None:
|
||||||
|
job["on_success"](job["function"]())
|
||||||
|
if job.get("on_finished"):
|
||||||
|
job["on_finished"]()
|
||||||
|
|
||||||
|
|
||||||
|
def dialog_for(client: Client) -> module.DiagnosisDialog:
|
||||||
|
return module.DiagnosisDialog(
|
||||||
|
RemoteDoctorRepository(client), permissions=PermissionSet(["*"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_unlocks_before_single_batch_and_preserves_draft(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
client = Client()
|
||||||
|
dialog = dialog_for(client)
|
||||||
|
try:
|
||||||
|
dialog.open_for(501, editable=True, seed={"patient_name": "Unverified"})
|
||||||
|
assert dialog.isVisible()
|
||||||
|
assert dialog.drawer_loading.isVisibleTo(dialog)
|
||||||
|
assert dialog.edit_fields["patient_name"].isReadOnly()
|
||||||
|
assert not dialog.save_button.isEnabled()
|
||||||
|
assert len(queued) == 1
|
||||||
|
complete(queued[0])
|
||||||
|
assert client.calls == [("tcm.diagnosis/detail", {"id": 501})]
|
||||||
|
assert dialog._authoritative_detail_loaded
|
||||||
|
assert dialog.save_button.isEnabled()
|
||||||
|
assert not dialog.drawer_loading.isVisibleTo(dialog)
|
||||||
|
assert len(queued) == 2
|
||||||
|
dialog.edit_fields["chief_complaint"].setPlainText("Unsaved draft")
|
||||||
|
dialog.edit_fields["appetite"].setPlainText("bitter")
|
||||||
|
complete(queued[1])
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
assert client.calls[1][0] == "config/dict"
|
||||||
|
assert len(client.calls[1][1]["type"].split(",")) == 16
|
||||||
|
assert dialog.edit_fields["appetite"].toPlainText() == "bitter"
|
||||||
|
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Unsaved draft"
|
||||||
|
assert dialog._loaded_tabs == set()
|
||||||
|
assert not any("getCallRecords" in endpoint for endpoint, _params in client.calls)
|
||||||
|
dialog._ensure_tab_loaded("basic")
|
||||||
|
dialog.refresh_permissions()
|
||||||
|
assert len(queued) == 2
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("close", [False, True])
|
||||||
|
def test_late_detail_and_dictionary_callbacks_cannot_touch_another_opening(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]], close: bool
|
||||||
|
) -> None:
|
||||||
|
dialog = dialog_for(Client())
|
||||||
|
try:
|
||||||
|
dialog.open_for(501, editable=True)
|
||||||
|
detail_job = queued[0]
|
||||||
|
complete(detail_job)
|
||||||
|
metadata_job = queued[1]
|
||||||
|
if close:
|
||||||
|
dialog.close()
|
||||||
|
else:
|
||||||
|
dialog.open_for(502, editable=True)
|
||||||
|
complete(queued[2])
|
||||||
|
dialog.edit_fields["chief_complaint"].setPlainText("Keep current draft")
|
||||||
|
before = dialog.edit_fields["patient_name"].toPlainText()
|
||||||
|
complete(detail_job)
|
||||||
|
complete(metadata_job)
|
||||||
|
assert dialog.edit_fields["patient_name"].toPlainText() == before
|
||||||
|
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep current draft"
|
||||||
|
if not close:
|
||||||
|
assert before == "Patient 502"
|
||||||
|
assert dialog._patient_id == 1502
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dictionary_failure_does_not_relock_valid_detail_or_erase_choices(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
dialog = dialog_for(Client())
|
||||||
|
try:
|
||||||
|
dialog.open_for(501, editable=True)
|
||||||
|
complete(queued[0])
|
||||||
|
queued[1]["on_error"](RuntimeError("Delayed metadata unavailable"))
|
||||||
|
assert dialog._authoritative_detail_loaded
|
||||||
|
assert dialog.save_button.isEnabled()
|
||||||
|
assert dialog.edit_fields["appetite"].toPlainText() == "dry"
|
||||||
|
assert not dialog.drawer_loading.isVisibleTo(dialog)
|
||||||
|
dialog._retry_current()
|
||||||
|
assert len(queued) == 3
|
||||||
|
complete(queued[2])
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactive_basic_tab_defers_dictionary_request_until_selected(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
client = Client()
|
||||||
|
dialog = dialog_for(client)
|
||||||
|
try:
|
||||||
|
notes_index = next(
|
||||||
|
i for i in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(i) == "notes"
|
||||||
|
)
|
||||||
|
dialog.tabs.setCurrentIndex(notes_index)
|
||||||
|
dialog.open_for(501, editable=True)
|
||||||
|
complete(queued[0])
|
||||||
|
assert dialog._current_tab_key() == "notes"
|
||||||
|
assert dialog._dictionary_requested_generation != dialog._generation
|
||||||
|
assert len(client.calls) == 1
|
||||||
|
# The only secondary request is for the active notes tab, not all tabs.
|
||||||
|
assert len(queued) == 2
|
||||||
|
dialog.tabs.setCurrentIndex(0)
|
||||||
|
assert len(queued) == 3
|
||||||
|
complete(queued[2])
|
||||||
|
assert client.calls[-1][0] == "config/dict"
|
||||||
|
dialog.tabs.setCurrentIndex(notes_index)
|
||||||
|
dialog.tabs.setCurrentIndex(0)
|
||||||
|
assert len(queued) == 3
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("retry_from_banner", [False, True])
|
||||||
|
def test_dictionary_error_survives_other_tab_success_and_retries_correct_request(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]], retry_from_banner: bool,
|
||||||
|
) -> None:
|
||||||
|
client = Client()
|
||||||
|
dialog = dialog_for(client)
|
||||||
|
try:
|
||||||
|
dialog.open_for(501, editable=True)
|
||||||
|
complete(queued[0])
|
||||||
|
metadata_job = queued[1]
|
||||||
|
notes_index = next(i for i in range(dialog.tabs.count())
|
||||||
|
if dialog.tabs.tabBar().tabData(i) == "notes")
|
||||||
|
dialog.tabs.setCurrentIndex(notes_index)
|
||||||
|
notes_job = queued[2]
|
||||||
|
metadata_job["on_error"](RuntimeError("Metadata unavailable"))
|
||||||
|
complete(notes_job)
|
||||||
|
assert dialog.drawer_banner.label.text() == dialog._dictionary_error_message
|
||||||
|
assert dialog.drawer_banner.label.text().startswith("病历选项加载失败")
|
||||||
|
dialog.edit_fields["chief_complaint"].setPlainText("Keep draft across retry")
|
||||||
|
if retry_from_banner:
|
||||||
|
dialog.drawer_banner.action_requested.emit()
|
||||||
|
else:
|
||||||
|
dialog.tabs.setCurrentIndex(0)
|
||||||
|
assert len(queued) == 4
|
||||||
|
complete(queued[3])
|
||||||
|
assert client.calls[-1][0] == "config/dict"
|
||||||
|
assert not dialog._dictionary_error_message
|
||||||
|
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep draft across retry"
|
||||||
|
assert dialog._authoritative_detail_loaded
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlled_latency_removes_sixteen_round_trips_from_detail_gate(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
client = Client(delay=0.02)
|
||||||
|
dialog = dialog_for(client)
|
||||||
|
try:
|
||||||
|
types = sorted({"diagnosis_type", *(row[0] for row in module._CHOICE_DICTIONARIES.values())})
|
||||||
|
started = time.perf_counter()
|
||||||
|
dialog.repository.get_diagnosis_detail(501)
|
||||||
|
for key in types:
|
||||||
|
dialog.repository.get_dictionary(key)
|
||||||
|
old_time = time.perf_counter() - started
|
||||||
|
old_calls = len(client.calls)
|
||||||
|
client.calls.clear()
|
||||||
|
started = time.perf_counter()
|
||||||
|
detail = dialog._load_bundle(501, "edit")
|
||||||
|
new_time = time.perf_counter() - started
|
||||||
|
assert detail["detail"]["id"] == 501
|
||||||
|
assert len(client.calls) == 1
|
||||||
|
dialog._load_dictionary_choices()
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
assert old_calls == 17
|
||||||
|
assert new_time < old_time / 4
|
||||||
|
print(f"controlled 20 ms RTT: old gate={old_time:.3f}s/17 calls; "
|
||||||
|
f"new gate={new_time:.3f}s/1 call; total new=2 calls")
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
@@ -356,8 +356,11 @@ def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
|||||||
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
|
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
|
||||||
assert thumb.property("loadState") == "ready"
|
assert thumb.property("loadState") == "ready"
|
||||||
assert thumb._rendered_pixmap.size() == QSize(64, 64)
|
assert thumb._rendered_pixmap.size() == QSize(64, 64)
|
||||||
|
previews: list[tuple[list[str], int]] = []
|
||||||
|
timeline.preview_images_requested.connect(lambda sources, index: previews.append((sources, index)))
|
||||||
thumb.click()
|
thumb.click()
|
||||||
assert opened == [tongue_url]
|
assert opened == []
|
||||||
|
assert previews == [([tongue_url], 0)]
|
||||||
|
|
||||||
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
|
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
|
||||||
assert len(remove_buttons) == 2
|
assert len(remove_buttons) == 2
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QBuffer, QCoreApplication, QEvent, QIODevice, QPoint, Qt
|
||||||
|
from PySide6.QtGui import QColor, QDesktopServices, QFont, QImage, QPainter
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from test_diagnosis_drawer_visual import (
|
||||||
|
ActionRepository,
|
||||||
|
_open_dialog,
|
||||||
|
_select_tab,
|
||||||
|
)
|
||||||
|
from test_diagnosis_drawer_visual import (
|
||||||
|
application as application,
|
||||||
|
)
|
||||||
|
from test_diagnosis_drawer_visual import (
|
||||||
|
immediate_async as immediate_async,
|
||||||
|
)
|
||||||
|
from test_diagnosis_media_thumbnail_visual import (
|
||||||
|
_FakeManager,
|
||||||
|
_hold_remote_load,
|
||||||
|
_png_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
from doctor_workstation.ui.diagnosis_drawer import _RemoteImageButton
|
||||||
|
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
|
||||||
|
from doctor_workstation.ui.theme import _register_preferred_cjk_fonts
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_image() -> bytes:
|
||||||
|
image = QImage(1600, 1200, QImage.Format.Format_ARGB32)
|
||||||
|
image.fill(QColor("#F4E3D8"))
|
||||||
|
painter = QPainter(image)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor("#C37B85"))
|
||||||
|
painter.drawEllipse(440, 160, 720, 900)
|
||||||
|
painter.setPen(QColor("#67434A"))
|
||||||
|
painter.setFont(QFont("Arial", 36))
|
||||||
|
painter.drawText(image.rect(), Qt.AlignmentFlag.AlignCenter, "SYNTHETIC TEST IMAGE")
|
||||||
|
painter.end()
|
||||||
|
buffer = QBuffer()
|
||||||
|
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||||
|
assert image.save(buffer, "PNG")
|
||||||
|
return bytes(buffer.data())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["edit", "readonly"])
|
||||||
|
def test_real_note_thumbnail_click_keeps_drawer_and_previews_all_eight_images(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch, mode: str,
|
||||||
|
) -> None:
|
||||||
|
"""Exercise the wired drawer click, not just the standalone lightbox class."""
|
||||||
|
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
|
||||||
|
external: list[str] = []
|
||||||
|
monkeypatch.setattr(QDesktopServices, "openUrl", lambda url: external.append(url.toString()) or True)
|
||||||
|
application.setFont(QFont(_register_preferred_cjk_fonts(), 10))
|
||||||
|
payload = _synthetic_image()
|
||||||
|
|
||||||
|
def offline_request(preview: ImagePreviewDialog, target: str) -> None:
|
||||||
|
preview._invalidate_request()
|
||||||
|
preview.apply_payload(payload, preview._generation)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ImagePreviewDialog, "_request", offline_request)
|
||||||
|
dialog = _open_dialog(application, (1440, 900), mode=mode, repository=ActionRepository())
|
||||||
|
urls = [f"https://media.example.invalid/tongue-{index}.jpg" for index in range(8)]
|
||||||
|
if mode == "edit":
|
||||||
|
_select_tab(dialog, "notes")
|
||||||
|
dialog._fill_notes([{"id": 7001, "note_date": "2026-09-08", "content": "合成测试图片", "tongue_images": urls}])
|
||||||
|
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
timeline = dialog.drawer_notes_timeline if mode == "edit" else dialog._notes_timelines[0]
|
||||||
|
application.processEvents()
|
||||||
|
thumbs = timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb")
|
||||||
|
assert len(thumbs) == 8
|
||||||
|
for thumb in thumbs:
|
||||||
|
assert thumb._apply_payload(payload, thumb._generation)
|
||||||
|
original_parent = dialog.parentWidget()
|
||||||
|
original_generation = dialog._generation
|
||||||
|
for index in (0, 7, 3):
|
||||||
|
QTest.mouseClick(thumbs[index], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
|
||||||
|
application.processEvents()
|
||||||
|
assert external == [], "Thumbnail click must not launch the system browser"
|
||||||
|
preview = dialog._image_preview
|
||||||
|
assert isinstance(preview, ImagePreviewDialog)
|
||||||
|
assert preview.isVisible() and preview.parentWidget() is dialog
|
||||||
|
assert dialog.isVisible() and dialog.parentWidget() is original_parent
|
||||||
|
assert dialog._generation == original_generation
|
||||||
|
assert preview.current_source == urls[index]
|
||||||
|
assert preview.sources == urls
|
||||||
|
assert not preview.canvas.pixmap().isNull()
|
||||||
|
fitted_width = preview.canvas.pixmap().width()
|
||||||
|
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert preview.canvas.pixmap().width() == 1600 > fitted_width
|
||||||
|
QTest.mouseClick(preview.next_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert preview.current_source == urls[(index + 1) % 8]
|
||||||
|
QTest.mouseClick(preview.previous_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert preview.current_source == urls[index]
|
||||||
|
if mode == "edit" and index == 7:
|
||||||
|
output = Path(__file__).resolve().parents[2] / "artifacts/patient-render-image-fix"
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
assert dialog.grab().save(str(output / "notes-eight-thumbnails.png"))
|
||||||
|
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
|
||||||
|
application.processEvents()
|
||||||
|
assert preview.grab().save(str(output / "notes-image-preview.png"))
|
||||||
|
if index == 3:
|
||||||
|
QTest.keyClick(preview, Qt.Key.Key_Escape)
|
||||||
|
else:
|
||||||
|
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.isVisible() and timeline.isVisible()
|
||||||
|
assert dialog._image_preview is None
|
||||||
|
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
assert external == []
|
||||||
|
QTest.mouseClick(thumbs[4], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
|
||||||
|
preview = dialog._image_preview
|
||||||
|
QTest.mouseClick(preview.external_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert external == [urls[4]]
|
||||||
|
dialog.reject()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog._image_preview is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("close_with_escape", [False, True])
|
||||||
|
def test_preview_close_cancels_pending_response(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch, close_with_escape: bool,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(ImagePreviewDialog, "_request", lambda self, target: None)
|
||||||
|
preview = ImagePreviewDialog(["https://media.example.invalid/pending.jpg"])
|
||||||
|
manager = _FakeManager(preview)
|
||||||
|
manager.queue(_png_bytes(80, 60))
|
||||||
|
preview._manager = manager
|
||||||
|
monkeypatch.undo()
|
||||||
|
preview.reload_current()
|
||||||
|
reply = manager.replies[-1]
|
||||||
|
generation = preview._generation
|
||||||
|
preview.show()
|
||||||
|
if close_with_escape:
|
||||||
|
QTest.keyClick(preview, Qt.Key.Key_Escape)
|
||||||
|
else:
|
||||||
|
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert reply.aborted
|
||||||
|
assert preview._generation > generation
|
||||||
|
reply.finished.emit()
|
||||||
|
assert preview._pixmap.isNull()
|
||||||
|
application.processEvents()
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, Qt
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||||
|
DiagnosisTableHost,
|
||||||
|
_blue_appointment_layout,
|
||||||
|
_blue_appointment_text,
|
||||||
|
_blue_font,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(12):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(identifier: int = 501) -> dict:
|
||||||
|
return {
|
||||||
|
"id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000,
|
||||||
|
"patient_name": "林晓岚", "gender": 2, "age": 46, "doctor_name": "陈医生(演示)",
|
||||||
|
"appointment_id": identifier + 2000, "has_appointment": 1, "appointment_status": 1,
|
||||||
|
"appointment_date": "2026-09-05", "appointment_time": "09:00-09:30",
|
||||||
|
"appointments": [
|
||||||
|
{"id": identifier + 2000, "status": 1, "appointment_date": "2026-09-05"},
|
||||||
|
{"id": identifier + 1900, "status": 3, "appointment_date": "2026-08-06"},
|
||||||
|
],
|
||||||
|
"diagnosis_confirmed": 1, "assistant_name": "周医助", "has_prescription": 0,
|
||||||
|
"unserved_days": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_fallback_requires_exact_current_appointment_id(application: QApplication) -> None:
|
||||||
|
row = _row()
|
||||||
|
before = copy.deepcopy(row)
|
||||||
|
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
||||||
|
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
||||||
|
)
|
||||||
|
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||||
|
"—", "2026-08-06 时间 —",
|
||||||
|
)
|
||||||
|
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||||
|
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
||||||
|
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
||||||
|
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||||
|
for missing_id in (0, "", None):
|
||||||
|
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
||||||
|
assert row == before
|
||||||
|
legacy = DiagnosisTableHost()
|
||||||
|
blue = DiagnosisTableHost(tech_blue=True)
|
||||||
|
for host in (legacy, blue):
|
||||||
|
host.set_rows([row])
|
||||||
|
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||||
|
assert blue.model.index(0, 4).data().splitlines() == [
|
||||||
|
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
||||||
|
]
|
||||||
|
assert blue.model.index(0, 9).data() == "—"
|
||||||
|
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||||
|
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||||
|
blue.set_rows([{**row, "appointments": []}])
|
||||||
|
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
||||||
|
legacy.close()
|
||||||
|
blue.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QApplication) -> None:
|
||||||
|
blue = DiagnosisTableHost(tech_blue=True)
|
||||||
|
legacy = DiagnosisTableHost()
|
||||||
|
row = _row()
|
||||||
|
single = _row(502)
|
||||||
|
single["appointments"] = single["appointments"][:1]
|
||||||
|
long = copy.deepcopy(single)
|
||||||
|
long["id"] = long["diagnosis_id"] = 503
|
||||||
|
long["appointments"][0]["doctor_name"] = "超长完整医生姓名以及完整门诊部门需要换行" * 2
|
||||||
|
long["latest_appointment_channel_text"] = "完整保留的挂号渠道说明" * 4
|
||||||
|
long["has_prescription"] = 1
|
||||||
|
long["followup_time_text"] = "2026-10-15 09:00-09:30"
|
||||||
|
long["followup_doctor_name"] = "复诊医生姓名"
|
||||||
|
long["followup_rx_voided"] = True
|
||||||
|
blue.set_rows([row, single, long])
|
||||||
|
legacy.set_rows([row])
|
||||||
|
assert blue.model.columnCount() == 12
|
||||||
|
assert [blue.main.columnWidth(i) for i in range(10)] == [48, 70, 82, 102, 244, 90, 84, 90, 88, 122]
|
||||||
|
assert blue.main.horizontalHeader().height() == 41
|
||||||
|
assert legacy.main.horizontalHeader().height() == 39
|
||||||
|
assert blue.main.rowHeight(0) == 108
|
||||||
|
assert blue.main.rowHeight(1) == 72
|
||||||
|
assert blue.main.rowHeight(2) > 110
|
||||||
|
assert legacy.main.rowHeight(0) == 96
|
||||||
|
for index in range(3):
|
||||||
|
assert blue.main.rowHeight(index) == blue.fixed.rowHeight(index)
|
||||||
|
assert _blue_font(14).family() == body_family()
|
||||||
|
assert _blue_font(14, medium=True).family() == heading_family()
|
||||||
|
assert _blue_font(14, medium=True).weight() == QFont.Weight.Medium
|
||||||
|
assert _blue_font(13).pixelSize() == 13
|
||||||
|
blue.close()
|
||||||
|
legacy.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||||
|
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
|
||||||
|
application: QApplication, tech_blue: bool,
|
||||||
|
) -> None:
|
||||||
|
window = QWidget()
|
||||||
|
layout = QVBoxLayout(window)
|
||||||
|
layout.setContentsMargins(7, 7, 7, 7)
|
||||||
|
host = DiagnosisTableHost(tech_blue=tech_blue)
|
||||||
|
layout.addWidget(host, 1)
|
||||||
|
footer = QLabel("始终可达的分页")
|
||||||
|
footer.setFixedHeight(42)
|
||||||
|
layout.addWidget(footer)
|
||||||
|
host.set_rows([_row(i) for i in range(40)])
|
||||||
|
window.resize(1280, 640)
|
||||||
|
window.show()
|
||||||
|
_settle(application)
|
||||||
|
minimum = window.minimumSizeHint().height()
|
||||||
|
for width, height in ((780, 480), (1440, 780), (800, 540), (1280, 640)) * 3:
|
||||||
|
window.resize(width, height)
|
||||||
|
_settle(application)
|
||||||
|
# The previous implementation wrote the available height into the
|
||||||
|
# fixed child's minimum and enlarged every ancestor on each relayout.
|
||||||
|
assert window.size().height() == height
|
||||||
|
assert window.minimumSizeHint().height() == minimum
|
||||||
|
assert host.fixed.minimumHeight() == 0
|
||||||
|
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||||
|
assert host.main.viewport().mapTo(host, QPoint()).y() == host.fixed.viewport().mapTo(host, QPoint()).y()
|
||||||
|
main_scroll, fixed_scroll = host.main.verticalScrollBar(), host.fixed.verticalScrollBar()
|
||||||
|
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||||
|
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||||
|
_settle(application)
|
||||||
|
assert main_scroll.value() == fixed_scroll.value()
|
||||||
|
fixed_scroll.setValue(fixed_scroll.maximum())
|
||||||
|
_settle(application)
|
||||||
|
assert main_scroll.value() == fixed_scroll.value()
|
||||||
|
frozen_x = host.fixed.x()
|
||||||
|
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||||
|
_settle(application)
|
||||||
|
assert host.fixed.x() == frozen_x
|
||||||
|
assert host.fixed.geometry().right() <= host.rect().right()
|
||||||
|
assert footer.mapTo(window, QPoint()).y() + footer.height() <= window.height()
|
||||||
|
window.close()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkbox_toggle_and_select_all_do_not_change_current_patient(application: QApplication) -> None:
|
||||||
|
host = DiagnosisTableHost(tech_blue=True)
|
||||||
|
host.resize(1300, 420)
|
||||||
|
host.set_rows([_row(501), _row(502)])
|
||||||
|
host.show()
|
||||||
|
_settle(application)
|
||||||
|
host.main.selectRow(1)
|
||||||
|
box = host.main.visualRect(host.model.index(1, 0)).center()
|
||||||
|
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||||
|
assert [row["id"] for row in host.selected_records()] == [502]
|
||||||
|
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||||
|
assert host.selected_records() == []
|
||||||
|
assert host.main.currentRow() == 1
|
||||||
|
header = host.main.horizontalHeader()
|
||||||
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||||
|
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||||
|
assert host.main.currentRow() == 1
|
||||||
|
host.main.selectRow(0)
|
||||||
|
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||||
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||||
|
assert host.selected_records() == []
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_appointment_cancel_hit_targets_and_video_gates(application: QApplication) -> None:
|
||||||
|
host = DiagnosisTableHost(tech_blue=True, action_policy={
|
||||||
|
"view": True, "edit": True, "appointment_cancel": True, "video_call": True,
|
||||||
|
})
|
||||||
|
row = _row()
|
||||||
|
# Make the second entry cancellable and the first doctor wrap: hit regions
|
||||||
|
# must follow measured content, not a legacy fixed 42-pixel multiplier.
|
||||||
|
row["appointments"][0]["doctor_name"] = "医生与门诊信息完整保留" * 3
|
||||||
|
row["appointments"][1]["status"] = 4
|
||||||
|
host.set_rows([row])
|
||||||
|
host.resize(1300, 500)
|
||||||
|
host.show()
|
||||||
|
_settle(application)
|
||||||
|
received = []
|
||||||
|
host.appointment_cancel_requested.connect(lambda record, identifier: received.append((record["id"], identifier)))
|
||||||
|
overlay = host.main.indexWidget(host.model.index(0, 4))
|
||||||
|
buttons = overlay.findChildren(QToolButton)
|
||||||
|
assert len(buttons) == 2
|
||||||
|
entries, total = _blue_appointment_layout(row, overlay.width())
|
||||||
|
for position, button in enumerate(buttons):
|
||||||
|
assert button.y() == max(9, (overlay.height() - total) // 2) + entries[position][0]
|
||||||
|
assert button.geometry().bottom() < overlay.height()
|
||||||
|
button.click()
|
||||||
|
assert received == [(501, 2501), (501, 2401)]
|
||||||
|
more = host.fixed.indexWidget(host.model.index(0, 11)).findChild(QToolButton, "DiagnosisRowMore")
|
||||||
|
assert "取消挂号" not in [action.text() for action in more.menu().actions()]
|
||||||
|
assert not host.fixed.indexWidget(host.model.index(0, 10)).findChildren(QToolButton)
|
||||||
|
row["video_call_hint"] = {"state": "live"}
|
||||||
|
host.set_rows([row])
|
||||||
|
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||||
|
assert video is not None and video.isEnabled()
|
||||||
|
row["patient_id"] = 0
|
||||||
|
host.set_rows([row])
|
||||||
|
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||||
|
assert video is not None and not video.isEnabled()
|
||||||
|
host.action_policy["appointment_cancel"] = False
|
||||||
|
host.set_rows([row])
|
||||||
|
assert host.main.indexWidget(host.model.index(0, 4)) is None
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_shell_navigation_has_no_fixed_height_stack_overflow(tmp_path: Path) -> None:
|
||||||
|
script = tmp_path / "shell_navigation.py"
|
||||||
|
script.write_text('''
|
||||||
|
import socket
|
||||||
|
from unittest.mock import patch
|
||||||
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||||
|
app = QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
def settle():
|
||||||
|
for _ in range(12):
|
||||||
|
QThreadPool.globalInstance().waitForDone(1000)
|
||||||
|
app.processEvents()
|
||||||
|
with patch.object(socket.socket, "connect", side_effect=RuntimeError("offline test")):
|
||||||
|
repo = DemoDoctorRepository()
|
||||||
|
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||||
|
shell = ShellWindow(repo, {"session": session, "demo_mode": True}, permissions=session.permissions)
|
||||||
|
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||||
|
shell.resize(1536, 960)
|
||||||
|
shell.show()
|
||||||
|
settle()
|
||||||
|
for route in ("consultations", "appointments", "consultations"):
|
||||||
|
shell.navigate(route)
|
||||||
|
settle()
|
||||||
|
host = shell.pages["consultations"].table_host
|
||||||
|
for width, height in ((1024, 640), (1536, 960), (1366, 768)):
|
||||||
|
shell.resize(width, height)
|
||||||
|
settle()
|
||||||
|
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||||
|
assert host.fixed.minimumHeight() == 0
|
||||||
|
assert shell.height() == height
|
||||||
|
shell.close()
|
||||||
|
settle()
|
||||||
|
print("native navigation geometry stable")
|
||||||
|
''', encoding="utf-8")
|
||||||
|
env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "DOCTOR_SMOKE_TEST": "1",
|
||||||
|
"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
|
||||||
|
result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace", env=env, timeout=90)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
assert "native navigation geometry stable" in result.stdout
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Regressions for the blank strip between query results and pinned actions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(app):
|
||||||
|
for _ in range(4):
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(15)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(count):
|
||||||
|
return [{"id": i + 1, "patient_name": f"演示患者{i + 1}", "age": 55,
|
||||||
|
"gender": 1, "has_appointment": 0, "diagnosis_confirmed": 1}
|
||||||
|
for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("blue", [True, False])
|
||||||
|
def test_filter_results_fill_viewport_through_scrollbar_and_window_changes(application, blue):
|
||||||
|
host = DiagnosisTableHost(tech_blue=blue, action_policy={"view": True, "edit": True})
|
||||||
|
host.resize(1650, 680)
|
||||||
|
host.show()
|
||||||
|
for width, count in ((1650, 70), (1650, 2), (1900, 2), (900, 2),
|
||||||
|
(1900, 0), (1900, 2), (1366, 80), (1650, 2)):
|
||||||
|
host.resize(width, 680)
|
||||||
|
host.set_rows(rows(count))
|
||||||
|
settle(application)
|
||||||
|
main = host.main
|
||||||
|
viewport = main.viewport()
|
||||||
|
base_width = sum(host.LEFT_WIDTHS)
|
||||||
|
assert main.horizontalHeader().length() == max(base_width, viewport.width())
|
||||||
|
assert all(main.columnWidth(i) >= minimum for i, minimum in enumerate(host.LEFT_WIDTHS))
|
||||||
|
assert main.viewport().height() == host.fixed.viewport().height()
|
||||||
|
assert main.verticalScrollBar().maximum() == host.fixed.verticalScrollBar().maximum()
|
||||||
|
if viewport.width() >= base_width:
|
||||||
|
assert main.horizontalScrollBar().maximum() == 0
|
||||||
|
if count:
|
||||||
|
main.selectRow(1)
|
||||||
|
settle(application)
|
||||||
|
row = main.visualRect(host.model.index(1, 9))
|
||||||
|
point = QPoint(viewport.width() - 4, row.bottom() - 5)
|
||||||
|
# A real data cell, rather than the QTableView's blank background,
|
||||||
|
# must occupy the entire strip leading into the pinned actions.
|
||||||
|
assert main.indexAt(point).row() == 1
|
||||||
|
assert main.indexAt(point).column() == 9
|
||||||
|
if blue:
|
||||||
|
capture = viewport.grab()
|
||||||
|
scale = capture.devicePixelRatio()
|
||||||
|
pixel = QPoint(round(point.x() * scale), round(point.y() * scale))
|
||||||
|
assert capture.toImage().pixelColor(pixel).name() == "#eaf2ff"
|
||||||
|
else:
|
||||||
|
assert main.horizontalScrollBar().maximum() > 0
|
||||||
|
main.horizontalScrollBar().setValue(main.horizontalScrollBar().maximum())
|
||||||
|
settle(application)
|
||||||
|
assert main.visualRect(host.model.index(0, 9)).right() < viewport.width()
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint
|
||||||
|
from PySide6.QtGui import QPalette
|
||||||
|
from PySide6.QtWidgets import QApplication, QDialog, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui import apply_theme
|
||||||
|
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, _SlotCard
|
||||||
|
from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS, DiagnosisTabWidget
|
||||||
|
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
|
||||||
|
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||||
|
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(application: QApplication) -> None:
|
||||||
|
for _ in range(10):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width", [600, 760, 860])
|
||||||
|
def test_prescription_form_keeps_date_gender_and_controls_readable(
|
||||||
|
application: QApplication, width: int
|
||||||
|
) -> None:
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(width, 640)
|
||||||
|
host.show()
|
||||||
|
editor = PrescriptionEditorDialog(
|
||||||
|
SimpleNamespace(list_medicines=lambda **kwargs: {"lists": [], "count": 0}),
|
||||||
|
{"diagnosis_id": 501, "patient_name": "林晓岚", "prescription_type": "浓缩水丸"},
|
||||||
|
parent=host,
|
||||||
|
)
|
||||||
|
editor.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
display = editor.date_edit._display
|
||||||
|
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||||
|
for button in (editor.gender_male, editor.gender_female):
|
||||||
|
assert button.width() >= button.sizeHint().width()
|
||||||
|
controls = [editor.patient_name, editor.age, editor.date_edit, editor.prescription_type, editor.times_per_day]
|
||||||
|
assert len({control.height() for control in controls}) == 1
|
||||||
|
assert editor.body_content.width() == editor.body_scroll.viewport().width()
|
||||||
|
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
|
||||||
|
footer_position = editor.footer.mapToGlobal(QPoint())
|
||||||
|
editor.body_scroll.verticalScrollBar().setValue(editor.body_scroll.verticalScrollBar().maximum())
|
||||||
|
settle(application)
|
||||||
|
assert editor.footer.mapToGlobal(QPoint()) == footer_position
|
||||||
|
assert editor.save_button.visibleRegion().boundingRect() == editor.save_button.rect()
|
||||||
|
# Changing prescription type keeps its conditional fields reachable after reflow.
|
||||||
|
editor.prescription_type.setCurrentText("饮片")
|
||||||
|
settle(application)
|
||||||
|
assert not editor._main_decoction_field.isHidden()
|
||||||
|
assert editor._main_bag_field.isHidden()
|
||||||
|
if width == 860:
|
||||||
|
for resized_width in (600, 860):
|
||||||
|
editor.resize(resized_width, 640)
|
||||||
|
settle(application)
|
||||||
|
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||||
|
assert editor._main_decoction_field.width() >= editor.need_decoction.minimumSizeHint().width() + 104
|
||||||
|
finally:
|
||||||
|
editor.close()
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_overflow_rail_leaves_selected_tab_underline_visible(application: QApplication) -> None:
|
||||||
|
tabs = DiagnosisTabWidget()
|
||||||
|
tabs.setObjectName("DiagnosisDrawerTabs")
|
||||||
|
tabs.setStyleSheet(DIAGNOSIS_QSS)
|
||||||
|
for title in ("病历", "医生备注", "日常记录", "处方", "业务订单", "视频录制回放", "聊天", "指派医助记录", "挂号记录"):
|
||||||
|
tabs.addTab(QWidget(), title)
|
||||||
|
tabs.resize(614, 480)
|
||||||
|
tabs.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
selected = tabs.tabBar().tabRect(tabs.currentIndex())
|
||||||
|
selected.moveTopLeft(tabs.tabBar().pos() + selected.topLeft())
|
||||||
|
assert tabs.tab_scrollbar.isVisible()
|
||||||
|
assert not selected.intersects(tabs.tab_scrollbar.geometry())
|
||||||
|
tabs.tab_scrollbar.setValue(tabs.count() - 1)
|
||||||
|
assert tabs.currentIndex() == tabs.count() - 1
|
||||||
|
finally:
|
||||||
|
tabs.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_appointment_slot_labels_follow_checked_and_disabled_states(application: QApplication) -> None:
|
||||||
|
dialog = QDialog()
|
||||||
|
dialog.setObjectName("AppointmentDrawerOverlay")
|
||||||
|
dialog.setStyleSheet(APPOINTMENT_DRAWER_QSS)
|
||||||
|
layout = QVBoxLayout(dialog)
|
||||||
|
card = _SlotCard("09:00-09:30", "可约")
|
||||||
|
card.setProperty("appointmentSlot", True)
|
||||||
|
card.setProperty("availability", "available")
|
||||||
|
card.setCheckable(True)
|
||||||
|
layout.addWidget(card)
|
||||||
|
dialog.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
for checked, expected in ((True, "#ffffff"), (False, "#1a1c1f")):
|
||||||
|
card.setChecked(checked)
|
||||||
|
settle(application)
|
||||||
|
assert card.time_label.palette().color(QPalette.ColorRole.WindowText).name() == expected
|
||||||
|
card.setChecked(True)
|
||||||
|
card.setEnabled(False)
|
||||||
|
settle(application)
|
||||||
|
assert card.time_label.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText).name() == "#8e8f90"
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_report_columns_keep_headings_at_the_same_text_baseline(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
def immediate(function, **kwargs):
|
||||||
|
kwargs["on_success"](function())
|
||||||
|
if kwargs.get("on_finished"):
|
||||||
|
kwargs["on_finished"]()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_module, "run_async", immediate)
|
||||||
|
report = {"summary": "复核参考", "possible_symptoms": ["乏力", "纳差", "睡眠不安"], "main_indications": "脾气不足", "efficacy": ["健脾", "益气", "养阴"], "suitable_people": ["需由医师辨证确认"]}
|
||||||
|
repository = SimpleNamespace(list_prescription_template_ai_reports=lambda template_id: {"prescription_id": template_id, "reports": [{"report_id": 1, "model_key": "qwen", "report": report}]})
|
||||||
|
dialog = PrescriptionAiReportDialog(repository, PermissionSet(["*"]))
|
||||||
|
dialog.open_for({"id": 1, "prescription_name": "测试方", "herbs": []})
|
||||||
|
dialog.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
labels = {label.text(): label for label in dialog.findChildren(QLabel) if label.objectName() == "PrescriptionAiSectionTitle"}
|
||||||
|
for left, right in (("可能症状与证候", "主治方向"), ("主要功效", "可能适用人群")):
|
||||||
|
# QLabel vertically centers text: compare the centers, not just widget origins.
|
||||||
|
centers = [labels[text].mapTo(dialog, labels[text].rect().center()).y() for text in (left, right)]
|
||||||
|
assert abs(centers[0] - centers[1]) <= 1
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.dialogs import ai_consult_picker, diagnosis, prescription
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def run(function: Any, *, on_success: Any, on_error: Any, on_finished: Any = None) -> None:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
on_success(result)
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
for module in (ai_consult_picker, diagnosis, prescription):
|
||||||
|
monkeypatch.setattr(module, "run_async", run)
|
||||||
|
|
||||||
|
|
||||||
|
class ListRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
self.fail_page = 0
|
||||||
|
|
||||||
|
def _list(self, **filters: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(filters)
|
||||||
|
page, size = filters["page_no"], filters["page_size"]
|
||||||
|
if page == self.fail_page:
|
||||||
|
raise RuntimeError("暂时无法加载")
|
||||||
|
start = (page - 1) * size + 1
|
||||||
|
return {
|
||||||
|
"lists": [
|
||||||
|
{
|
||||||
|
"id": number,
|
||||||
|
"diagnosis_id": number,
|
||||||
|
"patient_id": 1000 + number,
|
||||||
|
"patient_name": f"患者{number}",
|
||||||
|
"prescription_id": 77,
|
||||||
|
"prescription_name": f"处方{number}",
|
||||||
|
"herbs": [{"name": "白术", "dosage": 10}],
|
||||||
|
"order_no": f"ORDER{number}",
|
||||||
|
}
|
||||||
|
for number in range(start, min(start + size, 46))
|
||||||
|
],
|
||||||
|
"count": 45,
|
||||||
|
}
|
||||||
|
|
||||||
|
list_prescription_templates = _list
|
||||||
|
list_prescription_orders = _list
|
||||||
|
list_ai_patient_options = _list
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_to_bottom(table: Any) -> None:
|
||||||
|
scrollbar = table.verticalScrollBar()
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
QTest.qWait(70)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["template", "order", "ai"])
|
||||||
|
def test_scroll_appends_and_retains_selected_business_identity(
|
||||||
|
application: QApplication, immediate_async: None, kind: str
|
||||||
|
) -> None:
|
||||||
|
repository = ListRepository()
|
||||||
|
if kind == "template":
|
||||||
|
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||||
|
controller = dialog.infinite_list
|
||||||
|
elif kind == "order":
|
||||||
|
dialog = prescription.PrescriptionOrderListDialog(repository, prescription_id=77)
|
||||||
|
controller = dialog.infinite_list
|
||||||
|
else:
|
||||||
|
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||||
|
controller = dialog.pager
|
||||||
|
dialog.resize(980, 520)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
page_size = controller.page_size
|
||||||
|
assert dialog.table.rowCount() == page_size
|
||||||
|
assert not any(
|
||||||
|
button.text() in {"上一页", "下一页"} for button in dialog.findChildren(QPushButton)
|
||||||
|
)
|
||||||
|
dialog.table.selectRow(2)
|
||||||
|
scroll_to_bottom(dialog.table)
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert dialog.table.rowCount() == page_size * 2
|
||||||
|
assert dialog.table.currentRow() == 2
|
||||||
|
assert all(
|
||||||
|
dialog.table.item(row, column).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||||
|
for row in range(dialog.table.rowCount())
|
||||||
|
for column in range(dialog.table.columnCount())
|
||||||
|
)
|
||||||
|
repository.fail_page = 3
|
||||||
|
scroll_to_bottom(dialog.table)
|
||||||
|
assert controller.retry_button.isVisible()
|
||||||
|
assert dialog.table.rowCount() == page_size * 2
|
||||||
|
repository.fail_page = 0
|
||||||
|
controller.retry_button.click()
|
||||||
|
assert dialog.table.rowCount() == 45
|
||||||
|
assert not controller.has_more
|
||||||
|
assert dialog.table.currentRow() == 2
|
||||||
|
if kind == "template":
|
||||||
|
dialog.accept()
|
||||||
|
assert dialog.selected_template()["id"] == 3
|
||||||
|
elif kind == "ai":
|
||||||
|
dialog.accept()
|
||||||
|
assert dialog.selected_target().diagnosis_id == 3
|
||||||
|
assert dialog.selected_target().patient_id == 1003
|
||||||
|
else:
|
||||||
|
assert dialog.table.item(2, 0).data(Qt.ItemDataRole.UserRole)["id"] == 3
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_query_supersedes_pending_load_and_keeps_creator_scope(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
pending: list[tuple[Any, dict[str, Any]]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
prescription, "run_async", lambda function, **callbacks: pending.append((function, callbacks))
|
||||||
|
)
|
||||||
|
repository = ListRepository()
|
||||||
|
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
dialog.name_edit.setText("白术")
|
||||||
|
dialog.formula_combo.setCurrentIndex(1)
|
||||||
|
dialog.search()
|
||||||
|
assert len(pending) == 2
|
||||||
|
latest_function, latest_callbacks = pending[1]
|
||||||
|
latest_callbacks["on_success"](latest_function())
|
||||||
|
old_function, old_callbacks = pending[0]
|
||||||
|
old_callbacks["on_success"](old_function())
|
||||||
|
old_callbacks["on_error"](RuntimeError("过期错误"))
|
||||||
|
assert dialog.table.rowCount() == 15
|
||||||
|
assert not dialog.banner.isVisible()
|
||||||
|
assert repository.calls[0] == {
|
||||||
|
"page_no": 1,
|
||||||
|
"page_size": 15,
|
||||||
|
"prescription_name": "白术",
|
||||||
|
"formula_type": "主方",
|
||||||
|
"prescribing_creator_id": 42,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_picker_continues_when_first_page_has_no_valid_diagnosis_targets(
|
||||||
|
application: QApplication, immediate_async: None
|
||||||
|
) -> None:
|
||||||
|
class Repository(ListRepository):
|
||||||
|
def list_ai_patient_options(self, **filters: Any) -> dict[str, Any]:
|
||||||
|
result = self._list(**filters)
|
||||||
|
if filters["page_no"] == 1:
|
||||||
|
for row in result["lists"]:
|
||||||
|
row["id"] = -row["id"]
|
||||||
|
row["diagnosis_id"] = row["id"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
repository = Repository()
|
||||||
|
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
QTest.qWait(100)
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert dialog.table.rowCount() == 20
|
||||||
|
assert dialog.table.item(0, 3).text() == "21"
|
||||||
|
assert dialog.table.isVisible()
|
||||||
|
assert not dialog.empty_state.isVisible()
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_readonly_orders_scroll_and_switching_patient_resets_scope(
|
||||||
|
application: QApplication, immediate_async: None
|
||||||
|
) -> None:
|
||||||
|
repository = ListRepository()
|
||||||
|
dialog = diagnosis.DiagnosisDialog(
|
||||||
|
repository, permissions=PermissionSet(["tcm.diagnosis/patientOrders"])
|
||||||
|
)
|
||||||
|
dialog.open_for(
|
||||||
|
501, authoritative_detail={"id": 501, "patient_id": 301, "patient_name": "患者一"}
|
||||||
|
)
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
table = dialog._table_registry["orders"][0]
|
||||||
|
dialog.readonly_scroll.ensureWidgetVisible(table)
|
||||||
|
assert table.rowCount() == 10
|
||||||
|
assert dialog.orders_list.parentWidget() is dialog._readonly_sections["orders"]
|
||||||
|
table.selectRow(2)
|
||||||
|
scroll_to_bottom(table)
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
assert table.currentRow() == 2
|
||||||
|
assert table.item(2, 0).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||||
|
assert repository.calls[-1] == {
|
||||||
|
"page_no": 2,
|
||||||
|
"page_size": 10,
|
||||||
|
"patient_id": 301,
|
||||||
|
"context_diagnosis_id": 501,
|
||||||
|
"scene": "diagnosis_edit",
|
||||||
|
}
|
||||||
|
dialog.open_for(
|
||||||
|
502, authoritative_detail={"id": 502, "patient_id": 302, "patient_name": "患者二"}
|
||||||
|
)
|
||||||
|
assert table.rowCount() == 10
|
||||||
|
assert repository.calls[-1]["page_no"] == 1
|
||||||
|
assert repository.calls[-1]["patient_id"] == 302
|
||||||
|
assert repository.calls[-1]["context_diagnosis_id"] == 502
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Doctor choices remain explicit and transactional across mouse/search/keyboard use."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.ui.pages.prescriptions import DoctorMultiSelect, _DoctorSelectionDialog
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dialog(application):
|
||||||
|
parent = DoctorMultiSelect()
|
||||||
|
widget = _DoctorSelectionDialog({1: "医生甲", 2: "医生乙", 3: "医生丙"}, {1}, parent)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
parent.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def item_for(dialog, doctor_id):
|
||||||
|
return next(dialog.listing.item(i) for i in range(dialog.listing.count())
|
||||||
|
if dialog.listing.item(i).data(Qt.ItemDataRole.UserRole) == doctor_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_and_checkbox_toggle_once_and_keyboard(dialog, application):
|
||||||
|
item = item_for(dialog, 2)
|
||||||
|
rect = dialog.listing.visualItemRect(item)
|
||||||
|
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
|
||||||
|
assert dialog.selected_ids() == {1, 2}
|
||||||
|
assert dialog.count_label.text() == "已选 2 位医生"
|
||||||
|
QTest.mouseClick(dialog.listing.viewport(), Qt.MouseButton.LeftButton,
|
||||||
|
pos=QPoint(rect.left() + 20, rect.center().y()))
|
||||||
|
assert dialog.selected_ids() == {1}
|
||||||
|
dialog.listing.setCurrentItem(item)
|
||||||
|
dialog.listing.setFocus()
|
||||||
|
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
|
||||||
|
assert dialog.selected_ids() == {1, 2}
|
||||||
|
QTest.keyClick(dialog.listing, Qt.Key.Key_Space)
|
||||||
|
assert dialog.selected_ids() == {1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_preserves_checks_and_clear_includes_hidden(dialog):
|
||||||
|
dialog.search.setText("乙")
|
||||||
|
assert item_for(dialog, 1).isHidden()
|
||||||
|
assert not item_for(dialog, 2).isHidden()
|
||||||
|
assert dialog.selected_ids() == {1}
|
||||||
|
dialog.clear_button.click()
|
||||||
|
assert dialog.selected_ids() == set()
|
||||||
|
assert not dialog.clear_button.isEnabled()
|
||||||
|
dialog.search.clear()
|
||||||
|
assert not item_for(dialog, 1).isHidden()
|
||||||
|
|
||||||
|
|
||||||
|
def test_checked_row_blue_and_unchecked_white(dialog, application):
|
||||||
|
application.processEvents()
|
||||||
|
image = dialog.listing.viewport().grab().toImage()
|
||||||
|
scale = image.devicePixelRatio()
|
||||||
|
colors = []
|
||||||
|
for doctor_id in (1, 2):
|
||||||
|
rect = dialog.listing.visualItemRect(item_for(dialog, doctor_id))
|
||||||
|
colors.append(image.pixelColor(int((rect.right() - 70) * scale),
|
||||||
|
int(rect.center().y() * scale)).name())
|
||||||
|
assert colors == ["#eaf2ff", "#ffffff"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_commits_cancel_preserves_and_reopen_restores(application):
|
||||||
|
selector = DoctorMultiSelect()
|
||||||
|
selector.update_options([{"id": 1, "name": "医生甲"}, {"id": 2, "name": "医生乙"}])
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def choose(accept):
|
||||||
|
modal = application.activeModalWidget()
|
||||||
|
seen.append(modal.selected_ids())
|
||||||
|
item_for(modal, 2).setCheckState(Qt.CheckState.Checked)
|
||||||
|
modal.accept() if accept else modal.reject()
|
||||||
|
|
||||||
|
QTimer.singleShot(0, lambda: choose(False))
|
||||||
|
selector._choose()
|
||||||
|
assert selector.values() == []
|
||||||
|
QTimer.singleShot(0, lambda: choose(True))
|
||||||
|
selector._choose()
|
||||||
|
assert selector.values() == [2]
|
||||||
|
assert selector.button.text() == "医生乙"
|
||||||
|
QTimer.singleShot(0, lambda: choose(False))
|
||||||
|
selector._choose()
|
||||||
|
assert seen == [set(), set(), {2}]
|
||||||
|
assert selector.values() == [2]
|
||||||
|
selector.deleteLater()
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Search disclosure preserves queries, permissions, focus and usable list space."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.filter_disclosure import FilterDisclosure
|
||||||
|
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(app):
|
||||||
|
for _ in range(3):
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(35)
|
||||||
|
|
||||||
|
|
||||||
|
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
value = function()
|
||||||
|
if on_success:
|
||||||
|
on_success(value)
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_fold_preserves_hidden_permission_controls_and_focus(application):
|
||||||
|
host = QWidget()
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
panel = QWidget(host)
|
||||||
|
form = QVBoxLayout(panel)
|
||||||
|
field = QLineEdit("保留查询", panel)
|
||||||
|
forbidden = QPushButton("无权限操作", panel)
|
||||||
|
forbidden.hide()
|
||||||
|
form.addWidget(field)
|
||||||
|
form.addWidget(forbidden)
|
||||||
|
disclosure = FilterDisclosure(host, [panel])
|
||||||
|
layout.addWidget(disclosure.button)
|
||||||
|
layout.addWidget(panel)
|
||||||
|
host.show()
|
||||||
|
settle(application)
|
||||||
|
assert not panel.isVisible()
|
||||||
|
assert disclosure.button.height() == 32
|
||||||
|
disclosure.button.setFocus()
|
||||||
|
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||||
|
settle(application)
|
||||||
|
assert panel.isVisible() and not forbidden.isVisible()
|
||||||
|
field.setFocus()
|
||||||
|
disclosure.set_expanded(False)
|
||||||
|
assert QApplication.focusWidget() is disclosure.button
|
||||||
|
disclosure.set_expanded(True)
|
||||||
|
assert field.text() == "保留查询" and forbidden.isHidden()
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||||
|
@pytest.mark.parametrize("size", [(1328, 860), (1158, 690), (816, 620)])
|
||||||
|
def test_compact_default_expands_for_query_and_retains_values(application, monkeypatch, kind, size):
|
||||||
|
module = prescriptions if kind == "issued" else prescription_library
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
|
page_type = module.PrescriptionsPage if kind == "issued" else module.PrescriptionLibraryPage
|
||||||
|
page = page_type(repository, {"*"}, session.user)
|
||||||
|
page.resize(*size)
|
||||||
|
page.show()
|
||||||
|
settle(application)
|
||||||
|
disclosure = page.filter_disclosure
|
||||||
|
assert not disclosure.expanded and page.filter_card.isHidden()
|
||||||
|
assert page.header.height() == 44
|
||||||
|
assert page.header.breadcrumb_label.isHidden() and page.header.subtitle_label.isHidden()
|
||||||
|
assert disclosure.button.isVisible()
|
||||||
|
assert disclosure.button.height() == 32
|
||||||
|
assert page.header.rect().contains(disclosure.button.mapTo(page.header, QPoint(0, 0)))
|
||||||
|
compact_height = page.table.height()
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
assert disclosure.expanded and page.filter_card.isVisible()
|
||||||
|
assert compact_height - page.table.height() >= 100
|
||||||
|
field = page.sn_filter if kind == "issued" else page.name_filter
|
||||||
|
assert field.isVisible() and page.query_button.isVisible() and page.reset_button.isVisible()
|
||||||
|
field.setText("不存在的查询")
|
||||||
|
# Folding must not issue a request or clear an unfinished query.
|
||||||
|
generation = page._generation
|
||||||
|
disclosure.set_expanded(False)
|
||||||
|
settle(application)
|
||||||
|
assert page._generation == generation
|
||||||
|
assert field.text() == "不存在的查询"
|
||||||
|
disclosure.set_expanded(True)
|
||||||
|
QTest.keyClick(field, Qt.Key.Key_Return)
|
||||||
|
settle(application)
|
||||||
|
assert page._generation > generation
|
||||||
|
assert disclosure.expanded
|
||||||
|
disclosure.set_expanded(False)
|
||||||
|
page.refresh()
|
||||||
|
page.resize(size[0] + 10, size[1])
|
||||||
|
settle(application)
|
||||||
|
assert not disclosure.expanded and not page.filter_card.isVisible()
|
||||||
|
disclosure.set_expanded(True)
|
||||||
|
page.reset_button.click()
|
||||||
|
settle(application)
|
||||||
|
assert field.text() == "" and page.table.rowCount() == 2
|
||||||
|
for timer in page.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Contract for the shared icon system.
|
||||||
|
|
||||||
|
Before ``ui/icons.py`` existed the product drew its glyphs from ten independent
|
||||||
|
painters that disagreed on stroke weight, design grid and palette. These tests
|
||||||
|
lock in the properties that keep the set reading as one family, so a new glyph
|
||||||
|
cannot quietly reintroduce a one-off weight or a clipped mark.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtGui import QColor, QImage
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.ui import icons
|
||||||
|
from doctor_workstation.ui.theme import COLORS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _rendered(kind: str, size: int) -> QImage:
|
||||||
|
return icons.pixmap(kind, "strong", size).toImage()
|
||||||
|
|
||||||
|
|
||||||
|
def _ink_bounds(image: QImage) -> tuple[int, int, int, int, int]:
|
||||||
|
"""Return ``(left, top, right, bottom, count)`` of visibly painted pixels."""
|
||||||
|
|
||||||
|
left, top = image.width(), image.height()
|
||||||
|
right = bottom = -1
|
||||||
|
count = 0
|
||||||
|
for y in range(image.height()):
|
||||||
|
for x in range(image.width()):
|
||||||
|
if image.pixelColor(x, y).alpha() > 24:
|
||||||
|
count += 1
|
||||||
|
left, top = min(left, x), min(top, y)
|
||||||
|
right, bottom = max(right, x), max(bottom, y)
|
||||||
|
return left, top, right, bottom, count
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_glyph_paints_inside_its_box_at_every_shipped_size(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""No glyph may touch the edge of its pixmap.
|
||||||
|
|
||||||
|
Clipping is what the old painters did whenever a call site asked for a size
|
||||||
|
other than the one the geometry was authored against - the shell's ``ai``
|
||||||
|
star lost its companion dot, and the prescription icons were drawn with
|
||||||
|
16 px geometry inside 14 px and 15 px boxes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for kind in icons.available_kinds():
|
||||||
|
for size in (14, 16, 18, 20, 24):
|
||||||
|
image = _rendered(kind, size)
|
||||||
|
device = image.width() # honours the device pixel ratio
|
||||||
|
left, top, right, bottom, count = _ink_bounds(image)
|
||||||
|
|
||||||
|
assert count > 0, f"{kind}@{size} painted nothing"
|
||||||
|
assert left > 0 and top > 0, f"{kind}@{size} is clipped at the top/left"
|
||||||
|
assert right < device - 1 and bottom < device - 1, (
|
||||||
|
f"{kind}@{size} is clipped at the bottom/right"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphs_fill_a_consistent_share_of_the_optical_box(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""Every mark lives in the same safe area, so none reads over- or undersized.
|
||||||
|
|
||||||
|
The set this replaces mixed glyphs that spanned the full 18 px box with a
|
||||||
|
``close`` cross that spanned only 7 px, which is why the title bar controls
|
||||||
|
never looked like siblings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for kind in icons.available_kinds():
|
||||||
|
image = _rendered(kind, 24)
|
||||||
|
device = image.width()
|
||||||
|
left, top, right, bottom, _count = _ink_bounds(image)
|
||||||
|
extent = max(right - left, bottom - top) / device
|
||||||
|
|
||||||
|
assert 0.5 <= extent <= 0.95, f"{kind} fills {extent:.2f} of its box"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stroke_weight_is_one_formula_across_the_shipped_size_range() -> None:
|
||||||
|
assert icons.stroke_px(24) == pytest.approx(2.0)
|
||||||
|
assert icons.stroke_px(18) == pytest.approx(1.5)
|
||||||
|
assert icons.stroke_px(16) == pytest.approx(4.0 / 3.0)
|
||||||
|
# Clamped so a small icon stays visible and a large one does not turn slab.
|
||||||
|
assert icons.stroke_px(8) == pytest.approx(1.25)
|
||||||
|
assert icons.stroke_px(64) == pytest.approx(2.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_icons_are_cached_so_list_rows_do_not_repaint_them(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""A list page builds one icon per action button per row on every refresh.
|
||||||
|
|
||||||
|
Without the cache that is a fresh ``QPainter`` run per button; the pages in
|
||||||
|
this product ask for the same handful of (kind, colour, size) triples over
|
||||||
|
and over, so the cache turns a per-row cost into a per-process one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
icons.clear_cache()
|
||||||
|
for _ in range(50):
|
||||||
|
icons.icon("eye", "accent", 15)
|
||||||
|
icons.icon("trash", "danger", 15)
|
||||||
|
|
||||||
|
# Two glyphs, each painted once in its requested colour and once disabled.
|
||||||
|
assert icons._cached_pixmap.cache_info().misses == 4
|
||||||
|
# The remaining 98 calls are answered from the icon cache without painting.
|
||||||
|
assert icons._cached_icon.cache_info().hits == 98
|
||||||
|
|
||||||
|
|
||||||
|
def test_colour_roles_resolve_to_the_palette_not_to_per_call_site_hexes() -> None:
|
||||||
|
assert icons.resolve_color("accent") == COLORS["indigo"]
|
||||||
|
assert icons.resolve_color("danger") == COLORS["danger"]
|
||||||
|
assert icons.resolve_color("muted") == COLORS["muted"]
|
||||||
|
# A literal colour still passes through for the few bespoke tints that remain.
|
||||||
|
assert icons.resolve_color("#8268E8") == "#8268E8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_icon_carries_its_own_selected_and_disabled_pixmaps(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""Navigation rows invert on selection, so the icon has to invert with them."""
|
||||||
|
|
||||||
|
icon = icons.state_icon("patients", size=18, normal="muted", checked="inverse")
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
|
||||||
|
off = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.Off).toImage()
|
||||||
|
on = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On).toImage()
|
||||||
|
|
||||||
|
assert off != on
|
||||||
|
assert _dominant_ink(on) == QColor("#FFFFFF")
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_ink(image: QImage) -> QColor:
|
||||||
|
for y in range(image.height()):
|
||||||
|
for x in range(image.width()):
|
||||||
|
colour = image.pixelColor(x, y)
|
||||||
|
if colour.alpha() > 240:
|
||||||
|
colour.setAlpha(255)
|
||||||
|
return colour
|
||||||
|
raise AssertionError("no opaque pixel found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_glyphs_are_visually_distinct(application: QApplication) -> None:
|
||||||
|
"""Six sidebar entries need six silhouettes.
|
||||||
|
|
||||||
|
The reception detail tabs previously shared three near-identical
|
||||||
|
"document with lines" marks, which made them unreadable at 16 px.
|
||||||
|
"""
|
||||||
|
|
||||||
|
renders = {
|
||||||
|
kind: _rendered(kind, 24).constBits().tobytes()
|
||||||
|
for kind in (
|
||||||
|
"reception",
|
||||||
|
"appointments",
|
||||||
|
"prescription_library",
|
||||||
|
"prescriptions",
|
||||||
|
"patients",
|
||||||
|
"consultations",
|
||||||
|
"report",
|
||||||
|
"meds",
|
||||||
|
"daily",
|
||||||
|
"followup",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(set(renders.values())) == len(renders)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Incremental requests retain business rows and survive query changes/errors."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||||
|
from doctor_workstation.ui.widgets import get_value, page_items
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
class Requests:
|
||||||
|
def __init__(self):
|
||||||
|
self.pending = []
|
||||||
|
|
||||||
|
def __call__(self, function, **callbacks):
|
||||||
|
self.pending.append((function, callbacks))
|
||||||
|
|
||||||
|
def finish(self, index=0, error=None):
|
||||||
|
fn, cb = self.pending.pop(index)
|
||||||
|
if error:
|
||||||
|
cb["on_error"](error)
|
||||||
|
else:
|
||||||
|
cb["on_success"](fn())
|
||||||
|
cb["on_finished"]()
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app, size=2):
|
||||||
|
host = QWidget()
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
table = QTableWidget(0, 2)
|
||||||
|
footer = InfiniteList(size)
|
||||||
|
footer.bind(table)
|
||||||
|
layout.addWidget(table)
|
||||||
|
layout.addWidget(footer)
|
||||||
|
host.resize(420, 220)
|
||||||
|
seen = []
|
||||||
|
errors = []
|
||||||
|
jobs = Requests()
|
||||||
|
|
||||||
|
def apply(result):
|
||||||
|
rows = page_items(result)
|
||||||
|
seen.append(result)
|
||||||
|
table.setRowCount(len(rows))
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
for col in range(2):
|
||||||
|
item = QTableWidgetItem(str(row["id"]))
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||||
|
if col == 1:
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(Qt.CheckState.Unchecked)
|
||||||
|
table.setItem(i, col, item)
|
||||||
|
|
||||||
|
return host, table, footer, seen, errors, jobs, apply
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_deduplicates_and_preserves_selection_checks_metadata(app):
|
||||||
|
host, table, f, seen, errors, jobs, apply = setup(app)
|
||||||
|
pages = {
|
||||||
|
1: {"items": [{"id": 1}, {"id": 2}], "total": 4, "extend": {"scope": "mine"}},
|
||||||
|
2: {"items": [{"id": 2}, {"id": 3}], "total": 4},
|
||||||
|
3: {"items": [{"id": 4}], "total": 4},
|
||||||
|
}
|
||||||
|
f.reload(lambda p: pages[p], apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
table.selectRow(1)
|
||||||
|
table.item(1, 1).setCheckState(Qt.CheckState.Checked)
|
||||||
|
f.load_more()
|
||||||
|
f.load_more()
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
jobs.finish()
|
||||||
|
assert [r["id"] for r in f.rows] == [1, 2, 3]
|
||||||
|
assert table.currentRow() == 1 and table.item(1, 1).checkState() == Qt.CheckState.Checked
|
||||||
|
assert get_value(seen[-1], "extend.scope") == "mine"
|
||||||
|
f.load_more()
|
||||||
|
jobs.finish()
|
||||||
|
assert not f.has_more
|
||||||
|
assert table.rowCount() == 4 and not errors
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_query_discards_old_success_and_error(app):
|
||||||
|
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||||
|
f.reload(
|
||||||
|
lambda p: {"items": [{"id": 1}], "total": 1},
|
||||||
|
apply,
|
||||||
|
errors.append,
|
||||||
|
runner=jobs,
|
||||||
|
query_key="a",
|
||||||
|
)
|
||||||
|
f.reload(
|
||||||
|
lambda p: {"items": [{"id": 2}], "total": 1},
|
||||||
|
apply,
|
||||||
|
errors.append,
|
||||||
|
runner=jobs,
|
||||||
|
query_key="b",
|
||||||
|
)
|
||||||
|
jobs.finish(1)
|
||||||
|
jobs.finish(0, error=RuntimeError("stale"))
|
||||||
|
assert f.rows == [{"id": 2}] and not errors and not f.loading
|
||||||
|
f.invalidate()
|
||||||
|
assert not f.has_more
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_failure_retry_and_empty_end_are_bounded(app):
|
||||||
|
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fetch(p):
|
||||||
|
calls.append(p)
|
||||||
|
return {"items": [{"id": 1}, {"id": 2}] if p == 1 else [], "total": 8}
|
||||||
|
|
||||||
|
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
f.load_more()
|
||||||
|
jobs.finish(error=RuntimeError("offline"))
|
||||||
|
assert t.rowCount() == 2 and f.page == 1 and not f.retry_button.isHidden()
|
||||||
|
f.load_more()
|
||||||
|
assert not jobs.pending
|
||||||
|
f.retry()
|
||||||
|
jobs.finish()
|
||||||
|
assert f.page == 2 and not f.has_more
|
||||||
|
assert calls == [1, 2] and len(errors) == 1
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_retains_loaded_prefix_until_complete(app):
|
||||||
|
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fetch(p):
|
||||||
|
calls.append(p)
|
||||||
|
return {"items": [{"id": p * 2 - 1}, {"id": p * 2}], "total": 6}
|
||||||
|
|
||||||
|
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
f.load_more()
|
||||||
|
jobs.finish()
|
||||||
|
assert t.rowCount() == 4
|
||||||
|
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
assert t.rowCount() == 4 and f.loading
|
||||||
|
jobs.finish()
|
||||||
|
assert t.rowCount() == 4 and f.page == 2
|
||||||
|
assert calls == [1, 2, 1, 2]
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_autofill_then_load_at_bottom(app):
|
||||||
|
host, t, f, seen, errors, jobs, apply = setup(app, size=12)
|
||||||
|
|
||||||
|
def fetch(p):
|
||||||
|
return {"items": [{"id": i} for i in range((p - 1) * 12, p * 12)], "total": 36}
|
||||||
|
|
||||||
|
host.show()
|
||||||
|
app.processEvents()
|
||||||
|
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(60)
|
||||||
|
assert not jobs.pending
|
||||||
|
t.verticalScrollBar().setValue(t.verticalScrollBar().maximum())
|
||||||
|
QTest.qWait(60)
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
old = t.verticalScrollBar().value()
|
||||||
|
jobs.finish()
|
||||||
|
assert t.verticalScrollBar().value() == old and t.rowCount() == 24
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_during_slow_request_keeps_work_and_updates_consumer_callbacks(app):
|
||||||
|
host, table, footer, seen, errors, jobs, apply = setup(app)
|
||||||
|
completed = []
|
||||||
|
|
||||||
|
def fetch(page):
|
||||||
|
return {"items": [{"id": page * 2 - 1}, {"id": page * 2}], "total": 6}
|
||||||
|
|
||||||
|
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
footer.reload(
|
||||||
|
fetch,
|
||||||
|
apply,
|
||||||
|
errors.append,
|
||||||
|
runner=jobs,
|
||||||
|
query_key="a",
|
||||||
|
on_finished=lambda: completed.append("latest"),
|
||||||
|
)
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
jobs.finish()
|
||||||
|
assert completed == ["latest"] and table.rowCount() == 2
|
||||||
|
footer.load_more()
|
||||||
|
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
jobs.finish()
|
||||||
|
assert table.rowCount() == 4 and footer.page == 2
|
||||||
|
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish() # Prefix page 2 now pending.
|
||||||
|
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
jobs.finish()
|
||||||
|
assert footer.page == 2 and not footer.loading and table.rowCount() == 4
|
||||||
|
host.close()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, QTimer
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui import apply_theme
|
||||||
|
from doctor_workstation.ui.pages import (
|
||||||
|
appointments,
|
||||||
|
consultations,
|
||||||
|
patients,
|
||||||
|
prescription_library,
|
||||||
|
prescriptions,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.widgets import PageHeader
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def immediate(function: Any, *args: Any, on_success=None, on_error=None,
|
||||||
|
on_finished=None, **kwargs: Any) -> object:
|
||||||
|
try:
|
||||||
|
result = function(*args, **kwargs)
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
return object()
|
||||||
|
|
||||||
|
modules = (appointments, consultations, patients, prescription_library, prescriptions)
|
||||||
|
for module in modules:
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(kind: str, width: int = 1040):
|
||||||
|
repo = DemoDoctorRepository()
|
||||||
|
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||||
|
classes = {
|
||||||
|
"appointments": appointments.AppointmentsPage,
|
||||||
|
"patients": patients.PatientsPage,
|
||||||
|
"consultations": consultations.ConsultationsPage,
|
||||||
|
"prescriptions": prescriptions.PrescriptionsPage,
|
||||||
|
"prescription_library": prescription_library.PrescriptionLibraryPage,
|
||||||
|
}
|
||||||
|
page = classes[kind](repo, permissions=session.permissions, current_user=session.user)
|
||||||
|
opened.append(page)
|
||||||
|
page.resize(width, 700)
|
||||||
|
page.show()
|
||||||
|
page.refresh()
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
return page
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for page in opened:
|
||||||
|
for timer in page.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["appointments", "patients", "consultations", "prescriptions", "prescription_library"])
|
||||||
|
def test_list_header_fits_the_real_bundled_font(page_factory, kind: str) -> None:
|
||||||
|
page = page_factory(kind)
|
||||||
|
header = page.findChild(PageHeader)
|
||||||
|
assert header is not None
|
||||||
|
for label in header.findChildren(QLabel):
|
||||||
|
if label.isVisible() and label.text() and label.property("role"):
|
||||||
|
assert label.height() >= label.fontMetrics().height(), (
|
||||||
|
kind, label.text(), label.height(), label.fontMetrics().height()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["patients", "prescription_library"])
|
||||||
|
@pytest.mark.parametrize("width", [1040, 1200])
|
||||||
|
def test_row_actions_are_fully_reachable_at_each_width(page_factory, kind, width):
|
||||||
|
page = page_factory(kind, width)
|
||||||
|
table = page.patient_workspace.table if kind == "patients" else page.table
|
||||||
|
assert table.rowCount() > 0
|
||||||
|
assert table.horizontalHeader().visualIndex(9) == 9
|
||||||
|
actions = table.cellWidget(0, 9)
|
||||||
|
assert actions is not None
|
||||||
|
assert table.horizontalScrollBar().value() == 0
|
||||||
|
# Both approved layouts put actions after the nine data columns. Preserve
|
||||||
|
# full-size controls and the existing reachability assertion after scrolling.
|
||||||
|
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert actions.geometry().left() >= 0
|
||||||
|
assert actions.geometry().right() < table.viewport().width()
|
||||||
|
assert actions.height() >= actions.minimumSizeHint().height()
|
||||||
|
for child in actions.findChildren(QPushButton):
|
||||||
|
assert actions.rect().contains(child.mapTo(actions, QPoint()))
|
||||||
|
assert actions.rect().contains(child.mapTo(actions, child.rect().bottomRight()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_appointment_detail_lines_and_cancel_fit_without_overlapping(page_factory) -> None:
|
||||||
|
page = page_factory("appointments")
|
||||||
|
table = page.table
|
||||||
|
assert table.rowCount() > 0
|
||||||
|
host = table.cellWidget(0, 4)
|
||||||
|
assert host.width() >= host.minimumSizeHint().width()
|
||||||
|
assert host.height() >= host.minimumSizeHint().height()
|
||||||
|
for label in host.findChildren(QLabel):
|
||||||
|
assert label.height() >= label.fontMetrics().height()
|
||||||
|
assert host.rect().contains(label.geometry())
|
||||||
|
cancel = next(button for button in host.findChildren(QPushButton) if button.text() == "取消")
|
||||||
|
for label in host.findChildren(QLabel):
|
||||||
|
assert not label.geometry().intersects(cancel.geometry())
|
||||||
|
# Underlying text is still available to sorting while a delegate paints only
|
||||||
|
# the selection surface beneath the actual controls.
|
||||||
|
assert table.item(0, 4).text()
|
||||||
|
assert isinstance(table.itemDelegateForColumn(4), appointments._AppointmentInfoDelegate)
|
||||||
|
assert table.horizontalScrollBar().maximum() == 0
|
||||||
|
search = page.findChild(QPushButton, "AppointmentSearchButton")
|
||||||
|
assert search.height() == page.patient_input.height()
|
||||||
|
assert all(button.height() == search.height() for button in page.date_buttons.values() if button.isVisible())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["patients", "prescriptions", "prescription_library", "consultations"])
|
||||||
|
def test_filter_actions_align_with_their_inputs(page_factory, kind: str) -> None:
|
||||||
|
page = page_factory(kind)
|
||||||
|
if kind == "patients":
|
||||||
|
controls = page.patient_workspace
|
||||||
|
widgets = (controls.keyword_edit, controls.search_button, controls.reset_button)
|
||||||
|
statuses = tuple(controls.status_buttons.values())
|
||||||
|
assert len({widget.height() for widget in statuses}) == 1
|
||||||
|
assert len({widget.mapTo(controls.status_host, QPoint()).y() for widget in statuses}) == 1
|
||||||
|
for widget in statuses:
|
||||||
|
assert controls.status_host.rect().contains(widget.geometry())
|
||||||
|
assert widget.height() >= widget.fontMetrics().height()
|
||||||
|
elif kind == "prescriptions":
|
||||||
|
widgets = (page.sn_filter, page.query_button, page.reset_button, page.doctor_filter.button)
|
||||||
|
elif kind == "prescription_library":
|
||||||
|
widgets = (page.name_filter, page.query_button, page.reset_button)
|
||||||
|
else:
|
||||||
|
widgets = (page.search_button, page.reset_button, page.custom_date_edit)
|
||||||
|
assert len({widget.height() for widget in widgets}) == 1
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Contract for the shared motion system.
|
||||||
|
|
||||||
|
The product previously had no `QPropertyAnimation` at all, so these tests exist
|
||||||
|
to keep the two things that make added motion a liability from creeping back:
|
||||||
|
an animation that outlives or destroys the object it is animating, and a
|
||||||
|
graphics effect left attached after a fade, which would quietly move a whole
|
||||||
|
subtree onto Qt's offscreen composite path for the rest of the session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
# The module disables itself under the offscreen platform so widget grabs in the
|
||||||
|
# other suites capture settled frames; these tests are about the animation, so
|
||||||
|
# they opt back in.
|
||||||
|
os.environ["DOCTOR_MOTION"] = "on"
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
|
||||||
|
from PySide6.QtGui import QWheelEvent
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QLabel,
|
||||||
|
QScrollArea,
|
||||||
|
QStackedWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from doctor_workstation.ui import motion
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _stack(application: QApplication) -> tuple[QWidget, QStackedWidget, QLabel]:
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(320, 120)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
stack = QStackedWidget()
|
||||||
|
first, second = QLabel("A"), QLabel("B")
|
||||||
|
stack.addWidget(first)
|
||||||
|
stack.addWidget(second)
|
||||||
|
layout.addWidget(stack)
|
||||||
|
host.show()
|
||||||
|
application.processEvents()
|
||||||
|
return host, stack, second
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_transition_fades_and_settles_upward(application: QApplication) -> None:
|
||||||
|
host, stack, incoming = _stack(application)
|
||||||
|
origin = incoming.pos()
|
||||||
|
|
||||||
|
motion.switch_stack(stack, 1)
|
||||||
|
|
||||||
|
animations = incoming._doctor_motion
|
||||||
|
rise, fade = animations["enter"], animations["fade"]
|
||||||
|
assert rise.duration() == fade.duration() == motion.BASE
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
for at in (0, motion.BASE // 2, motion.BASE - 1):
|
||||||
|
rise.setCurrentTime(at)
|
||||||
|
fade.setCurrentTime(at)
|
||||||
|
samples.append((incoming.pos().y() - origin.y(), incoming.graphicsEffect().opacity()))
|
||||||
|
|
||||||
|
offsets = [offset for offset, _ in samples]
|
||||||
|
opacities = [opacity for _, opacity in samples]
|
||||||
|
assert offsets == sorted(offsets, reverse=True), "the page must settle downward-to-up"
|
||||||
|
assert opacities == sorted(opacities), "opacity must rise monotonically"
|
||||||
|
assert offsets[0] == motion.RISE and opacities[0] == pytest.approx(0.0)
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fade_detaches_its_graphics_effect_when_it_finishes(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""A left-behind opacity effect is a permanent frame-rate tax, not a leak."""
|
||||||
|
|
||||||
|
host, stack, incoming = _stack(application)
|
||||||
|
motion.switch_stack(stack, 1)
|
||||||
|
assert incoming.graphicsEffect() is not None
|
||||||
|
|
||||||
|
fade = incoming._doctor_motion["fade"]
|
||||||
|
fade.setCurrentTime(fade.duration())
|
||||||
|
# The detach is deferred by one event-loop turn on purpose, so that the
|
||||||
|
# effect is not destroyed from inside the signal it is emitting.
|
||||||
|
application.processEvents()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert incoming.graphicsEffect() is None
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_motion_can_be_turned_off_without_leaving_widgets_mid_state(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("DOCTOR_MOTION", "off")
|
||||||
|
assert motion.reduced_motion()
|
||||||
|
|
||||||
|
host, stack, incoming = _stack(application)
|
||||||
|
origin = incoming.pos()
|
||||||
|
motion.switch_stack(stack, 1)
|
||||||
|
|
||||||
|
assert stack.currentIndex() == 1
|
||||||
|
assert incoming.pos() == origin
|
||||||
|
assert incoming.graphicsEffect() is None
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _scroll_area(application: QApplication) -> QScrollArea:
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidgetResizable(True)
|
||||||
|
area.setWidget(QLabel("\n".join(f"line {index}" for index in range(300))))
|
||||||
|
area.resize(300, 200)
|
||||||
|
area.show()
|
||||||
|
application.processEvents()
|
||||||
|
return area
|
||||||
|
|
||||||
|
|
||||||
|
def _wheel(delta: int) -> QWheelEvent:
|
||||||
|
return QWheelEvent(
|
||||||
|
QPointF(50, 50),
|
||||||
|
QPointF(50, 50),
|
||||||
|
QPoint(0, 0),
|
||||||
|
QPoint(0, delta),
|
||||||
|
Qt.MouseButton.NoButton,
|
||||||
|
Qt.KeyboardModifier.NoModifier,
|
||||||
|
Qt.ScrollPhase.NoScrollPhase,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_scrolling_is_eased_rather_than_jumped(application: QApplication) -> None:
|
||||||
|
area = _scroll_area(application)
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
scroller = area._doctor_smooth_scroll
|
||||||
|
bar = area.verticalScrollBar()
|
||||||
|
|
||||||
|
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||||
|
animation = scroller._animation
|
||||||
|
assert animation.endValue() == motion.SCROLL_STEP
|
||||||
|
|
||||||
|
values = []
|
||||||
|
for at in (0, 60, 120, animation.duration() - 1):
|
||||||
|
animation.setCurrentTime(at)
|
||||||
|
values.append(bar.value())
|
||||||
|
|
||||||
|
assert values[0] == 0
|
||||||
|
assert values == sorted(values)
|
||||||
|
assert values[-1] < motion.SCROLL_STEP, "an eased curve never reaches its end early"
|
||||||
|
animation.stop()
|
||||||
|
area.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_at_either_end_is_handed_back_to_the_enclosing_area(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""Swallowing the wheel at the extremes is what makes nested panes feel stuck."""
|
||||||
|
|
||||||
|
area = _scroll_area(application)
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
scroller = area._doctor_smooth_scroll
|
||||||
|
bar = area.verticalScrollBar()
|
||||||
|
|
||||||
|
bar.setValue(bar.minimum())
|
||||||
|
application.processEvents()
|
||||||
|
assert not scroller.eventFilter(area.viewport(), _wheel(120))
|
||||||
|
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||||
|
|
||||||
|
scroller._animation.stop()
|
||||||
|
bar.setValue(bar.maximum())
|
||||||
|
application.processEvents()
|
||||||
|
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||||
|
area.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_gestures_are_left_alone(application: QApplication) -> None:
|
||||||
|
area = _scroll_area(application)
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
scroller = area._doctor_smooth_scroll
|
||||||
|
|
||||||
|
ctrl_wheel = QWheelEvent(
|
||||||
|
QPointF(50, 50),
|
||||||
|
QPointF(50, 50),
|
||||||
|
QPoint(0, 0),
|
||||||
|
QPoint(0, -120),
|
||||||
|
Qt.MouseButton.NoButton,
|
||||||
|
Qt.KeyboardModifier.ControlModifier,
|
||||||
|
Qt.ScrollPhase.NoScrollPhase,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not scroller.eventFilter(area.viewport(), ctrl_wheel)
|
||||||
|
area.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_is_idempotent(application: QApplication) -> None:
|
||||||
|
area = _scroll_area(application)
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
first = area._doctor_smooth_scroll
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
|
||||||
|
assert area._doctor_smooth_scroll is first
|
||||||
|
area.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""A QScrollArea should not have to opt in page by page."""
|
||||||
|
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
apply_theme(application)
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidget(QLabel("content"))
|
||||||
|
area.show()
|
||||||
|
area.ensurePolished()
|
||||||
|
application.sendEvent(area, QEvent(QEvent.Type.Polish))
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert getattr(area, "_doctor_smooth_scroll", None) is not None
|
||||||
|
area.close()
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
"""Order contracts at risk when filters, summary and table receive the blue layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
|
from doctor_workstation.ui.pages.patients import PatientOrdersWorkspace, PatientsPage
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[dict[str, Any]] = []
|
||||||
|
self.rows = [
|
||||||
|
{
|
||||||
|
"id": 901 + index,
|
||||||
|
"order_no": f"PO2026081000{index + 1}",
|
||||||
|
"patient_name": name,
|
||||||
|
"patient_phone_masked": f"186****482{index}",
|
||||||
|
"recipient_phone": f"1860000482{index}",
|
||||||
|
"prescription_id": 801 + index,
|
||||||
|
"diagnosis_id": 501 + index,
|
||||||
|
"amount": 368 + index,
|
||||||
|
"effective_amount": 368 + index,
|
||||||
|
"prescription_audit_status": 1,
|
||||||
|
"payment_slip_audit_status": 1,
|
||||||
|
"fulfillment_status": 2,
|
||||||
|
"assistant_name": "周医助",
|
||||||
|
"doctor_name": "陈医生(演示)",
|
||||||
|
"creator_name": "周医助",
|
||||||
|
# Source-only values must not fill display fields that are absent.
|
||||||
|
"create_time": "2026-09-05 10:15:00",
|
||||||
|
"pay_orders": [{"id": 2001 + index, "pay_amount": 368 + index}],
|
||||||
|
}
|
||||||
|
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||||
|
]
|
||||||
|
self.total = 47
|
||||||
|
self.summary = {
|
||||||
|
"orders": 47,
|
||||||
|
"amount": 12368.5,
|
||||||
|
"pending": 8,
|
||||||
|
"completed": 9,
|
||||||
|
"rejected": 2,
|
||||||
|
"rejection_rate": 4.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.queries.append(query)
|
||||||
|
return {
|
||||||
|
"lists": deepcopy(self.rows),
|
||||||
|
"count": self.total,
|
||||||
|
"extend": {
|
||||||
|
"scope": {"label": "测试部门订单范围"},
|
||||||
|
"summary": deepcopy(self.summary),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"lists": [{"id": 501, "diagnosis_id": 501, "patient_name": "阿青"}],
|
||||||
|
"count": 1,
|
||||||
|
"extend": {"scope": {"label": "测试患者范围"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def patient_progress(self, **_query: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"lists": [],
|
||||||
|
"count": 0,
|
||||||
|
"extend": {"scope": {"label": "测试面诊范围"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _click(widget, application: QApplication) -> None:
|
||||||
|
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def _rect_in(widget, parent) -> QRect:
|
||||||
|
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||||
|
|
||||||
|
|
||||||
|
def _banner_text(workspace: PatientOrdersWorkspace) -> str:
|
||||||
|
return " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args: Any, **_kwargs: Any):
|
||||||
|
pytest.fail("Order visual tests must use only local fixture data")
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(*, permissions=("*",), width=1270, height=680, page=False):
|
||||||
|
repository = _Repository()
|
||||||
|
widget = (
|
||||||
|
PatientsPage(repository, permissions=PermissionSet(list(permissions)))
|
||||||
|
if page
|
||||||
|
else PatientOrdersWorkspace(repository, PermissionSet(list(permissions)))
|
||||||
|
)
|
||||||
|
opened.append(widget)
|
||||||
|
widget.resize(width, height)
|
||||||
|
widget.show()
|
||||||
|
if not page:
|
||||||
|
widget.refresh()
|
||||||
|
_settle(application)
|
||||||
|
return widget, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for widget in opened:
|
||||||
|
for timer in widget.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_defaults_and_reset_keep_edited_dates_but_remove_query_limit(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
default_query = {
|
||||||
|
"keyword": "",
|
||||||
|
"prescription_audit_status": None,
|
||||||
|
"payment_slip_audit_status": None,
|
||||||
|
"fulfillment_status": None,
|
||||||
|
"start_date": "",
|
||||||
|
"end_date": "",
|
||||||
|
"page_no": 1,
|
||||||
|
"page_size": 15,
|
||||||
|
}
|
||||||
|
assert repository.queries[0] == default_query
|
||||||
|
assert all(query == {**default_query, "page_no": index + 1}
|
||||||
|
for index, query in enumerate(repository.queries))
|
||||||
|
assert not workspace.use_dates.isChecked()
|
||||||
|
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||||
|
assert workspace.start_date.date() == QDate.currentDate().addDays(-30)
|
||||||
|
assert workspace.end_date.date() == QDate.currentDate()
|
||||||
|
assert all(
|
||||||
|
combo.currentData() is None
|
||||||
|
for combo in (workspace.rx_audit, workspace.pay_audit, workspace.fulfillment)
|
||||||
|
)
|
||||||
|
|
||||||
|
before_filters = len(repository.queries)
|
||||||
|
workspace.keyword_edit.setText(" 林医生 ")
|
||||||
|
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||||
|
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||||
|
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||||
|
assert len(repository.queries) == before_filters # Changing status does not auto-submit.
|
||||||
|
_click(workspace.use_dates, application)
|
||||||
|
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||||
|
start, end = QDate(2026, 7, 3), QDate(2026, 8, 8)
|
||||||
|
workspace.start_date.setDate(start)
|
||||||
|
workspace.end_date.setDate(end)
|
||||||
|
_click(workspace.search_button, application)
|
||||||
|
assert repository.queries[before_filters] == {
|
||||||
|
**default_query,
|
||||||
|
"keyword": "林医生",
|
||||||
|
"prescription_audit_status": 2,
|
||||||
|
"payment_slip_audit_status": 0,
|
||||||
|
"fulfillment_status": 9,
|
||||||
|
"start_date": "2026-07-03",
|
||||||
|
"end_date": "2026-08-08",
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace.pager.load_more()
|
||||||
|
assert repository.queries[-1]["page_no"] == 2
|
||||||
|
assert repository.queries[-1]["page_size"] == 15
|
||||||
|
workspace.keyword_edit.setText("新检索")
|
||||||
|
QTest.keyClick(workspace.keyword_edit, Qt.Key.Key_Return)
|
||||||
|
_settle(application)
|
||||||
|
assert next(query for query in repository.queries if query["keyword"] == "新检索")["page_no"] == 1
|
||||||
|
workspace.start_date.setDate(end.addDays(1))
|
||||||
|
before = len(repository.queries)
|
||||||
|
_click(workspace.search_button, application)
|
||||||
|
assert len(repository.queries) == before
|
||||||
|
assert workspace.banner.isVisible()
|
||||||
|
assert "开始日期不能晚于结束日期" in _banner_text(workspace)
|
||||||
|
|
||||||
|
_click(workspace.reset_button, application)
|
||||||
|
assert repository.queries[-1] == {**default_query, "page_no": repository.queries[-1]["page_no"]}
|
||||||
|
assert workspace.keyword_edit.text() == ""
|
||||||
|
assert not workspace.use_dates.isChecked()
|
||||||
|
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||||
|
assert workspace.start_date.date() == end.addDays(1)
|
||||||
|
assert workspace.end_date.date() == end
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_twelve_columns_keep_source_text_distinct_ids_and_missing_display_fields(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
row = repository.rows[0]
|
||||||
|
row["order_no"] = "PO-非常长的订单号-20260905-ABCDEFGHIJ"
|
||||||
|
row["patient_name"] = "用于检查省略与提示的较长患者姓名"
|
||||||
|
row["doctor_name"] = "用于检查分行显示与完整提示信息的开方医生(演示)"
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
table = workspace.table
|
||||||
|
assert [table.horizontalHeaderItem(column).text() for column in range(table.columnCount())] == [
|
||||||
|
"订单",
|
||||||
|
"患者",
|
||||||
|
"处方 / 诊单",
|
||||||
|
"有效金额",
|
||||||
|
"处方审核",
|
||||||
|
"支付审核",
|
||||||
|
"履约",
|
||||||
|
"支付单",
|
||||||
|
"归属助理",
|
||||||
|
"开方人",
|
||||||
|
"创建人",
|
||||||
|
"创建时间",
|
||||||
|
]
|
||||||
|
index = next(
|
||||||
|
index
|
||||||
|
for index in range(table.rowCount())
|
||||||
|
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == 901
|
||||||
|
)
|
||||||
|
assert table.item(index, 0).text() == f"{row['order_no']} · #901"
|
||||||
|
assert table.item(index, 1).text() == f"{row['patient_name']} · 186****4820"
|
||||||
|
assert table.item(index, 2).text() == "#801 / #501"
|
||||||
|
assert table.item(index, 9).text() == row["doctor_name"]
|
||||||
|
assert table.item(index, 0).toolTip() == table.item(index, 0).text()
|
||||||
|
assert table.item(index, 1).toolTip() == table.item(index, 1).text()
|
||||||
|
assert table.item(index, 9).toolTip() == row["doctor_name"]
|
||||||
|
assert table.item(index, 7).text() == "—"
|
||||||
|
assert table.item(index, 11).text() == "—"
|
||||||
|
for column in range(12):
|
||||||
|
assert not table.isColumnHidden(column)
|
||||||
|
assert table.item(index, column).data(Qt.ItemDataRole.UserRole) == row
|
||||||
|
assert row["recipient_phone"] not in table.item(index, column).text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_selection_buttons_and_menu_emit_current_order_resource_ids(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, _repository = workspace_factory()
|
||||||
|
table = workspace.table
|
||||||
|
emitted = []
|
||||||
|
workspace.diagnosis_requested.connect(lambda row: emitted.append(("diagnosis", row)))
|
||||||
|
workspace.detail_requested.connect(lambda row: emitted.append(("detail", row)))
|
||||||
|
workspace.action_requested.connect(lambda key, row: emitted.append((key, row)))
|
||||||
|
table.sortItems(0, Qt.SortOrder.AscendingOrder)
|
||||||
|
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||||
|
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id
|
||||||
|
for index in (0, 2, 1):
|
||||||
|
table.selectRow(index)
|
||||||
|
_settle(application)
|
||||||
|
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
workspace.diagnosis_button.click()
|
||||||
|
workspace.detail_button.click()
|
||||||
|
next(
|
||||||
|
action for action in workspace.action_menu.actions() if action.text() == "确认发货"
|
||||||
|
).trigger()
|
||||||
|
assert [kind for kind, _row in emitted[-3:]] == ["diagnosis", "detail", "ship"]
|
||||||
|
for _kind, row in emitted[-3:]:
|
||||||
|
assert (row["id"], row["prescription_id"], row["diagnosis_id"]) == (
|
||||||
|
expected["id"],
|
||||||
|
expected["prescription_id"],
|
||||||
|
expected["diagnosis_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("permissions", "menu", "detail"),
|
||||||
|
[
|
||||||
|
(("*",), ["撤回支付审核", "修改快递单号", "确认发货", "上传药房"], True),
|
||||||
|
(("tcm.prescriptionOrder/detail",), [], True),
|
||||||
|
(("tcm.prescriptionOrder/ship",), ["确认发货"], False),
|
||||||
|
(("tcm.prescriptionOrder.detail", "tcm.prescriptionOrder.ship"), [], False),
|
||||||
|
((), [], False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_selected_order_menu_requires_canonical_permissions(
|
||||||
|
application: QApplication, workspace_factory, permissions, menu, detail
|
||||||
|
) -> None:
|
||||||
|
workspace, _repository = workspace_factory(permissions=permissions)
|
||||||
|
assert [action.text() for action in workspace.action_menu.actions()] == menu
|
||||||
|
assert workspace.action_button.isVisible() is bool(menu)
|
||||||
|
assert workspace.detail_button.isVisible() is detail
|
||||||
|
assert workspace.diagnosis_button.isEnabled()
|
||||||
|
emitted = []
|
||||||
|
workspace.detail_requested.connect(lambda row: emitted.append(row["id"]))
|
||||||
|
workspace._request_detail() # The double-click path also checks permission.
|
||||||
|
assert bool(emitted) is detail
|
||||||
|
workspace.permissions = PermissionSet([])
|
||||||
|
workspace._selection_changed()
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.action_menu.actions() == []
|
||||||
|
assert not workspace.action_button.isVisible()
|
||||||
|
assert not workspace.detail_button.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("rx", "pay", "fulfillment", "rx_text", "pay_text", "state", "exclusion"),
|
||||||
|
[
|
||||||
|
(0, 1, 4, "待审核", "已通过", "已取消", "已取消不计入"),
|
||||||
|
(1, 2, 9, "已通过", "已驳回", "拒收", "拒收不计入"),
|
||||||
|
(2, 0, 10, "已驳回", "待审核", "退款", "退款不计入"),
|
||||||
|
(1, 1, 6, "已通过", "已通过", "已签收", ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_amount_exclusion_reasons_and_independent_status_text_survive_rendering(
|
||||||
|
application: QApplication,
|
||||||
|
workspace_factory,
|
||||||
|
rx,
|
||||||
|
pay,
|
||||||
|
fulfillment,
|
||||||
|
rx_text,
|
||||||
|
pay_text,
|
||||||
|
state,
|
||||||
|
exclusion,
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.rows = [
|
||||||
|
{
|
||||||
|
**repository.rows[0],
|
||||||
|
"prescription_audit_status": rx,
|
||||||
|
"payment_slip_audit_status": pay,
|
||||||
|
"fulfillment_status": fulfillment,
|
||||||
|
"amount_included": not bool(exclusion),
|
||||||
|
"amount_exclusion_text": exclusion,
|
||||||
|
"effective_amount": 12368.5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.table.item(0, 3).text() == (exclusion or "¥12,368.50")
|
||||||
|
assert [workspace.table.item(0, column).text() for column in (4, 5, 6)] == [
|
||||||
|
rx_text,
|
||||||
|
pay_text,
|
||||||
|
state,
|
||||||
|
]
|
||||||
|
if fulfillment == 6:
|
||||||
|
refund = next(
|
||||||
|
action for action in workspace.action_menu.actions() if action.text() == "退款"
|
||||||
|
)
|
||||||
|
assert refund.property("danger") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_uses_response_scope_and_legacy_aliases_without_recomputing_rows(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||||
|
"orders": "47",
|
||||||
|
"amount": "¥12,368.50",
|
||||||
|
"pending": "8",
|
||||||
|
"completed": "9",
|
||||||
|
"rejected": "2",
|
||||||
|
"rejection_rate": "4.3%",
|
||||||
|
}
|
||||||
|
assert workspace.scope_label.text() == "测试部门订单范围"
|
||||||
|
assert workspace.pager.total == 47
|
||||||
|
assert workspace.pager.page_size == 15
|
||||||
|
assert workspace.pager.height() == 24
|
||||||
|
# Preserve the existing ratio compatibility, including its known ambiguity.
|
||||||
|
repository.summary = {
|
||||||
|
"order_count": 20,
|
||||||
|
"effective_amount": 700.25,
|
||||||
|
"pending_audit": 3,
|
||||||
|
"completed": 4,
|
||||||
|
"rejected": 1,
|
||||||
|
"rejection_rate": 0.05,
|
||||||
|
}
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||||
|
"orders": "20",
|
||||||
|
"amount": "¥700.25",
|
||||||
|
"pending": "3",
|
||||||
|
"completed": "4",
|
||||||
|
"rejected": "1",
|
||||||
|
"rejection_rate": "5.0%",
|
||||||
|
}
|
||||||
|
before = len(repository.queries)
|
||||||
|
for metric in workspace.metrics.values():
|
||||||
|
_click(metric, application)
|
||||||
|
assert len(repository.queries) == before # The six metrics are read-only.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("silent", [False, True])
|
||||||
|
def test_loading_freezes_queries_resets_rows_and_ignores_stale_success_and_failure(
|
||||||
|
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
table = workspace.table
|
||||||
|
queued = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
patients_module,
|
||||||
|
"run_async",
|
||||||
|
lambda function, **callbacks: queued.append((function, callbacks)),
|
||||||
|
)
|
||||||
|
workspace.keyword_edit.setText("旧请求")
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
workspace.keyword_edit.setText("最新请求")
|
||||||
|
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||||
|
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||||
|
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||||
|
workspace.use_dates.setChecked(True)
|
||||||
|
workspace.start_date.setDate(QDate(2026, 7, 1))
|
||||||
|
workspace.end_date.setDate(QDate(2026, 8, 1))
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
_settle(application)
|
||||||
|
assert table.rowCount() == 0
|
||||||
|
assert workspace.content_stack.currentIndex() == 1
|
||||||
|
assert workspace.pager.isVisible()
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
workspace.keyword_edit.setText("尚未提交")
|
||||||
|
workspace.rx_audit.setCurrentIndex(0)
|
||||||
|
workspace.use_dates.setChecked(False)
|
||||||
|
newer = queued[1][0]()
|
||||||
|
assert repository.queries[-1] == {
|
||||||
|
"keyword": "最新请求",
|
||||||
|
"prescription_audit_status": 2,
|
||||||
|
"payment_slip_audit_status": 0,
|
||||||
|
"fulfillment_status": 9,
|
||||||
|
"start_date": "2026-07-01",
|
||||||
|
"end_date": "2026-08-01",
|
||||||
|
"page_no": 1,
|
||||||
|
"page_size": 15,
|
||||||
|
}
|
||||||
|
newer["extend"]["scope"]["label"] = "最新范围"
|
||||||
|
queued[1][1]["on_success"](newer)
|
||||||
|
current_item = table.item(0, 0)
|
||||||
|
stale = queued[0][0]()
|
||||||
|
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||||
|
queued[0][1]["on_success"](stale)
|
||||||
|
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 0) is current_item
|
||||||
|
assert workspace.scope_label.text() == "最新范围"
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
workspace.keyword_edit.setText("最新请求")
|
||||||
|
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||||
|
workspace.use_dates.setChecked(True)
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
queued[-1][1]["on_error"](RuntimeError("订单查询失败"))
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 0) is current_item
|
||||||
|
assert workspace.metrics["orders"].text() == "47"
|
||||||
|
assert workspace.banner.isVisible()
|
||||||
|
assert "订单查询失败" in _banner_text(workspace)
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
queued[-1][1]["on_success"]({"lists": [], "count": 0})
|
||||||
|
_settle(application)
|
||||||
|
assert table.rowCount() == 0
|
||||||
|
assert workspace.content_stack.currentIndex() == 1
|
||||||
|
assert "当前范围内暂无订单" in " ".join(
|
||||||
|
label.text() for label in workspace.content_stack.currentWidget().findChildren(QLabel)
|
||||||
|
)
|
||||||
|
assert not workspace.diagnosis_button.isEnabled()
|
||||||
|
assert not workspace.detail_button.isEnabled()
|
||||||
|
assert workspace.action_menu.actions() == []
|
||||||
|
assert workspace.pager.total == 0
|
||||||
|
assert workspace.metrics["orders"].text() == "0"
|
||||||
|
assert workspace.metrics["amount"].text() == "¥0.00"
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1270, 680), (1014, 490), (760, 380)])
|
||||||
|
def test_layout_keeps_filters_actions_and_load_status_reachable_at_narrow_viewports(
|
||||||
|
application: QApplication, workspace_factory, width: int, height: int
|
||||||
|
) -> None:
|
||||||
|
workspace, _repository = workspace_factory(width=width, height=height)
|
||||||
|
table = workspace.table
|
||||||
|
assert workspace.size().width() == width
|
||||||
|
assert workspace.size().height() == height
|
||||||
|
assert table.columnCount() == 12
|
||||||
|
assert table.horizontalHeader().height() == 46
|
||||||
|
assert all(table.rowHeight(index) == 68 for index in range(table.rowCount()))
|
||||||
|
assert table.font().pixelSize() == 14
|
||||||
|
assert workspace.scope_label.font().pixelSize() == 13
|
||||||
|
assert all(metric.font().pixelSize() == 18 for metric in workspace.metrics.values())
|
||||||
|
for widget in (
|
||||||
|
workspace.keyword_edit,
|
||||||
|
workspace.rx_audit,
|
||||||
|
workspace.pay_audit,
|
||||||
|
workspace.fulfillment,
|
||||||
|
workspace.search_button,
|
||||||
|
workspace.reset_button,
|
||||||
|
workspace.use_dates,
|
||||||
|
workspace.start_date,
|
||||||
|
workspace.end_date,
|
||||||
|
):
|
||||||
|
assert widget.isVisibleTo(workspace)
|
||||||
|
assert workspace.filter_card.rect().contains(_rect_in(widget, workspace.filter_card))
|
||||||
|
for metric in workspace.metrics.values():
|
||||||
|
assert workspace.summary_strip.rect().contains(_rect_in(metric, workspace.summary_strip))
|
||||||
|
if width == 1270:
|
||||||
|
assert workspace.filter_card.height() == 132
|
||||||
|
assert workspace.summary_strip.height() == 96
|
||||||
|
if width == 760:
|
||||||
|
assert workspace.filter_card.height() == 184
|
||||||
|
assert (
|
||||||
|
len(
|
||||||
|
{
|
||||||
|
metric.mapTo(workspace.summary_strip, QPoint()).y()
|
||||||
|
for metric in workspace.metrics.values()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
assert table.horizontalScrollBar().maximum() > 0
|
||||||
|
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||||
|
|
||||||
|
for host in (
|
||||||
|
workspace.filter_card,
|
||||||
|
workspace.summary_strip,
|
||||||
|
workspace.action_bar,
|
||||||
|
workspace.pager,
|
||||||
|
):
|
||||||
|
workspace.scroll.ensureWidgetVisible(host, 0, 0)
|
||||||
|
_settle(application)
|
||||||
|
assert (
|
||||||
|
workspace.scroll.viewport().rect().contains(_rect_in(host, workspace.scroll.viewport()))
|
||||||
|
), host.objectName()
|
||||||
|
for button in (workspace.diagnosis_button, workspace.detail_button, workspace.action_button):
|
||||||
|
assert workspace.action_bar.rect().contains(_rect_in(button, workspace.action_bar))
|
||||||
|
assert workspace.pager.height() == 24
|
||||||
|
for widget in (workspace.pager.summary_label,):
|
||||||
|
assert workspace.pager.rect().contains(_rect_in(widget, workspace.pager))
|
||||||
|
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||||
|
table.scrollToBottom()
|
||||||
|
_settle(application)
|
||||||
|
assert table.columnViewportPosition(11) >= 0
|
||||||
|
assert table.columnViewportPosition(11) + table.columnWidth(11) <= table.viewport().width()
|
||||||
|
assert table.rowViewportPosition(2) + table.rowHeight(2) <= table.viewport().height()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tab_switches_keep_patient_styles_scope_deduplication_and_progress_timer(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
page, _repository = workspace_factory(width=1328, height=884, page=True)
|
||||||
|
patient = page.patient_workspace
|
||||||
|
progress = page.progress_workspace
|
||||||
|
patient_style = patient.styleSheet()
|
||||||
|
assert patient.table.objectName() == "PatientTable"
|
||||||
|
assert patient.table.columnCount() == 10
|
||||||
|
assert patient.table.rowHeight(0) == 66
|
||||||
|
assert patient.table.horizontalHeader().height() == 42
|
||||||
|
assert patient.table.font().pixelSize() == 14
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert not patient.search_toolbar.isVisible()
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
assert patient.search_toolbar.isVisible()
|
||||||
|
assert not progress.timer.isActive()
|
||||||
|
page.tabs.setCurrentIndex(2)
|
||||||
|
_settle(application)
|
||||||
|
progress_style = progress.styleSheet()
|
||||||
|
progress_table_font = progress.schedule_table.font()
|
||||||
|
progress_metric_font = progress.overview["total"][0].font()
|
||||||
|
assert progress.timer.interval() == 15_000
|
||||||
|
assert progress.timer.isActive()
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert not progress.scope_label.isVisible()
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
assert progress.scope_label.isVisible()
|
||||||
|
assert progress_table_font.pixelSize() == 14
|
||||||
|
assert progress_metric_font.pixelSize() == 18
|
||||||
|
assert progress.schedule_table.rowCount() == 7
|
||||||
|
assert progress.schedule_table.columnCount() == 7
|
||||||
|
|
||||||
|
page.tabs.setCurrentIndex(1)
|
||||||
|
_settle(application)
|
||||||
|
assert not progress.timer.isActive()
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert not patient.search_toolbar.isVisible()
|
||||||
|
assert page.order_workspace.scope_label.isVisible()
|
||||||
|
assert page.order_workspace.scope_label.text() == "测试部门订单范围"
|
||||||
|
assert patient.styleSheet() == patient_style
|
||||||
|
assert progress.styleSheet() == progress_style
|
||||||
|
assert progress.schedule_table.font() == progress_table_font
|
||||||
|
assert progress.overview["total"][0].font() == progress_metric_font
|
||||||
|
assert patient.table.rowHeight(0) == 66
|
||||||
|
assert patient.table.horizontalHeader().height() == 42
|
||||||
|
|
||||||
|
page.tabs.setCurrentIndex(0)
|
||||||
|
_settle(application)
|
||||||
|
assert patient.search_toolbar.isVisible()
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert not progress.timer.isActive()
|
||||||
|
page.tabs.setCurrentIndex(2)
|
||||||
|
_settle(application)
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert progress.scope_label.isVisible()
|
||||||
|
assert progress.timer.isActive()
|
||||||
|
page.hide()
|
||||||
|
_settle(application)
|
||||||
|
assert not progress.timer.isActive()
|
||||||
|
page.show()
|
||||||
|
_settle(application)
|
||||||
|
assert progress.timer.isActive()
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"""Progress data/display contracts and native reachability after the blue redesign."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QAbstractItemView, QApplication, QLabel
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
|
from doctor_workstation.ui.pages.patients import PatientProgressWorkspace, PatientsPage
|
||||||
|
from doctor_workstation.ui.shell import ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[dict[str, Any]] = []
|
||||||
|
self.result = {
|
||||||
|
"lists": [{"id": 101, "diagnosis_id": 501, "patient_id": 301,
|
||||||
|
"patient_name": "林晓岚", "doctor_name": "陈医生(演示)",
|
||||||
|
"appointment_time": "09:00-09:30"}],
|
||||||
|
"count": 37,
|
||||||
|
"extend": {"scope": {"label": "本部门及下级"}, "schedule_mode": "self",
|
||||||
|
"summary": {"waiting": 37, "completed": 2, "missed": 3}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.queries.append(deepcopy(query))
|
||||||
|
return deepcopy(self.result)
|
||||||
|
|
||||||
|
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||||
|
return {"lists": [], "count": 0}
|
||||||
|
|
||||||
|
def patient_orders(self, **_query: Any) -> dict[str, Any]:
|
||||||
|
return {"lists": [], "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(table) -> list[list[str]]:
|
||||||
|
return [[table.item(row, column).text() for column in range(table.columnCount())]
|
||||||
|
for row in range(table.rowCount())]
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(workspace) -> dict[str, tuple[str, str]]:
|
||||||
|
return {key: (value.text(), hint.text()) for key, (value, hint) in workspace.overview.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _rect_in(widget, parent) -> QRect:
|
||||||
|
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args, **_kwargs):
|
||||||
|
pytest.fail("Progress regression tests must use local fixtures or Demo only")
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||||
|
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(*, page=False, shell=False, permissions=("*",), width=1274, height=696,
|
||||||
|
repository=None, refresh=True):
|
||||||
|
repository = repository if repository is not None else _Repository()
|
||||||
|
permission_set = PermissionSet(list(permissions))
|
||||||
|
if shell:
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
|
# A real shell with only the tested navigation route, not unrelated workspaces.
|
||||||
|
widget = ShellWindow(repository, {
|
||||||
|
"user": session.user, "permissions": permission_set,
|
||||||
|
"menu": [{"perms": "firstvisit.myPatient/lists"}],
|
||||||
|
}, permissions=permission_set)
|
||||||
|
elif page:
|
||||||
|
widget = PatientsPage(repository, permissions=permission_set)
|
||||||
|
else:
|
||||||
|
widget = PatientProgressWorkspace(repository)
|
||||||
|
opened.append(widget)
|
||||||
|
widget.resize(width, height)
|
||||||
|
widget.show()
|
||||||
|
if shell:
|
||||||
|
widget.navigate("patients")
|
||||||
|
widget.pages["patients"].tabs.setCurrentIndex(2)
|
||||||
|
elif page:
|
||||||
|
widget.tabs.setCurrentIndex(2)
|
||||||
|
elif refresh:
|
||||||
|
widget.refresh()
|
||||||
|
_settle(application)
|
||||||
|
return widget, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for widget in opened:
|
||||||
|
for timer in widget.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("extend", "total", "expected_mode", "expected"),
|
||||||
|
[
|
||||||
|
({"schedule_mode": "self", "summary": {"waiting": 5, "completed": 2, "missed": 1}},
|
||||||
|
99, "按本人归属", {"total": ("8", "0 位接诊医生"), "waiting": ("5", "本人归属患者"), "completed": ("2", "已过号 1 人")}),
|
||||||
|
({"schedule_mode": "ownership", "summary": {"completed": 3, "missed": 2}},
|
||||||
|
7, "按本人归属", {"total": ("12", "0 位接诊医生"), "waiting": ("7", "本人归属患者"), "completed": ("3", "已过号 2 人")}),
|
||||||
|
({"schedule_mode": "self", "summary": {"waiting": 8, "completed": 9, "missed": 7},
|
||||||
|
"today_overview": {"total_visits": 0, "booked": 0, "completed": 0, "missed": 0, "doctor_count": 4}},
|
||||||
|
99, "按本人归属", {"total": ("0", "4 位接诊医生"), "waiting": ("0", "本人归属患者"), "completed": ("0", "已过号 0 人")}),
|
||||||
|
({"schedule_mode": "roster", "summary": {"waiting": 30, "completed": 40, "missed": 5},
|
||||||
|
"today_overview": {"total_visits": 18, "booked": 12, "completed": 4, "empty_slots": 6, "doctor_count": 3, "missed": 2}},
|
||||||
|
99, "与排班合并", {"total": ("18", "3 位接诊医生"), "waiting": ("12", "有效挂号"), "completed": ("6", "当前空号")}),
|
||||||
|
({"schedule_mode": "roster", "summary": {"waiting": 6, "completed": 3, "missed": 1}},
|
||||||
|
99, "与排班合并", {"total": ("10", "0 位接诊医生"), "waiting": ("6", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||||
|
({}, 4, "与排班合并", {"total": ("4", "0 位接诊医生"), "waiting": ("4", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||||
|
],
|
||||||
|
ids=["self-summary", "ownership-total-fallback", "explicit-zero-wins", "roster-empty-slots", "roster-summary-fallback", "missing-extend"],
|
||||||
|
)
|
||||||
|
def test_overview_preserves_mode_fallbacks_and_fixed_completed_caption(
|
||||||
|
workspace_factory, extend, total, expected_mode, expected
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.result.update(count=total, extend=extend)
|
||||||
|
workspace.refresh()
|
||||||
|
assert _metrics(workspace) == expected
|
||||||
|
assert workspace.mode_label.text() == expected_mode
|
||||||
|
assert [label.text() for label in workspace.overview_card.findChildren(QLabel)
|
||||||
|
if label.text() in {"今日面诊总数", "待面诊", "已完成"}] == ["今日面诊总数", "待面诊", "已完成"]
|
||||||
|
assert workspace.queue_count.text() == f"共 {total} 人 · 每 15 秒刷新"
|
||||||
|
assert workspace.queue_table.rowCount() == 1 # Totals are server scope, not visible-row counts.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("week_schedule", [None, [], "invalid"], ids=["missing", "empty", "invalid-type"])
|
||||||
|
def test_schedule_fallback_is_seven_consecutive_days_with_blank_doctors(
|
||||||
|
workspace_factory, monkeypatch, week_schedule
|
||||||
|
) -> None:
|
||||||
|
# Sunday fixes the distinction between rolling seven days and a calendar week.
|
||||||
|
class Sunday:
|
||||||
|
@staticmethod
|
||||||
|
def currentDate():
|
||||||
|
return QDate(2026, 9, 6)
|
||||||
|
|
||||||
|
monkeypatch.setattr(patients_module, "QDate", Sunday)
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.result["extend"]["week_schedule"] = week_schedule
|
||||||
|
workspace.refresh()
|
||||||
|
rows = _texts(workspace.schedule_table)
|
||||||
|
assert [row[0] for row in rows] == ["09-06 周日", "09-07 周一", "09-08 周二", "09-09 周三", "09-10 周四", "09-11 周五", "09-12 周六"]
|
||||||
|
assert all(row[1:6] == ["0"] * 5 and row[6] == "" for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_zero_priority_missing_fallback_and_doctor_names(workspace_factory) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.result["extend"]["week_schedule"] = [
|
||||||
|
{"date_text": "09-05", "weekday": "周六", "doctor_count": 2, "total_appointments": 0,
|
||||||
|
"waiting_appointments": 0, "completed_appointments": 0, "total_slots": 11,
|
||||||
|
"booked_slots": 7, "empty_slots": 4, "missed_appointments": 1, "doctors": []},
|
||||||
|
{"date_text": "09-06", "weekday": "周日", "doctor_count": 2, "total_appointments": None,
|
||||||
|
"waiting_appointments": "", "total_slots": 11, "booked_slots": 7, "empty_slots": 4,
|
||||||
|
"missed_appointments": 0, "doctors": [{"doctor_name": "甲医生"}, {"name": "乙医生"}]},
|
||||||
|
{"date_text": "09-07", "weekday": "周一", "doctors": "invalid"},
|
||||||
|
]
|
||||||
|
workspace.refresh()
|
||||||
|
rows = {row[0]: row for row in _texts(workspace.schedule_table)}
|
||||||
|
assert rows["09-05 周六"] == ["09-05 周六", "2", "0", "0", "0", "1", ""]
|
||||||
|
assert rows["09-06 周日"] == ["09-06 周日", "2", "11", "7", "4", "0", "甲医生、乙医生"]
|
||||||
|
assert rows["09-07 周一"][6] == "—"
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_row_keeps_missing_queue_fields_and_empty_cells_visually_blank(
|
||||||
|
application, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
|
workspace, _ = workspace_factory(repository=repository)
|
||||||
|
assert _texts(workspace.queue_table) == [["—", "林晓岚", "陈医生(演示)", "09:00", "0 位", "—"]]
|
||||||
|
assert _metrics(workspace) == {"total": ("1", "0 位接诊医生"), "waiting": ("1", "本人归属患者"), "completed": ("0", "已过号 0 人")}
|
||||||
|
assert workspace.scope == "演示医生本人患者"
|
||||||
|
assert workspace.pager.summary_label.text() == "共 1 条 · 已全部加载"
|
||||||
|
assert not workspace.pager.has_more
|
||||||
|
table = workspace.schedule_table
|
||||||
|
table.scrollToItem(table.item(0, 6))
|
||||||
|
_settle(application)
|
||||||
|
rect = table.visualItemRect(table.item(0, 6)).adjusted(3, 3, -3, -3)
|
||||||
|
image = table.viewport().grab(rect).toImage()
|
||||||
|
assert not image.isNull()
|
||||||
|
# Read the actual delegate output, not just the model's DisplayRole: blank is not “—”.
|
||||||
|
assert all(image.pixelColor(x, y).lightness() > 160
|
||||||
|
for x in range(image.width()) for y in range(image.height()))
|
||||||
|
dash_table = workspace.queue_table
|
||||||
|
dash_image = dash_table.viewport().grab(dash_table.visualItemRect(dash_table.item(0, 0))).toImage()
|
||||||
|
assert any(dash_image.pixelColor(x, y).lightness() < 100
|
||||||
|
for x in range(dash_image.width()) for y in range(dash_image.height()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_waiting_statuses_keep_source_counts_and_no_status_desc_fallback(workspace_factory) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
examples = [
|
||||||
|
({"queue_status": "consulting", "queue_status_text": "就诊中", "ahead_count": 12}, "0(进行中)", "就诊中"),
|
||||||
|
({"queue_status": "next", "queue_status_text": "待确认", "ahead_count": 12}, "0(待接诊)", "待确认"),
|
||||||
|
({"queue_status": "waiting", "queue_status_text": "等待中", "ahead_count": "3"}, "3 位 · 约 45 分钟", "等待中"),
|
||||||
|
({"queue_status": "waiting", "ahead_count": 2, "estimated_wait_minutes": 7}, "2 位 · 约 7 分钟", "—"),
|
||||||
|
({"queue_status": "missed", "queue_status_text": "已过号", "ahead_count": 2, "estimated_wait_minutes": -5}, "2 位 · 约 0 分钟", "已过号"),
|
||||||
|
({"queue_status": "completed", "queue_status_text": "已完成", "ahead_count": -8}, "0 位", "已完成"),
|
||||||
|
({"status_desc": "待接诊", "diagnosis_confirmed": 1, "ahead_count": "bad"}, "0 位", "—"),
|
||||||
|
]
|
||||||
|
base = repository.result["lists"][0]
|
||||||
|
repository.result["lists"] = [dict(base, **fields, id=100 + index, queue_no=40 + index)
|
||||||
|
for index, (fields, _, _) in enumerate(examples)]
|
||||||
|
workspace.refresh()
|
||||||
|
table = workspace.queue_table
|
||||||
|
for row_index in range(table.rowCount()):
|
||||||
|
row = table.item(row_index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
fields, expected_wait, expected_status = examples[row["id"] - 100]
|
||||||
|
assert table.item(row_index, 0).text() == str(row["queue_no"])
|
||||||
|
assert table.item(row_index, 4).text() == expected_wait
|
||||||
|
assert table.item(row_index, 5).text() == expected_status
|
||||||
|
assert all(table.item(row_index, col).data(Qt.ItemDataRole.UserRole) == row for col in range(6))
|
||||||
|
assert table.selectionMode() == QAbstractItemView.SelectionMode.SingleSelection
|
||||||
|
assert table.editTriggers() == QAbstractItemView.EditTrigger.NoEditTriggers
|
||||||
|
assert table.isSortingEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("permissions", "expected"),
|
||||||
|
[(('tcm.diagnosis/edit', 'tcm.diagnosis/readonlyDetail'), "edit"),
|
||||||
|
(("tcm.diagnosis/readonlyDetail",), "read"), ((), "denied"),
|
||||||
|
(("tcm.diagnosis.edit", "tcm.diagnosis.readonlyDetail"), "denied")],
|
||||||
|
ids=["edit-preferred", "readonly", "no-permission", "noncanonical-permission"],
|
||||||
|
)
|
||||||
|
def test_sorted_double_click_uses_selected_diagnosis_and_canonical_permissions(
|
||||||
|
application, workspace_factory, monkeypatch, permissions, expected
|
||||||
|
) -> None:
|
||||||
|
page, repository = workspace_factory(page=True, width=1328, height=884, permissions=permissions)
|
||||||
|
workspace = page.progress_workspace
|
||||||
|
repository.result["lists"] = [dict(repository.result["lists"][0], id=101 + index,
|
||||||
|
diagnosis_id=501 + index, patient_id=301 + index,
|
||||||
|
patient_name=name, queue_no=index + 1)
|
||||||
|
for index, name in enumerate(("阿青", "林青", "赵青"))]
|
||||||
|
workspace.refresh()
|
||||||
|
calls, toasts = [], []
|
||||||
|
dialog = SimpleNamespace(
|
||||||
|
open_for=lambda resource, **kwargs: calls.append(("edit", resource, kwargs)),
|
||||||
|
open_view_only=lambda resource, **kwargs: calls.append(("read", resource, kwargs)),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: dialog)
|
||||||
|
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||||
|
table = workspace.queue_table
|
||||||
|
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||||
|
table.selectRow(0)
|
||||||
|
table.scrollToItem(table.item(0, 1))
|
||||||
|
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||||
|
_settle(application)
|
||||||
|
row = table.item(0, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
assert row["id"] == 103 # A sorted row must not resolve to the first source row (101).
|
||||||
|
point = table.visualItemRect(table.item(0, 1)).center()
|
||||||
|
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||||
|
QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||||
|
_settle(application)
|
||||||
|
if expected == "denied":
|
||||||
|
assert calls == [] and toasts == ["当前账号没有诊单查看权限。"]
|
||||||
|
else:
|
||||||
|
assert len(calls) == 1 and calls[0][0:2] == (expected, row["diagnosis_id"])
|
||||||
|
assert calls[0][1] != row["patient_id"] and calls[0][2]["seed"] == row
|
||||||
|
assert not toasts
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_row_id_fallback_and_invalid_diagnosis_never_use_patient_id(
|
||||||
|
workspace_factory, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
page, repository = workspace_factory(page=True, permissions=("tcm.diagnosis/edit",))
|
||||||
|
workspace = page.progress_workspace
|
||||||
|
calls, toasts = [], []
|
||||||
|
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: SimpleNamespace(
|
||||||
|
open_for=lambda resource, **kwargs: calls.append(resource)))
|
||||||
|
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||||
|
for row, expected in [({"id": 810, "patient_id": 910}, 810),
|
||||||
|
({"id": 811, "diagnosis_id": 0, "patient_id": 911}, None),
|
||||||
|
({"patient_id": 912}, None)]:
|
||||||
|
repository.result["lists"] = [row]
|
||||||
|
workspace.refresh()
|
||||||
|
workspace.queue_table.selectRow(0)
|
||||||
|
before = len(calls)
|
||||||
|
workspace.queue_table.itemDoubleClicked.emit(workspace.queue_table.item(0, 0))
|
||||||
|
assert calls[before:] == ([] if expected is None else [expected])
|
||||||
|
assert toasts == ["患者诊单信息不完整。", "患者诊单信息不完整。"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_captures_today_status_and_page_before_worker_runs(
|
||||||
|
application, workspace_factory, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
queued = []
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||||
|
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||||||
|
workspace.refresh(silent=True)
|
||||||
|
queued.pop()[1]["on_success"](deepcopy(repository.result))
|
||||||
|
workspace.pager.load_more()
|
||||||
|
workspace.pager.load_more()
|
||||||
|
assert len(queued) == 1 # Repeated bottom events share the in-flight request.
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
# Later GUI changes must not reach the pending worker's immutable query.
|
||||||
|
workspace._page = 9
|
||||||
|
queued[0][0]()
|
||||||
|
assert repository.queries[-1:] == [
|
||||||
|
{"status": 1, "start_date": today, "end_date": today, "page_no": 2, "page_size": 15},
|
||||||
|
]
|
||||||
|
assert "page" not in repository.queries[-1]
|
||||||
|
assert workspace.pager.page_size == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_success_error_and_latest_error_preserve_the_last_good_data(
|
||||||
|
application, workspace_factory, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
pending = []
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", lambda _function, **callbacks: pending.append(callbacks))
|
||||||
|
workspace.refresh()
|
||||||
|
# A date rollover changes the query; repeated same-day polls reuse in-flight work.
|
||||||
|
tomorrow = patients_module.QDate.currentDate().addDays(1)
|
||||||
|
monkeypatch.setattr(patients_module, "QDate", SimpleNamespace(currentDate=lambda: tomorrow))
|
||||||
|
workspace.refresh()
|
||||||
|
newer = deepcopy(repository.result)
|
||||||
|
newer["lists"][0].update(id=202, patient_name="最新结果")
|
||||||
|
newer["count"] = 22
|
||||||
|
newer["extend"].update(scope={"label": "新范围"}, summary={"waiting": 22, "completed": 4, "missed": 2})
|
||||||
|
pending[1]["on_success"](newer)
|
||||||
|
_settle(application)
|
||||||
|
snapshot = (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||||
|
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||||
|
pending[0]["on_success"]({"lists": [], "count": 0})
|
||||||
|
pending[0]["on_error"](RuntimeError("obsolete failure"))
|
||||||
|
_settle(application)
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||||
|
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||||
|
workspace.refresh(silent=True)
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
pending[2]["on_error"](RuntimeError("连接失败,请稍后刷新重试。"))
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.banner.isVisible() and workspace.banner.property("kind") == "danger"
|
||||||
|
assert "连接失败" in workspace.banner.label.text()
|
||||||
|
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||||
|
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_keeps_total_week_and_compact_status_visible(
|
||||||
|
application, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.result.update(lists=[], count=37)
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.queue_stack.currentIndex() == 1
|
||||||
|
assert not workspace.queue_table.isVisible() and workspace.pager.isVisible()
|
||||||
|
assert workspace.pager.height() == 24
|
||||||
|
assert workspace.schedule_table.rowCount() == 7
|
||||||
|
assert workspace.queue_count.text() == "共 37 人 · 每 15 秒刷新"
|
||||||
|
labels = [label.text() for label in workspace.queue_stack.currentWidget().findChildren(QLabel)]
|
||||||
|
assert "今日暂无候诊患者" in labels
|
||||||
|
assert "当前权限范围内没有有效挂号。" in labels
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 768)])
|
||||||
|
def test_actual_shell_keeps_all_columns_rows_and_load_status_reachable(
|
||||||
|
application, workspace_factory, width, height
|
||||||
|
) -> None:
|
||||||
|
shell, _ = workspace_factory(shell=True, width=width, height=height)
|
||||||
|
page = shell.pages["patients"]
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
workspace = page.progress_workspace
|
||||||
|
assert (shell.width(), shell.height()) == (width, height)
|
||||||
|
assert not page.scope_badge.isVisible() and workspace.scope_label.isVisible()
|
||||||
|
schedule, queue = workspace.schedule_table, workspace.queue_table
|
||||||
|
assert (schedule.rowCount(), schedule.columnCount(), queue.columnCount()) == (7, 7, 6)
|
||||||
|
if width == 1536:
|
||||||
|
assert schedule.verticalScrollBar().value() == 0
|
||||||
|
assert schedule.rowViewportPosition(6) + schedule.rowHeight(6) <= schedule.viewport().height()
|
||||||
|
assert workspace.scroll.viewport().rect().contains(_rect_in(schedule, workspace.scroll.viewport()))
|
||||||
|
for table in (schedule, queue):
|
||||||
|
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.scroll.viewport().rect().contains(_rect_in(table, workspace.scroll.viewport()))
|
||||||
|
for column in range(table.columnCount()):
|
||||||
|
item = table.item(table.rowCount() - 1, column)
|
||||||
|
table.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||||
|
_settle(application)
|
||||||
|
assert not table.isColumnHidden(column)
|
||||||
|
assert table.viewport().rect().contains(table.visualItemRect(item)), (width, column)
|
||||||
|
workspace.scroll.ensureWidgetVisible(workspace.pager, 0, 0)
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.scroll.viewport().rect().contains(_rect_in(workspace.pager, workspace.scroll.viewport()))
|
||||||
|
assert workspace.pager.height() == 24
|
||||||
|
for child in (workspace.pager.summary_label,):
|
||||||
|
assert workspace.pager.rect().contains(_rect_in(child, workspace.pager))
|
||||||
|
if width == 1024:
|
||||||
|
assert schedule.horizontalScrollBar().maximum() > 0
|
||||||
|
assert queue.horizontalScrollBar().maximum() > 0
|
||||||
|
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_splitter_mouse_drag_reallocates_space_but_cannot_collapse_panels(
|
||||||
|
application, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, _ = workspace_factory()
|
||||||
|
splitter = workspace.splitter
|
||||||
|
before = splitter.sizes()
|
||||||
|
handle = splitter.handle(1)
|
||||||
|
point = handle.rect().center()
|
||||||
|
QTest.mousePress(handle, Qt.MouseButton.LeftButton, pos=point)
|
||||||
|
QTest.mouseMove(handle, point + QPoint(0, -60))
|
||||||
|
QTest.mouseRelease(handle, Qt.MouseButton.LeftButton, pos=point + QPoint(0, -60))
|
||||||
|
_settle(application)
|
||||||
|
assert splitter.sizes()[0] < before[0]
|
||||||
|
assert not splitter.childrenCollapsible()
|
||||||
|
for sizes in ([0, 1000], [1000, 0]):
|
||||||
|
splitter.setSizes(sizes)
|
||||||
|
_settle(application)
|
||||||
|
assert splitter.sizes()[0] >= workspace.schedule_card.minimumHeight() > 0
|
||||||
|
assert splitter.sizes()[1] >= workspace.queue_card.minimumHeight() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_typography_and_styles_are_local_to_the_progress_workspace(
|
||||||
|
application, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
app_style = application.styleSheet()
|
||||||
|
unrelated = QLabel("外部标签")
|
||||||
|
unrelated.ensurePolished()
|
||||||
|
original_font = unrelated.font()
|
||||||
|
workspace, _ = workspace_factory()
|
||||||
|
assert workspace.schedule_table.font().pixelSize() == 14
|
||||||
|
assert workspace.queue_table.font().pixelSize() == 14
|
||||||
|
assert workspace.scope_label.font().pixelSize() == 13
|
||||||
|
assert workspace.mode_label.font().pixelSize() == 13
|
||||||
|
assert workspace.queue_count.font().pixelSize() == 13
|
||||||
|
for value, hint in workspace.overview.values():
|
||||||
|
assert value.font().pixelSize() == 18 and hint.font().pixelSize() == 13
|
||||||
|
for label in workspace.findChildren(QLabel):
|
||||||
|
if label.property("role") == "sectionTitle":
|
||||||
|
assert label.font().pixelSize() == 14
|
||||||
|
assert application.styleSheet() == app_style
|
||||||
|
assert "ProgressWorkspace" not in app_style
|
||||||
|
assert unrelated.font() == original_font and not unrelated.styleSheet()
|
||||||
|
unrelated.deleteLater()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user