Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f7287331 | ||
|
|
9d91ff90ae | ||
|
|
1b8e166b33 | ||
|
|
0cea43b027 | ||
|
|
d0e2422155 | ||
|
|
9206d5ea62 | ||
|
|
bd0a3d3b96 | ||
|
|
a5f4c4472d | ||
|
|
9ef6eb8d67 | ||
|
|
d5164b7369 | ||
|
|
cf3fbdc5ef | ||
|
|
928f72ec3d | ||
|
|
b4c11881b4 | ||
|
|
58ffde808f | ||
|
|
486acc465d | ||
|
|
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
@@ -100,6 +100,7 @@ export interface OssCredentialsResponse {
|
|||||||
host?: string
|
host?: string
|
||||||
cdn_domain?: string
|
cdn_domain?: string
|
||||||
key_prefix?: string
|
key_prefix?: string
|
||||||
|
object_key?: string
|
||||||
max_size?: number
|
max_size?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
expired_time?: number
|
expired_time?: number
|
||||||
@@ -111,8 +112,10 @@ export interface OssCredentialsResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OssDirectUploadType = 'video' | 'voice' | 'desktop_package'
|
||||||
|
|
||||||
/** 申请 STS 临时凭证 */
|
/** 申请 STS 临时凭证 */
|
||||||
export function getOssCredentials(params: { type: 'video' }) {
|
export function getOssCredentials(params: { type: OssDirectUploadType; name?: string }) {
|
||||||
return request.post({
|
return request.post({
|
||||||
url: '/upload/ossCredentials',
|
url: '/upload/ossCredentials',
|
||||||
params
|
params
|
||||||
@@ -121,7 +124,7 @@ export function getOssCredentials(params: { type: 'video' }) {
|
|||||||
|
|
||||||
/** 直传完成回执:写 file 表 + HEAD 校验 */
|
/** 直传完成回执:写 file 表 + HEAD 校验 */
|
||||||
export function confirmOssUpload(params: {
|
export function confirmOssUpload(params: {
|
||||||
type: 'video'
|
type: OssDirectUploadType
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
size: number
|
size: number
|
||||||
|
|||||||
@@ -293,6 +293,50 @@ export function wecomPromotionBatchSetOperators(params: WecomPromotionBatchSetOp
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionBatchUpdatePoolsParams {
|
||||||
|
pool_ids: number[]
|
||||||
|
changes: {
|
||||||
|
skip_verify?: 0 | 1
|
||||||
|
fallback_url?: string
|
||||||
|
status?: 0 | 1
|
||||||
|
automation_config?: Record<string, unknown>
|
||||||
|
member_status?: {
|
||||||
|
member_admin_ids: number[]
|
||||||
|
status: 0 | 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionBatchUpdatePoolResult {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
success: boolean
|
||||||
|
sync_error?: string
|
||||||
|
sync_queued?: boolean
|
||||||
|
member_matched?: number
|
||||||
|
member_updated?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WecomPromotionBatchUpdatePoolsResult {
|
||||||
|
pool_ids: number[]
|
||||||
|
updated: number
|
||||||
|
failed: number
|
||||||
|
sync_error_count: number
|
||||||
|
sync_queued_count: number
|
||||||
|
member_matched: number
|
||||||
|
member_updated: number
|
||||||
|
results: WecomPromotionBatchUpdatePoolResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionBatchUpdatePools(params: WecomPromotionBatchUpdatePoolsParams) {
|
||||||
|
return request.post<WecomPromotionBatchUpdatePoolsResult>({
|
||||||
|
url: '/firstvisit.wecomPromotion/batchUpdatePools',
|
||||||
|
params,
|
||||||
|
timeout: 120000
|
||||||
|
}, { ignoreCancelToken: true })
|
||||||
|
}
|
||||||
|
|
||||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
|
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params, timeout: 120000 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ export function qywxCustomerLists(params: any) {
|
|||||||
return request.get({ url: '/qywx.customer/lists', params })
|
return request.get({ url: '/qywx.customer/lists', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除一条本地企业微信客户同步记录
|
||||||
|
export function qywxCustomerDelete(params: { id: number }) {
|
||||||
|
return request.post({ url: '/qywx.customer/delete', params })
|
||||||
|
}
|
||||||
|
|
||||||
// 同步企业微信客户
|
// 同步企业微信客户
|
||||||
export function qywxCustomerSync() {
|
export function qywxCustomerSync() {
|
||||||
return request.post({ url: '/qywx.customer/sync' })
|
return request.post({ url: '/qywx.customer/sync' })
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ import useAppStore from '@/stores/modules/app'
|
|||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import {
|
import {
|
||||||
|
DirectUploadApiError,
|
||||||
DirectUploadFallbackError,
|
DirectUploadFallbackError,
|
||||||
uploadVideoDirectToCos
|
uploadDirectToCos,
|
||||||
|
type DirectUploadType
|
||||||
} from '@/utils/oss-direct-upload'
|
} from '@/utils/oss-direct-upload'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -83,7 +85,7 @@ export default defineComponent({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
// 视频直传到 OSS(绕开服务器中转,仅 type=video 生效)
|
// 直传到对象存储,绕开服务器中转
|
||||||
direct: {
|
direct: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
@@ -102,8 +104,10 @@ export default defineComponent({
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const fileList = ref<any[]>([])
|
const fileList = ref<any[]>([])
|
||||||
|
|
||||||
// 仅 video/voice + direct 时才接管 http-request
|
const directTypes: DirectUploadType[] = ['video', 'voice', 'desktop_package']
|
||||||
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
|
const useDirect = computed(
|
||||||
|
() => props.direct && directTypes.includes(props.type as DirectUploadType)
|
||||||
|
)
|
||||||
|
|
||||||
const handleProgress = () => {
|
const handleProgress = () => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
@@ -131,7 +135,10 @@ export default defineComponent({
|
|||||||
fileList.value = []
|
fileList.value = []
|
||||||
emit('allSuccess')
|
emit('allSuccess')
|
||||||
}
|
}
|
||||||
feedback.msgError(`${file.name}文件上传失败`)
|
if (!(event instanceof DirectUploadApiError)) {
|
||||||
|
const message = event instanceof Error ? event.message : ''
|
||||||
|
feedback.msgError(message || `${file.name}文件上传失败`)
|
||||||
|
}
|
||||||
uploadRefs.value?.abort(file)
|
uploadRefs.value?.abort(file)
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('change', file)
|
emit('change', file)
|
||||||
@@ -153,18 +160,20 @@ export default defineComponent({
|
|||||||
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
|
||||||
case 'voice':
|
case 'voice':
|
||||||
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
|
||||||
|
case 'desktop_package':
|
||||||
|
return '.exe,.zip'
|
||||||
default:
|
default:
|
||||||
return '*'
|
return '*'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
|
// 走 COS 直传:成功时模拟老接口的响应 envelope
|
||||||
const httpRequest = async (options: UploadRequestOptions) => {
|
const httpRequest = async (options: UploadRequestOptions) => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
try {
|
try {
|
||||||
const data = await uploadVideoDirectToCos({
|
const data = await uploadDirectToCos({
|
||||||
file: options.file,
|
file: options.file,
|
||||||
type: props.type as any,
|
type: props.type as DirectUploadType,
|
||||||
cid: Number((options.data as any)?.cid ?? 0),
|
cid: Number((options.data as any)?.cid ?? 0),
|
||||||
onProgress(info) {
|
onProgress(info) {
|
||||||
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
|
||||||
@@ -178,6 +187,12 @@ export default defineComponent({
|
|||||||
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
|
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err instanceof DirectUploadFallbackError) {
|
if (err instanceof DirectUploadFallbackError) {
|
||||||
|
if (props.type === 'desktop_package') {
|
||||||
|
;(options as any).onError?.(
|
||||||
|
new Error('当前未启用腾讯云 COS,安装包无法直传,请配置 COS 后重试')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
|
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
|
||||||
await defaultXhrUpload(options)
|
await defaultXhrUpload(options)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import COS from 'cos-js-sdk-v5'
|
|||||||
import {
|
import {
|
||||||
confirmOssUpload,
|
confirmOssUpload,
|
||||||
getOssCredentials,
|
getOssCredentials,
|
||||||
type OssCredentialsResponse
|
type OssCredentialsResponse,
|
||||||
|
type OssDirectUploadType
|
||||||
} from '@/api/file'
|
} from '@/api/file'
|
||||||
|
|
||||||
export type DirectUploadType = 'video'
|
export type DirectUploadType = OssDirectUploadType
|
||||||
|
|
||||||
export interface DirectUploadProgress {
|
export interface DirectUploadProgress {
|
||||||
/** 0-100 */
|
/** 0-100 */
|
||||||
@@ -37,8 +38,17 @@ export interface DirectUploadOptions {
|
|||||||
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
|
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
const ASYNC_LIMIT = 3
|
const ASYNC_LIMIT = 3
|
||||||
|
|
||||||
|
async function callDirectUploadApi<T>(request: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await request()
|
||||||
|
} catch (error) {
|
||||||
|
// request 拦截器已经展示过接口/网络错误,上传组件只负责收口失败状态
|
||||||
|
throw new DirectUploadApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildKey(prefix: string, file: File): string {
|
function buildKey(prefix: string, file: File): string {
|
||||||
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
|
const ext = (file.name.split('.').pop() || 'bin').toLowerCase()
|
||||||
const ts = Date.now()
|
const ts = Date.now()
|
||||||
const rand = Math.random().toString(36).slice(2, 10)
|
const rand = Math.random().toString(36).slice(2, 10)
|
||||||
return `${prefix}${ts}-${rand}.${ext}`
|
return `${prefix}${ts}-${rand}.${ext}`
|
||||||
@@ -48,8 +58,13 @@ function buildKey(prefix: string, file: File): string {
|
|||||||
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
|
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
|
||||||
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
|
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
|
||||||
*/
|
*/
|
||||||
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
export async function uploadDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
|
||||||
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
|
const credentials: OssCredentialsResponse = await callDirectUploadApi(() =>
|
||||||
|
getOssCredentials({
|
||||||
|
type: options.type,
|
||||||
|
name: options.file.name
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
if (credentials.fallback) {
|
if (credentials.fallback) {
|
||||||
const handled = options.onFallback?.(credentials.provider) ?? false
|
const handled = options.onFallback?.(credentials.provider) ?? false
|
||||||
@@ -66,7 +81,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
|||||||
|
|
||||||
if (credentials.max_size && options.file.size > credentials.max_size) {
|
if (credentials.max_size && options.file.size > credentials.max_size) {
|
||||||
const mb = Math.round(credentials.max_size / 1024 / 1024)
|
const mb = Math.round(credentials.max_size / 1024 / 1024)
|
||||||
throw new Error(`视频体积超出上限(${mb}MB)`)
|
throw new Error(`文件体积超出上限(${mb}MB)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cred = credentials.credentials
|
const cred = credentials.credentials
|
||||||
@@ -85,7 +100,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const key = buildKey(credentials.key_prefix, options.file)
|
const key = credentials.object_key || buildKey(credentials.key_prefix, options.file)
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
cos.uploadFile(
|
cos.uploadFile(
|
||||||
@@ -117,14 +132,16 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const confirmed = await confirmOssUpload({
|
const confirmed = await callDirectUploadApi(() =>
|
||||||
type: options.type,
|
confirmOssUpload({
|
||||||
key,
|
type: options.type,
|
||||||
name: options.file.name,
|
key,
|
||||||
size: options.file.size,
|
name: options.file.name,
|
||||||
content_type: options.file.type || '',
|
size: options.file.size,
|
||||||
cid: options.cid ?? 0
|
content_type: options.file.type || '',
|
||||||
})
|
cid: options.cid ?? 0
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
|
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
|
||||||
|
|
||||||
@@ -140,3 +157,14 @@ export class DirectUploadFallbackError extends Error {
|
|||||||
this.provider = provider
|
this.provider = provider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 请求层已经展示过错误,避免 ElUpload 再弹一条通用失败提示。 */
|
||||||
|
export class DirectUploadApiError extends Error {
|
||||||
|
readonly originalError: unknown
|
||||||
|
|
||||||
|
constructor(error: unknown) {
|
||||||
|
super('')
|
||||||
|
this.name = 'DirectUploadApiError'
|
||||||
|
this.originalError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,23 @@
|
|||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="渠道">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.add_way"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="选择或搜索添加渠道"
|
||||||
|
style="width: 240px"
|
||||||
|
@change="resetPage"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in ADD_WAY_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="添加时间">
|
<el-form-item label="添加时间">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="addTimeRange"
|
v-model="addTimeRange"
|
||||||
@@ -235,6 +252,32 @@
|
|||||||
<span v-else class="text-gray-400">—</span>
|
<span v-else class="text-gray-400">—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="添加渠道" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="customerAddSources(row).length" class="flex items-center gap-1">
|
||||||
|
<el-tooltip
|
||||||
|
v-for="source in customerAddSources(row).slice(0, 1)"
|
||||||
|
:key="source.key"
|
||||||
|
:content="addSourceTooltip(source)"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="inline-block max-w-[150px] truncate align-middle">
|
||||||
|
{{ source.label }}
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip
|
||||||
|
v-if="customerAddSources(row).length > 1"
|
||||||
|
:content="remainingAddSourcesTooltip(row)"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="text-primary whitespace-nowrap cursor-help">
|
||||||
|
另 {{ customerAddSources(row).length - 1 }} 条
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400">未记录</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="添加时间" width="160">
|
<el-table-column label="添加时间" width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ formatTime(firstExternalAddTime(row)) }}
|
{{ formatTime(firstExternalAddTime(row)) }}
|
||||||
@@ -245,9 +288,19 @@
|
|||||||
{{ formatTime(row.update_time) }}
|
{{ formatTime(row.update_time) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120" fixed="right">
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
|
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['qywx.customer/delete']"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
:loading="deletingCustomerId === Number(row.id)"
|
||||||
|
:disabled="deletingCustomerId !== null"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -501,6 +554,21 @@
|
|||||||
<el-descriptions-item label="添加时间" :span="2">
|
<el-descriptions-item label="添加时间" :span="2">
|
||||||
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="添加渠道" :span="2">
|
||||||
|
<div v-if="customerAddSources(currentCustomer).length" class="flex flex-wrap gap-1">
|
||||||
|
<el-tooltip
|
||||||
|
v-for="source in customerAddSources(currentCustomer)"
|
||||||
|
:key="source.key"
|
||||||
|
:content="addSourceTooltip(source)"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag size="small" type="info" effect="plain">
|
||||||
|
{{ source.label }}
|
||||||
|
</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400">未记录</span>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="更新时间" :span="2">
|
<el-descriptions-item label="更新时间" :span="2">
|
||||||
{{ formatTime(currentCustomer.update_time) }}
|
{{ formatTime(currentCustomer.update_time) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -550,6 +618,7 @@ import { usePaging } from '@/hooks/usePaging'
|
|||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import {
|
import {
|
||||||
qywxCustomerLists,
|
qywxCustomerLists,
|
||||||
|
qywxCustomerDelete,
|
||||||
qywxCustomerSync,
|
qywxCustomerSync,
|
||||||
qywxCustomerStats,
|
qywxCustomerStats,
|
||||||
qywxSyncSettingsGet,
|
qywxSyncSettingsGet,
|
||||||
@@ -563,6 +632,7 @@ const syncing = ref(false)
|
|||||||
const showSyncSettings = ref(false)
|
const showSyncSettings = ref(false)
|
||||||
const showDetail = ref(false)
|
const showDetail = ref(false)
|
||||||
const currentCustomer = ref<any>(null)
|
const currentCustomer = ref<any>(null)
|
||||||
|
const deletingCustomerId = ref<number | null>(null)
|
||||||
|
|
||||||
const stats = reactive({
|
const stats = reactive({
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -594,6 +664,7 @@ const syncSettings = reactive({
|
|||||||
const queryParams = reactive<{
|
const queryParams = reactive<{
|
||||||
name: string
|
name: string
|
||||||
follow_user: string
|
follow_user: string
|
||||||
|
add_way: number | ''
|
||||||
tag_ids: string[]
|
tag_ids: string[]
|
||||||
add_time_start: string
|
add_time_start: string
|
||||||
add_time_end: string
|
add_time_end: string
|
||||||
@@ -601,6 +672,7 @@ const queryParams = reactive<{
|
|||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
follow_user: '',
|
follow_user: '',
|
||||||
|
add_way: '',
|
||||||
tag_ids: [],
|
tag_ids: [],
|
||||||
add_time_start: '',
|
add_time_start: '',
|
||||||
add_time_end: '',
|
add_time_end: '',
|
||||||
@@ -639,6 +711,22 @@ interface TagStatsPayload {
|
|||||||
groups: TagGroup[]
|
groups: TagGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AddChannel {
|
||||||
|
state: string
|
||||||
|
label: string
|
||||||
|
source_type: 'promotion_pool' | 'state'
|
||||||
|
pool_id: number
|
||||||
|
user_id: string
|
||||||
|
event_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddSource extends AddChannel {
|
||||||
|
key: string
|
||||||
|
add_way: number | null
|
||||||
|
channel_label: string
|
||||||
|
staff_name: string
|
||||||
|
}
|
||||||
|
|
||||||
const tagStats = reactive<TagStatsPayload>({
|
const tagStats = reactive<TagStatsPayload>({
|
||||||
total_tags: 0,
|
total_tags: 0,
|
||||||
total_relations: 0,
|
total_relations: 0,
|
||||||
@@ -850,6 +938,7 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
|||||||
function handleReset() {
|
function handleReset() {
|
||||||
queryParams.name = ''
|
queryParams.name = ''
|
||||||
queryParams.follow_user = ''
|
queryParams.follow_user = ''
|
||||||
|
queryParams.add_way = ''
|
||||||
queryParams.tag_ids = []
|
queryParams.tag_ids = []
|
||||||
queryParams.add_time_start = ''
|
queryParams.add_time_start = ''
|
||||||
queryParams.add_time_end = ''
|
queryParams.add_time_end = ''
|
||||||
@@ -964,6 +1053,34 @@ function viewDetail(row: any) {
|
|||||||
showDetail.value = true
|
showDetail.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: Record<string, any>) {
|
||||||
|
const id = Number(row.id)
|
||||||
|
if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return
|
||||||
|
|
||||||
|
const customerName = String(row.name || row.external_userid || '该客户')
|
||||||
|
try {
|
||||||
|
await feedback.confirm(
|
||||||
|
`确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。`
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingCustomerId.value = id
|
||||||
|
try {
|
||||||
|
await qywxCustomerDelete({ id })
|
||||||
|
if (pager.page > 1 && pager.lists.length === 1) {
|
||||||
|
pager.page -= 1
|
||||||
|
}
|
||||||
|
await Promise.all([getLists(), loadStats(), loadTagStats()])
|
||||||
|
feedback.msgSuccess('删除成功')
|
||||||
|
} catch (e: any) {
|
||||||
|
feedback.msgError(e?.message || e?.msg || '删除失败')
|
||||||
|
} finally {
|
||||||
|
deletingCustomerId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 列表接口会写入 admin_name(admin.work_wechat_userid = userid) */
|
/** 列表接口会写入 admin_name(admin.work_wechat_userid = userid) */
|
||||||
function formatFollowUser(user: Record<string, any>) {
|
function formatFollowUser(user: Record<string, any>) {
|
||||||
const adminName = String(user?.admin_name ?? '').trim()
|
const adminName = String(user?.admin_name ?? '').trim()
|
||||||
@@ -985,6 +1102,167 @@ function followStaffTooltip(user: Record<string, any>) {
|
|||||||
return parts.join('|')
|
return parts.join('|')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customerAddChannels(row: Record<string, any> | null | undefined): AddChannel[] {
|
||||||
|
if (!row) return []
|
||||||
|
if (Array.isArray(row.add_channels)) {
|
||||||
|
return row.add_channels
|
||||||
|
.map((channel: Record<string, any>): AddChannel => ({
|
||||||
|
state: String(channel?.state ?? '').trim(),
|
||||||
|
label: String(channel?.label ?? channel?.state ?? '').trim(),
|
||||||
|
source_type: channel?.source_type === 'promotion_pool' ? 'promotion_pool' : 'state',
|
||||||
|
pool_id: Number(channel?.pool_id ?? 0),
|
||||||
|
user_id: String(channel?.user_id ?? '').trim(),
|
||||||
|
event_time: Number(channel?.event_time ?? 0)
|
||||||
|
}))
|
||||||
|
.filter((channel: AddChannel) => channel.state !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容仅返回原始渠道数组的旧接口/灰度节点。
|
||||||
|
if (!Array.isArray(row.add_channel_states)) return []
|
||||||
|
return row.add_channel_states
|
||||||
|
.map((state: unknown) => String(state ?? '').trim())
|
||||||
|
.filter((state: string) => state !== '')
|
||||||
|
.map((state: string) => ({
|
||||||
|
state,
|
||||||
|
label: state,
|
||||||
|
source_type: 'state' as const,
|
||||||
|
pool_id: 0,
|
||||||
|
user_id: '',
|
||||||
|
event_time: 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_WAY_LABELS: Record<number, string> = {
|
||||||
|
0: '未知添加方式',
|
||||||
|
1: '通过扫描二维码添加',
|
||||||
|
2: '通过搜索手机号添加',
|
||||||
|
3: '通过名片分享添加',
|
||||||
|
4: '通过群聊添加',
|
||||||
|
5: '通过手机通讯录添加',
|
||||||
|
6: '通过微信联系人添加',
|
||||||
|
8: '安装第三方应用时自动添加',
|
||||||
|
9: '通过搜索邮箱添加',
|
||||||
|
10: '通过视频号添加',
|
||||||
|
11: '通过日程参与人添加',
|
||||||
|
12: '通过会议参与人添加',
|
||||||
|
13: '通过微信好友添加',
|
||||||
|
14: '通过智慧硬件专属客服添加',
|
||||||
|
15: '通过上门服务客服添加',
|
||||||
|
16: '通过获客链接添加',
|
||||||
|
17: '通过定制开发添加',
|
||||||
|
18: '通过需求回复添加',
|
||||||
|
21: '通过第三方售前客服添加',
|
||||||
|
22: '通过可能的商务伙伴添加',
|
||||||
|
24: '通过接受微信好友申请添加',
|
||||||
|
201: '通过内部成员共享添加',
|
||||||
|
202: '通过管理员或负责人分配添加'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_WAY_OPTIONS = Object.entries(ADD_WAY_LABELS).map(([value, label]) => ({
|
||||||
|
value: Number(value),
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
|
||||||
|
function normalizeAddWay(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value
|
||||||
|
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return null
|
||||||
|
return Number(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWayLabel(addWay: number) {
|
||||||
|
return ADD_WAY_LABELS[addWay] || `其他添加方式(${addWay})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerAddSources(row: Record<string, any> | null | undefined): AddSource[] {
|
||||||
|
if (!row) return []
|
||||||
|
|
||||||
|
const channels = customerAddChannels(row)
|
||||||
|
const usedChannelIndexes = new Set<number>()
|
||||||
|
const sources: AddSource[] = []
|
||||||
|
const followUsers = Array.isArray(row.follow_users) ? row.follow_users : []
|
||||||
|
|
||||||
|
followUsers.forEach((user: Record<string, any>, index: number) => {
|
||||||
|
const userId = String(user?.userid ?? user?.UserId ?? '').trim()
|
||||||
|
const state = String(user?.state ?? user?.State ?? '').trim()
|
||||||
|
const addWay = normalizeAddWay(user?.add_way ?? user?.AddWay)
|
||||||
|
|
||||||
|
let channelIndex = channels.findIndex(
|
||||||
|
(channel, i) =>
|
||||||
|
!usedChannelIndexes.has(i) &&
|
||||||
|
userId !== '' &&
|
||||||
|
state !== '' &&
|
||||||
|
channel.user_id === userId &&
|
||||||
|
channel.state === state
|
||||||
|
)
|
||||||
|
if (channelIndex < 0 && state !== '') {
|
||||||
|
channelIndex = channels.findIndex(
|
||||||
|
(channel, i) => !usedChannelIndexes.has(i) && channel.state === state
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (channelIndex < 0 && userId !== '') {
|
||||||
|
channelIndex = channels.findIndex(
|
||||||
|
(channel, i) => !usedChannelIndexes.has(i) && channel.user_id === userId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = channelIndex >= 0 ? channels[channelIndex] : undefined
|
||||||
|
if (channelIndex >= 0) usedChannelIndexes.add(channelIndex)
|
||||||
|
if (addWay === null && state === '' && !channel) return
|
||||||
|
|
||||||
|
const labelFromApi = String(user?.add_way_label ?? '').trim()
|
||||||
|
const sourceType = channel?.source_type ?? (/^zyt_pool:[1-9]\d*$/.test(state) ? 'promotion_pool' : 'state')
|
||||||
|
const label = labelFromApi || (addWay !== null
|
||||||
|
? addWayLabel(addWay)
|
||||||
|
: sourceType === 'promotion_pool'
|
||||||
|
? '通过获客链接添加'
|
||||||
|
: '通过其他渠道添加')
|
||||||
|
|
||||||
|
sources.push({
|
||||||
|
key: `follow:${index}:${userId}:${addWay ?? 'unknown'}:${state}`,
|
||||||
|
add_way: addWay,
|
||||||
|
label,
|
||||||
|
state: state || channel?.state || '',
|
||||||
|
channel_label: channel?.label || '',
|
||||||
|
source_type: sourceType,
|
||||||
|
pool_id: channel?.pool_id || 0,
|
||||||
|
user_id: userId || channel?.user_id || '',
|
||||||
|
staff_name: formatFollowUser(user),
|
||||||
|
event_time: channel?.event_time || Number(user?.createtime ?? 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 兼容事件日志中仍有记录、但当前 follow_users 已不存在或旧接口未返回 add_way 的客户。
|
||||||
|
channels.forEach((channel, index) => {
|
||||||
|
if (usedChannelIndexes.has(index)) return
|
||||||
|
sources.push({
|
||||||
|
...channel,
|
||||||
|
key: `channel:${index}:${channel.user_id}:${channel.state}`,
|
||||||
|
add_way: channel.source_type === 'promotion_pool' ? 16 : null,
|
||||||
|
label: channel.source_type === 'promotion_pool' ? '通过获客链接添加' : '通过其他渠道添加',
|
||||||
|
channel_label: channel.label,
|
||||||
|
staff_name: channel.user_id || '—'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return sources.sort((a, b) => b.event_time - a.event_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSourceTooltip(source: AddSource) {
|
||||||
|
const parts: string[] = []
|
||||||
|
parts.push(`添加方式:${source.label}`)
|
||||||
|
if (source.source_type === 'promotion_pool' && source.channel_label) {
|
||||||
|
parts.push(`获客助手方案:${source.channel_label}`)
|
||||||
|
}
|
||||||
|
if (source.staff_name && source.staff_name !== '—') parts.push(`跟进人:${source.staff_name}`)
|
||||||
|
if (source.event_time > 0) parts.push(`添加时间:${formatTime(source.event_time)}`)
|
||||||
|
if (source.state) parts.push(`渠道参数:${source.state}`)
|
||||||
|
return parts.join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingAddSourcesTooltip(row: Record<string, any>) {
|
||||||
|
return customerAddSources(row).slice(1).map(addSourceTooltip).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
|
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
|
||||||
* 再解析 follow_users;最后退回 create_time
|
* 再解析 follow_users;最后退回 create_time
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-31
@@ -2,9 +2,10 @@
|
|||||||
<div class="automation-form">
|
<div class="automation-form">
|
||||||
<el-alert class="automation-note" type="info" show-icon :closable="false" title="自动化设置只作用于之后新添加的客户,不会写入企微获客链接详情中的“欢迎语/客户标签”配置。" description="系统会在客户添加回调中立即发送渠道欢迎语并添加标签,后台任务负责失败重试及其他补偿。测试时请使用系统复制的、带渠道参数的链接。" />
|
<el-alert class="automation-note" type="info" show-icon :closable="false" title="自动化设置只作用于之后新添加的客户,不会写入企微获客链接详情中的“欢迎语/客户标签”配置。" description="系统会在客户添加回调中立即发送渠道欢迎语并添加标签,后台任务负责失败重试及其他补偿。测试时请使用系统复制的、带渠道参数的链接。" />
|
||||||
|
|
||||||
|
<section class="automation-section" :class="{ 'is-disabled': receptionDisabled }">
|
||||||
<h3 class="form-section-title">接待设置</h3>
|
<h3 class="form-section-title">接待设置</h3>
|
||||||
<el-form-item label="接待模式">
|
<el-form-item label="接待模式">
|
||||||
<el-radio-group v-model="config.reception_mode" :disabled="disabled">
|
<el-radio-group v-model="config.reception_mode" :disabled="receptionDisabled">
|
||||||
<el-radio value="always">全天接待</el-radio>
|
<el-radio value="always">全天接待</el-radio>
|
||||||
<el-radio value="scheduled">按星期时段自动上下线</el-radio>
|
<el-radio value="scheduled">按星期时段自动上下线</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
@@ -12,50 +13,52 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div v-if="config.reception_mode === 'scheduled'" class="reception-schedules">
|
<div v-if="config.reception_mode === 'scheduled'" class="reception-schedules">
|
||||||
<div v-for="(slot, index) in config.reception_schedule" :key="index" class="schedule-card">
|
<div v-for="(slot, index) in config.reception_schedule" :key="index" class="schedule-card">
|
||||||
<div class="schedule-heading"><strong>接待时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="disabled" @click="config.reception_schedule.splice(index, 1)">删除时段</el-button></div>
|
<div class="schedule-heading"><strong>接待时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="receptionDisabled" @click="config.reception_schedule.splice(index, 1)">删除时段</el-button></div>
|
||||||
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="disabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="receptionDisabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
||||||
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="receptionDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||||
<el-select v-model="slot.member_admin_ids" :disabled="disabled" multiple filterable clearable placeholder="从上方主接待成员中选择" style="width: 100%">
|
<el-select v-model="slot.member_admin_ids" :disabled="receptionDisabled" multiple filterable clearable placeholder="从上方主接待成员中选择" style="width: 100%">
|
||||||
<el-option v-for="member in mainMembers" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" />
|
<el-option v-for="member in mainMembers" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<p v-if="slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))" class="inline-error">该时段含已从主接待移除的成员,请重新选择。</p>
|
<p v-if="slot.member_admin_ids.some((id) => !mainMemberIds.includes(id))" class="inline-error">该时段含已从主接待移除的成员,请重新选择。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button :icon="Plus" :disabled="disabled || config.reception_schedule.length >= 30" @click="addReceptionSlot">添加接待时段</el-button>
|
<el-button :icon="Plus" :disabled="receptionDisabled || config.reception_schedule.length >= 30" @click="addReceptionSlot">添加接待时段</el-button>
|
||||||
<p class="field-help">最多 30 个时段。跨午夜时段归属开始日,例如星期一 22:00 至 02:00 包含星期二凌晨;接待时段重叠时取成员并集。</p>
|
<p class="field-help">最多 30 个时段。跨午夜时段归属开始日,例如星期一 22:00 至 02:00 包含星期二凌晨;接待时段重叠时取成员并集。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="备用成员" :required="config.reception_mode === 'scheduled'">
|
<el-form-item label="备用成员" :required="config.reception_mode === 'scheduled'">
|
||||||
<el-select v-model="config.backup_member_admin_ids" :disabled="disabled" multiple filterable clearable collapse-tags collapse-tags-tooltip :max-collapse-tags="3" :multiple-limit="500" placeholder="主接待成员均不可用时由备用成员接待" style="width: 100%">
|
<el-select v-model="config.backup_member_admin_ids" :disabled="receptionDisabled" multiple filterable clearable collapse-tags collapse-tags-tooltip :max-collapse-tags="3" :multiple-limit="500" placeholder="主接待成员均不可用时由备用成员接待" style="width: 100%">
|
||||||
<el-option v-for="member in members" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" :disabled="mainMemberIds.includes(Number(member.id))" />
|
<el-option v-for="member in members" :key="member.id" :value="Number(member.id)" :label="memberLabel(member)" :disabled="backupExcludedIds.includes(Number(member.id))" />
|
||||||
<el-option v-for="id in missingBackupIds" :key="`missing-${id}`" :value="id" :label="`成员 ${id}(当前不可选,请移除后重新选择)`" disabled />
|
<el-option v-for="id in missingBackupIds" :key="`missing-${id}`" :value="id" :label="`成员 ${id}(当前不可选,请移除后重新选择)`" disabled />
|
||||||
</el-select>
|
</el-select>
|
||||||
<p class="field-help">备用成员不能与主接待重复。按时段模式至少配置一名备用成员;仅当无可用主接待时进入官方成员范围。</p>
|
<p class="field-help">备用成员不能与主接待重复。按时段模式至少配置一名备用成员;仅当无可用主接待时进入官方成员范围。</p>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="automation-section" :class="{ 'is-disabled': customerDisabled }">
|
||||||
<h3 class="form-section-title">客户设置</h3>
|
<h3 class="form-section-title">客户设置</h3>
|
||||||
<el-form-item label="自动添加客户标签">
|
<el-form-item label="自动添加客户标签">
|
||||||
<el-switch v-model="config.tags_enabled" :disabled="disabled || tagsCreating" />
|
<el-switch v-model="config.tags_enabled" :disabled="customerDisabled || tagsCreating" />
|
||||||
<div v-if="hasMultipleTags" class="legacy-tags-warning full-width" role="alert">
|
<div v-if="hasMultipleTags" class="legacy-tags-warning full-width" role="alert">
|
||||||
<p>原方案设置了多个标签:{{ selectedTagNames }}。现在仅支持单选,请重新选择一个标签,或清空原标签。</p>
|
<p>原方案设置了多个标签:{{ selectedTagNames }}。现在仅支持单选,请重新选择一个标签,或清空原标签。</p>
|
||||||
<el-button size="small" :disabled="disabled || tagsCreating" @click="selectedTag = ''">清空原标签</el-button>
|
<el-button size="small" :disabled="customerDisabled || tagsCreating" @click="selectedTag = ''">清空原标签</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="config.tags_enabled" class="full-width tags-content">
|
<div v-if="config.tags_enabled" class="full-width tags-content">
|
||||||
<div class="tag-select-row">
|
<div class="tag-select-row">
|
||||||
<el-select v-model="selectedTag" :disabled="disabled || tagsCreating" :loading="tagsLoading" filterable clearable placeholder="选择一个企业微信客户标签" aria-label="企业微信客户标签" class="tag-select">
|
<el-select v-model="selectedTag" :disabled="customerDisabled || tagsCreating" :loading="tagsLoading" filterable clearable placeholder="选择一个企业微信客户标签" aria-label="企业微信客户标签" class="tag-select">
|
||||||
<el-option-group v-for="group in tagGroups" :key="group.group_id" :label="group.group_name">
|
<el-option-group v-for="group in tagGroups" :key="group.group_id" :label="group.group_name">
|
||||||
<el-option v-for="tag in group.tag" :key="tag.id" :value="tag.id" :label="tag.name" />
|
<el-option v-for="tag in group.tag" :key="tag.id" :value="tag.id" :label="tag.name" />
|
||||||
</el-option-group>
|
</el-option-group>
|
||||||
<el-option-group v-if="unknownTagIds.length" label="已选标签(名称暂不可用)"><el-option v-for="id in unknownTagIds" :key="id" :value="id" :label="`已选标签 · ${id}`" /></el-option-group>
|
<el-option-group v-if="unknownTagIds.length" label="已选标签(名称暂不可用)"><el-option v-for="id in unknownTagIds" :key="id" :value="id" :label="`已选标签 · ${id}`" /></el-option-group>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button :icon="Plus" :disabled="disabled || tagsCreating" @click="showCustomTag = !showCustomTag">自定义标签</el-button>
|
<el-button :icon="Plus" :disabled="customerDisabled || tagsCreating" @click="showCustomTag = !showCustomTag">自定义标签</el-button>
|
||||||
<el-button :icon="Refresh" :disabled="disabled || tagsCreating" :loading="tagsLoading" @click="loadTags">{{ tagsError ? '重试' : '刷新标签' }}</el-button>
|
<el-button :icon="Refresh" :disabled="customerDisabled || tagsCreating" :loading="tagsLoading" @click="loadTags">{{ tagsError ? '重试' : '刷新标签' }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="tagsError" role="alert" class="inline-error">{{ tagsError }} 已保留原有标签,点击“重试”重新加载。</p>
|
<p v-if="tagsError" role="alert" class="inline-error">{{ tagsError }} 已保留原有标签,点击“重试”重新加载。</p>
|
||||||
<p v-else class="field-help">每个方案只选一个标签,可选择已有企业微信标签,也可自定义创建。客户添加成功后由系统调用企微接口打标,不会显示在企微获客链接详情的“客户标签”配置中。</p>
|
<p v-else class="field-help">每个方案只选一个标签,可选择已有企业微信标签,也可自定义创建。客户添加成功后由系统调用企微接口打标,不会显示在企微获客链接详情的“客户标签”配置中。</p>
|
||||||
<div v-if="showCustomTag" class="custom-tag-editor">
|
<div v-if="showCustomTag" class="custom-tag-editor">
|
||||||
<label for="promotion-custom-tag-name">自定义标签名称</label>
|
<label for="promotion-custom-tag-name">自定义标签名称</label>
|
||||||
<div class="custom-tag-row">
|
<div class="custom-tag-row">
|
||||||
<el-input id="promotion-custom-tag-name" v-model="customTagName" :disabled="disabled || tagsCreating" maxlength="30" show-word-limit placeholder="例如:官网咨询" @input="customTagError = ''" @keydown.enter.prevent="createCustomTag" />
|
<el-input id="promotion-custom-tag-name" v-model="customTagName" :disabled="customerDisabled || tagsCreating" maxlength="30" show-word-limit placeholder="例如:官网咨询" @input="customTagError = ''" @keydown.enter.prevent="createCustomTag" />
|
||||||
<el-button type="primary" :disabled="disabled || tagsLoading" :loading="tagsCreating" @click="createCustomTag">创建并选用</el-button>
|
<el-button type="primary" :disabled="customerDisabled || tagsLoading" :loading="tagsCreating" @click="createCustomTag">创建并选用</el-button>
|
||||||
</div>
|
</div>
|
||||||
<p class="field-help">创建到企业微信“推广渠道”分组,同组同名标签会复用。创建后即保存到企微标签库,取消方案编辑不会删除标签。</p>
|
<p class="field-help">创建到企业微信“推广渠道”分组,同组同名标签会复用。创建后即保存到企微标签库,取消方案编辑不会删除标签。</p>
|
||||||
<p v-if="customTagError" role="alert" class="inline-error">{{ customTagError }} 原有选择未改变。</p>
|
<p v-if="customTagError" role="alert" class="inline-error">{{ customTagError }} 原有选择未改变。</p>
|
||||||
@@ -64,22 +67,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="自动设置客户备注">
|
<el-form-item label="自动设置客户备注">
|
||||||
<el-switch v-model="config.remark_enabled" :disabled="disabled" />
|
<el-switch v-model="config.remark_enabled" :disabled="customerDisabled" />
|
||||||
<div v-if="config.remark_enabled" class="full-width remark-content">
|
<div v-if="config.remark_enabled" class="full-width remark-content">
|
||||||
<div class="token-buttons"><el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="disabled" @click="insertRemark(token.value)">插入{{ token.label }}</el-button></div>
|
<div class="token-buttons"><el-button v-for="token in templateTokens" :key="token.value" size="small" :disabled="customerDisabled" @click="insertRemark(token.value)">插入{{ token.label }}</el-button></div>
|
||||||
<el-input ref="remarkInput" v-model="config.remark_template" :disabled="disabled" maxlength="200" show-word-limit placeholder="例如:官网-{customer_name}" @select="rememberRemarkSelection" @keyup="rememberRemarkSelection" @click="rememberRemarkSelection" @blur="rememberRemarkSelection" />
|
<el-input ref="remarkInput" v-model="config.remark_template" :disabled="customerDisabled" maxlength="200" show-word-limit placeholder="例如:官网-{customer_name}" @select="rememberRemarkSelection" @keyup="rememberRemarkSelection" @click="rememberRemarkSelection" @blur="rememberRemarkSelection" />
|
||||||
<div class="remark-preview"><span>备注预览</span><strong>{{ remarkPreview || '—' }}</strong><small>{{ Array.from(remarkPreview).length }}/20 字</small></div>
|
<div class="remark-preview"><span>备注预览</span><strong>{{ remarkPreview || '—' }}</strong><small>{{ Array.from(remarkPreview).length }}/20 字</small></div>
|
||||||
<p class="field-help">示例客户:张女士;员工:{{ employeeName }}。添加时间格式为 YYYY-MM-DD,生成后的备注最多保留前 20 字。</p>
|
<p class="field-help">示例客户:张女士;员工:{{ employeeName }}。添加时间格式为 YYYY-MM-DD,生成后的备注最多保留前 20 字。</p>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="自动设置客户描述">
|
<el-form-item label="自动设置客户描述">
|
||||||
<el-switch v-model="config.description_enabled" :disabled="disabled" />
|
<el-switch v-model="config.description_enabled" :disabled="customerDisabled" />
|
||||||
<el-input v-if="config.description_enabled" v-model="config.description" class="description-input" :disabled="disabled" type="textarea" :rows="3" maxlength="150" show-word-limit placeholder="请输入客户描述,最多 150 字" />
|
<el-input v-if="config.description_enabled" v-model="config.description" class="description-input" :disabled="customerDisabled" type="textarea" :rows="3" maxlength="150" show-word-limit placeholder="请输入客户描述,最多 150 字" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="automation-section" :class="{ 'is-disabled': welcomeDisabled }">
|
||||||
<h3 class="form-section-title">欢迎语设置</h3>
|
<h3 class="form-section-title">欢迎语设置</h3>
|
||||||
<el-form-item label="欢迎语模式">
|
<el-form-item label="欢迎语模式">
|
||||||
<el-radio-group v-model="config.welcome_mode" :disabled="disabled || anyUploading">
|
<el-radio-group v-model="config.welcome_mode" :disabled="welcomeDisabled || anyUploading">
|
||||||
<el-radio value="channel">渠道欢迎语</el-radio>
|
<el-radio value="channel">渠道欢迎语</el-radio>
|
||||||
<el-radio value="default">默认欢迎语</el-radio>
|
<el-radio value="default">默认欢迎语</el-radio>
|
||||||
<el-radio value="none">不发送欢迎语</el-radio>
|
<el-radio value="none">不发送欢迎语</el-radio>
|
||||||
@@ -90,20 +95,21 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<template v-if="config.welcome_mode === 'channel'">
|
<template v-if="config.welcome_mode === 'channel'">
|
||||||
<div class="welcome-block"><h4>基础渠道欢迎语</h4><p class="field-help">未开启分时欢迎语,或新客户添加时间未匹配任何时段时,使用以下内容。</p>
|
<div class="welcome-block"><h4>基础渠道欢迎语</h4><p class="field-help">未开启分时欢迎语,或新客户添加时间未匹配任何时段时,使用以下内容。</p>
|
||||||
<WelcomeMessageEditor v-model="config.welcome" :disabled="disabled" :employee-name="employeeName" @busy="(busy) => updateBusy('basic', busy)" />
|
<WelcomeMessageEditor v-model="config.welcome" :disabled="welcomeDisabled" :employee-name="employeeName" @busy="(busy) => updateBusy('basic', busy)" />
|
||||||
</div>
|
</div>
|
||||||
<el-form-item class="schedule-switch" label="分时欢迎语"><el-switch v-model="config.welcome_schedule_enabled" :disabled="disabled || anyUploading" /><span class="switch-help">按客户添加时的北京时间匹配,时段不能重叠</span></el-form-item>
|
<el-form-item class="schedule-switch" label="分时欢迎语"><el-switch v-model="config.welcome_schedule_enabled" :disabled="welcomeDisabled || anyUploading" /><span class="switch-help">按客户添加时的北京时间匹配,时段不能重叠</span></el-form-item>
|
||||||
<div v-if="config.welcome_schedule_enabled">
|
<div v-if="config.welcome_schedule_enabled">
|
||||||
<div v-for="(slot, index) in config.welcome_schedule" :key="index" class="schedule-card welcome-schedule">
|
<div v-for="(slot, index) in config.welcome_schedule" :key="index" class="schedule-card welcome-schedule">
|
||||||
<div class="schedule-heading"><strong>欢迎语时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="disabled || anyUploading" @click="removeWelcomeSlot(index)">删除时段</el-button></div>
|
<div class="schedule-heading"><strong>欢迎语时段 {{ index + 1 }}</strong><el-button type="danger" text size="small" :disabled="welcomeDisabled || anyUploading" @click="removeWelcomeSlot(index)">删除时段</el-button></div>
|
||||||
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="disabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
<el-checkbox-group v-model="slot.weekdays" class="weekday-select" :disabled="welcomeDisabled"><el-checkbox v-for="(day, i) in weekdays" :key="day" :value="i + 1">{{ day }}</el-checkbox></el-checkbox-group>
|
||||||
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="disabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
<div class="time-row"><el-time-picker v-model="slot.start" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="开始时间" :clearable="false" /><span>至</span><el-time-picker v-model="slot.end" :disabled="welcomeDisabled" format="HH:mm" value-format="HH:mm" placeholder="结束时间" :clearable="false" /><small v-if="slot.end < slot.start">次日结束</small></div>
|
||||||
<WelcomeMessageEditor :model-value="slot" :disabled="disabled" :employee-name="employeeName" @update:model-value="(message) => Object.assign(slot, message)" @busy="(busy) => updateBusy(`slot-${index}`, busy)" />
|
<WelcomeMessageEditor :model-value="slot" :disabled="welcomeDisabled" :employee-name="employeeName" @update:model-value="(message) => Object.assign(slot, message)" @busy="(busy) => updateBusy(`slot-${index}`, busy)" />
|
||||||
</div>
|
</div>
|
||||||
<el-button :icon="Plus" :disabled="disabled || anyUploading || config.welcome_schedule.length >= 30" @click="addWelcomeSlot">添加欢迎语时段</el-button>
|
<el-button :icon="Plus" :disabled="welcomeDisabled || anyUploading || config.welcome_schedule.length >= 30" @click="addWelcomeSlot">添加欢迎语时段</el-button>
|
||||||
<p class="field-help">最多 30 个时段,支持跨午夜。时段外自动使用基础渠道欢迎语,不会随机选择内容。</p>
|
<p class="field-help">最多 30 个时段,支持跨午夜。时段外自动使用基础渠道欢迎语,不会随机选择内容。</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -116,9 +122,22 @@ import WelcomeMessageEditor from './WelcomeMessageEditor.vue'
|
|||||||
import { previewTemplate, templateTokens, validateCustomTagName, weekdays } from './promotion-automation'
|
import { previewTemplate, templateTokens, validateCustomTagName, weekdays } from './promotion-automation'
|
||||||
import type { PromotionAutomationConfig, PromotionMemberChoice } from './promotion-automation'
|
import type { PromotionAutomationConfig, PromotionMemberChoice } from './promotion-automation'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: PromotionAutomationConfig; mainMemberIds: number[]; members: PromotionMemberChoice[]; disabled?: boolean }>()
|
type AutomationSection = 'reception' | 'customer' | 'welcome'
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: PromotionAutomationConfig
|
||||||
|
mainMemberIds: number[]
|
||||||
|
members: PromotionMemberChoice[]
|
||||||
|
disabled?: boolean
|
||||||
|
disabledSections?: AutomationSection[]
|
||||||
|
backupExcludedMemberIds?: number[]
|
||||||
|
}>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [config: PromotionAutomationConfig]; busy: [value: boolean] }>()
|
||||||
const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
const config = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||||
|
const sectionDisabled = (section: AutomationSection) => Boolean(props.disabled || props.disabledSections?.includes(section))
|
||||||
|
const receptionDisabled = computed(() => sectionDisabled('reception'))
|
||||||
|
const customerDisabled = computed(() => sectionDisabled('customer'))
|
||||||
|
const welcomeDisabled = computed(() => sectionDisabled('welcome'))
|
||||||
|
const backupExcludedIds = computed(() => props.backupExcludedMemberIds || props.mainMemberIds)
|
||||||
const mainMembers = computed(() => props.members.filter((member) => props.mainMemberIds.includes(Number(member.id))))
|
const mainMembers = computed(() => props.members.filter((member) => props.mainMemberIds.includes(Number(member.id))))
|
||||||
const missingBackupIds = computed(() => config.value.backup_member_admin_ids.filter((id) => !props.members.some((member) => Number(member.id) === id)))
|
const missingBackupIds = computed(() => config.value.backup_member_admin_ids.filter((id) => !props.members.some((member) => Number(member.id) === id)))
|
||||||
const employeeName = computed(() => mainMembers.value[0]?.name || '小陈')
|
const employeeName = computed(() => mainMembers.value[0]?.name || '小陈')
|
||||||
@@ -153,7 +172,9 @@ const unknownTagIds = computed(() => {
|
|||||||
const ids = new Set(tagGroups.value.flatMap((group) => group.tag.map((tag) => tag.id)))
|
const ids = new Set(tagGroups.value.flatMap((group) => group.tag.map((tag) => tag.id)))
|
||||||
return config.value.tag_ids.filter((id) => !ids.has(id))
|
return config.value.tag_ids.filter((id) => !ids.has(id))
|
||||||
})
|
})
|
||||||
watch(() => config.value.tags_enabled, (enabled) => { if (enabled && !tagsLoaded.value && !tagsLoading.value) void loadTags() }, { immediate: true })
|
watch([() => config.value.tags_enabled, customerDisabled], ([enabled, sectionIsDisabled]) => {
|
||||||
|
if (enabled && !sectionIsDisabled && !tagsLoaded.value && !tagsLoading.value) void loadTags()
|
||||||
|
}, { immediate: true })
|
||||||
function memberLabel(member: PromotionMemberChoice) { return `${member.name} · ${member.dept_names?.join(' / ') || member.userid || '未分部门'}` }
|
function memberLabel(member: PromotionMemberChoice) { return `${member.name} · ${member.dept_names?.join(' / ') || member.userid || '未分部门'}` }
|
||||||
function addReceptionSlot() { config.value.reception_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', member_admin_ids: [...props.mainMemberIds] }) }
|
function addReceptionSlot() { config.value.reception_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', member_admin_ids: [...props.mainMemberIds] }) }
|
||||||
function addWelcomeSlot() { config.value.welcome_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', text: '', attachments: [] }) }
|
function addWelcomeSlot() { config.value.welcome_schedule.push({ weekdays: [1, 2, 3, 4, 5], start: '09:00', end: '18:00', text: '', attachments: [] }) }
|
||||||
@@ -173,7 +194,7 @@ async function loadTags() {
|
|||||||
} finally { tagsLoading.value = false }
|
} finally { tagsLoading.value = false }
|
||||||
}
|
}
|
||||||
async function createCustomTag() {
|
async function createCustomTag() {
|
||||||
if (props.disabled || tagsCreating.value || tagsLoading.value) return
|
if (customerDisabled.value || tagsCreating.value || tagsLoading.value) return
|
||||||
customTagError.value = validateCustomTagName(customTagName.value)
|
customTagError.value = validateCustomTagName(customTagName.value)
|
||||||
customTagSuccess.value = ''
|
customTagSuccess.value = ''
|
||||||
if (customTagError.value) return
|
if (customTagError.value) return
|
||||||
@@ -215,6 +236,7 @@ onBeforeUnmount(() => emit('busy', false))
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.automation-form { width: 100%; }.automation-note { margin-top: 22px; }.automation-note :deep(.el-alert__description) { line-height: 1.7; }
|
.automation-form { width: 100%; }.automation-note { margin-top: 22px; }.automation-note :deep(.el-alert__description) { line-height: 1.7; }
|
||||||
|
.automation-section { min-width: 0; transition: opacity .2s ease; }.automation-section.is-disabled { opacity: .58; }
|
||||||
.form-section-title { margin: 28px 0 18px; padding: 0 0 12px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; }.field-help { width: 100%; font-size: 12px; line-height: 1.7; margin: 6px 0 0; color: #909399; }.warning-help { color: #9f6d14; }.full-width { width: 100%; }.inline-error { width: 100%; color: #d93026; font-size: 12px; line-height: 1.7; margin: 8px 0 0; }
|
.form-section-title { margin: 28px 0 18px; padding: 0 0 12px; border-bottom: 1px solid #ebeef5; font-size: 15px; font-weight: 600; color: #303133; }.field-help { width: 100%; font-size: 12px; line-height: 1.7; margin: 6px 0 0; color: #909399; }.warning-help { color: #9f6d14; }.full-width { width: 100%; }.inline-error { width: 100%; color: #d93026; font-size: 12px; line-height: 1.7; margin: 8px 0 0; }
|
||||||
.schedule-card { padding: 16px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fafbfd; margin-bottom: 12px; }.schedule-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 13px; }.weekday-select { display: flex; flex-wrap: wrap; gap: 0 18px; }.weekday-select :deep(.el-checkbox) { margin-right: 0; }.time-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }.time-row :deep(.el-date-editor.el-input) { width: 150px; }.time-row > span { font-size: 12px; color: #909399; }.time-row > small { font-size: 12px; color: #b88230; }.reception-schedules { margin: 0 0 20px; }
|
.schedule-card { padding: 16px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fafbfd; margin-bottom: 12px; }.schedule-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 13px; }.weekday-select { display: flex; flex-wrap: wrap; gap: 0 18px; }.weekday-select :deep(.el-checkbox) { margin-right: 0; }.time-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }.time-row :deep(.el-date-editor.el-input) { width: 150px; }.time-row > span { font-size: 12px; color: #909399; }.time-row > small { font-size: 12px; color: #b88230; }.reception-schedules { margin: 0 0 20px; }
|
||||||
.tags-content, .remark-content, .description-input { margin-top: 12px; }.tag-select-row { display: flex; gap: 10px; width: 100%; }.tag-select { flex: 1; min-width: 0; }.token-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }.token-buttons .el-button + .el-button { margin-left: 0; }.remark-preview { display: flex; gap: 14px; align-items: center; padding: 10px 12px; background: #f5f7fa; margin-top: 8px; border-radius: 4px; line-height: 1.7; }.remark-preview span, .remark-preview small { color: #909399; font-size: 12px; }.remark-preview strong { color: #303133; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }.remark-preview small { margin-left: auto; white-space: nowrap; }.welcome-block h4 { font-size: 13px; font-weight: 600; margin: 0 0 4px; }.welcome-block > .field-help { margin-bottom: 12px; }.schedule-switch { margin-top: 24px; }.switch-help { margin-left: 12px; color: #909399; font-size: 12px; }.welcome-schedule { background: #fff; }
|
.tags-content, .remark-content, .description-input { margin-top: 12px; }.tag-select-row { display: flex; gap: 10px; width: 100%; }.tag-select { flex: 1; min-width: 0; }.token-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }.token-buttons .el-button + .el-button { margin-left: 0; }.remark-preview { display: flex; gap: 14px; align-items: center; padding: 10px 12px; background: #f5f7fa; margin-top: 8px; border-radius: 4px; line-height: 1.7; }.remark-preview span, .remark-preview small { color: #909399; font-size: 12px; }.remark-preview strong { color: #303133; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }.remark-preview small { margin-left: auto; white-space: nowrap; }.welcome-block h4 { font-size: 13px; font-weight: 600; margin: 0 0 4px; }.welcome-block > .field-help { margin-bottom: 12px; }.schedule-switch { margin-top: 24px; }.switch-help { margin-left: 12px; color: #909399; font-size: 12px; }.welcome-schedule { background: #fff; }
|
||||||
|
|||||||
@@ -58,6 +58,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="section-heading-actions">
|
<div class="section-heading-actions">
|
||||||
<span v-if="selectedPoolIds.length" class="selection-count">已选 {{ selectedPoolIds.length }} 个方案</span>
|
<span v-if="selectedPoolIds.length" class="selection-count">已选 {{ selectedPoolIds.length }} 个方案</span>
|
||||||
|
<el-button
|
||||||
|
:icon="Edit"
|
||||||
|
:disabled="!selectedPoolIds.length"
|
||||||
|
@click="openBatchConfigDialog()"
|
||||||
|
>批量修改方案</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
:icon="User"
|
:icon="User"
|
||||||
:disabled="!selectedPoolIds.length"
|
:disabled="!selectedPoolIds.length"
|
||||||
@@ -69,6 +74,16 @@
|
|||||||
|
|
||||||
<div v-if="overview.pools.length" class="pool-layout">
|
<div v-if="overview.pools.length" class="pool-layout">
|
||||||
<aside class="pool-sidebar">
|
<aside class="pool-sidebar">
|
||||||
|
<div class="pool-select-all">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="allManageablePoolsSelected"
|
||||||
|
:indeterminate="someManageablePoolsSelected"
|
||||||
|
:disabled="!manageablePoolIds.length"
|
||||||
|
aria-label="全选可管理的分流方案"
|
||||||
|
@change="toggleAllPoolSelection"
|
||||||
|
>全选</el-checkbox>
|
||||||
|
<span>可选 {{ manageablePoolIds.length }} 个</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="pool in overview.pools"
|
v-for="pool in overview.pools"
|
||||||
:key="pool.id"
|
:key="pool.id"
|
||||||
@@ -138,7 +153,8 @@
|
|||||||
description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。"
|
description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-table :data="selectedMemberRules" class="link-table" stripe>
|
<div class="member-table-area">
|
||||||
|
<el-table :data="selectedMemberRules" class="link-table" height="100%" stripe>
|
||||||
<el-table-column label="推广成员" min-width="210" fixed="left">
|
<el-table-column label="推广成员" min-width="210" fixed="left">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="member-cell">
|
<div class="member-cell">
|
||||||
@@ -174,6 +190,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
|
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-else description="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接">
|
<el-empty v-else description="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接">
|
||||||
@@ -429,6 +446,150 @@
|
|||||||
<template #footer><span v-if="automationBusy" class="uploading-save-tip">正在处理标签或素材,请稍候</span><el-button :disabled="savingPool || automationBusy" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" :disabled="automationBusy" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
|
<template #footer><span v-if="automationBusy" class="uploading-save-tip">正在处理标签或素材,请稍候</span><el-button :disabled="savingPool || automationBusy" @click="poolDialogVisible = false">取消</el-button><el-button type="primary" :loading="savingPool" :disabled="automationBusy" @click="savePool">{{ poolForm.id ? '保存方案' : '保存并生成链接' }}</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="batchConfigDialogVisible"
|
||||||
|
title="批量修改分流方案"
|
||||||
|
width="1000px"
|
||||||
|
class="promotion-pool-dialog"
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="!savingBatchConfig && !batchConfigBusy"
|
||||||
|
:show-close="!savingBatchConfig && !batchConfigBusy"
|
||||||
|
>
|
||||||
|
<div ref="batchConfigScroll" class="pool-form-scroll batch-config-scroll">
|
||||||
|
<el-alert
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
:title="`将统一修改 ${batchConfigForm.pool_ids.length} 个分流方案`"
|
||||||
|
description="仅勾选的项目会覆盖到所选方案;未勾选项目保留各方案原值。表单初始值取第一个所选方案。"
|
||||||
|
/>
|
||||||
|
<el-alert v-if="batchConfigError" class="pool-form-error batch-config-error" :title="batchConfigError" type="error" show-icon :closable="false" role="alert" />
|
||||||
|
|
||||||
|
<div v-if="batchConfigPools.length" class="batch-pool-summary">
|
||||||
|
<strong>已选方案</strong>
|
||||||
|
<span v-for="pool in batchConfigPools.slice(0, 8)" :key="pool.id">{{ pool.name }}</span>
|
||||||
|
<small v-if="batchConfigPools.length > 8">另有 {{ batchConfigPools.length - 8 }} 个</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form class="batch-config-form" label-position="top" :disabled="savingBatchConfig">
|
||||||
|
<section class="batch-config-section">
|
||||||
|
<h3>基础设置</h3>
|
||||||
|
<div class="batch-field-grid">
|
||||||
|
<div class="batch-field" :class="{ 'is-disabled': !batchConfigApply.skip_verify }">
|
||||||
|
<el-checkbox v-model="batchConfigApply.skip_verify" :disabled="savingBatchConfig">批量修改验证方式</el-checkbox>
|
||||||
|
<el-form-item label="添加客户时跳过验证">
|
||||||
|
<el-switch v-model="batchConfigForm.skip_verify" :disabled="!batchConfigApply.skip_verify || savingBatchConfig" :active-value="1" :inactive-value="0" active-text="跳过验证" inactive-text="需要验证" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div class="batch-field" :class="{ 'is-disabled': !batchConfigApply.status }">
|
||||||
|
<el-checkbox v-model="batchConfigApply.status" :disabled="savingBatchConfig">批量修改运行状态</el-checkbox>
|
||||||
|
<el-form-item label="运行状态">
|
||||||
|
<el-switch v-model="batchConfigForm.status" :disabled="!batchConfigApply.status || savingBatchConfig" :active-value="1" :inactive-value="0" active-text="运行" inactive-text="停用" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="batch-field" :class="{ 'is-disabled': !batchConfigApply.fallback_url }">
|
||||||
|
<el-checkbox v-model="batchConfigApply.fallback_url" :disabled="savingBatchConfig">批量修改兜底获客助手链接</el-checkbox>
|
||||||
|
<el-form-item label="兜底获客助手链接">
|
||||||
|
<el-input v-model="batchConfigForm.fallback_url" :disabled="!batchConfigApply.fallback_url || savingBatchConfig" placeholder="留空表示清除;仅供旧版兼容跳转使用" />
|
||||||
|
<span class="form-tip">新生成的官方直链不经过本站跳转;此项仅兼容旧安装代码。</span>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="batch-config-section">
|
||||||
|
<h3>员工上下线</h3>
|
||||||
|
<p class="batch-section-tip">所选员工会在其已加入的所选方案中统一上线或下线;不属于某个方案的员工不会被加入该方案。为保证方案级原子提交,员工上下线需单独批量保存。</p>
|
||||||
|
<div class="batch-field batch-member-status-field" :class="{ 'is-disabled': !batchConfigApply.member_status }">
|
||||||
|
<el-checkbox v-model="batchConfigApply.member_status" :disabled="savingBatchConfig">批量修改员工上线状态</el-checkbox>
|
||||||
|
<div class="batch-member-status-grid">
|
||||||
|
<el-form-item label="目标状态">
|
||||||
|
<el-radio-group v-model="batchConfigForm.member_status" :disabled="!batchConfigApply.member_status || savingBatchConfig">
|
||||||
|
<el-radio-button :value="1">批量上线</el-radio-button>
|
||||||
|
<el-radio-button :value="0">批量下线</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="选择员工">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="batchConfigForm.member_admin_ids"
|
||||||
|
:data="batchMemberDepartmentTree"
|
||||||
|
:props="memberTreeProps"
|
||||||
|
:default-expanded-keys="batchMemberTreeDefaultExpandedKeys"
|
||||||
|
:filter-node-method="filterMemberTreeNode"
|
||||||
|
node-key="value"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
show-checkbox
|
||||||
|
check-on-click-node
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
:max-collapse-tags="3"
|
||||||
|
:multiple-limit="100"
|
||||||
|
:render-after-expand="false"
|
||||||
|
:disabled="!batchConfigApply.member_status || savingBatchConfig"
|
||||||
|
placeholder="按部门勾选,或搜索姓名 / 部门 / 企微 userid"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<span class="member-tree-option" :class="`is-${data.kind}`">
|
||||||
|
<span class="member-tree-option__label">{{ data.label }}</span>
|
||||||
|
<span v-if="data.kind === 'department'" class="member-tree-option__count">{{ data.member_count }} 人</span>
|
||||||
|
<span v-else class="member-tree-option__detail">{{ data.detail }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree-select>
|
||||||
|
<span class="form-tip">勾选部门可全选其下员工;已选 {{ batchConfigForm.member_admin_ids.length }} 名。</span>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
v-if="batchConfigApply.member_status && batchConfigForm.member_status === 0 && batchMemberOfflineBlockedPools.length"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
:title="`${batchMemberOfflineBlockedPools[0].name} 至少需要保留一名当前可用的上线员工`"
|
||||||
|
:description="batchMemberOfflineBlockedPools.length > 1 ? `另有 ${batchMemberOfflineBlockedPools.length - 1} 个方案也会没有可用上线员工,请调整选择。` : '请减少下线员工,或先为该方案上线一名当前可用的员工。'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="overview.automation_installed" class="batch-config-section automation-batch-section">
|
||||||
|
<h3>自动化设置</h3>
|
||||||
|
<p class="batch-section-tip">先选择要覆盖的分组。未选择的接待、客户或欢迎语设置不会随本次批量操作改变。</p>
|
||||||
|
<div class="batch-section-selectors">
|
||||||
|
<el-checkbox v-model="batchConfigApply.reception" :disabled="savingBatchConfig" border>批量修改接待设置</el-checkbox>
|
||||||
|
<el-checkbox v-model="batchConfigApply.customer" :disabled="savingBatchConfig" border>批量修改客户设置</el-checkbox>
|
||||||
|
<el-checkbox v-model="batchConfigApply.welcome" :disabled="savingBatchConfig" border>批量修改欢迎语设置</el-checkbox>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
v-if="batchConfigApply.reception && !batchSharedPrimaryMemberIds.length"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
title="所选方案没有共同主接待成员"
|
||||||
|
description="仍可统一改为全天接待;若使用按时段接待,时段成员必须同时属于全部所选方案。"
|
||||||
|
/>
|
||||||
|
<PromotionAutomationForm
|
||||||
|
v-model="batchConfigForm.automation_config"
|
||||||
|
:main-member-ids="batchSharedPrimaryMemberIds"
|
||||||
|
:backup-excluded-member-ids="batchPrimaryMemberUnion"
|
||||||
|
:members="overview.member_options"
|
||||||
|
:disabled="savingBatchConfig"
|
||||||
|
:disabled-sections="batchDisabledAutomationSections"
|
||||||
|
@busy="(busy) => batchConfigBusy = busy"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<el-alert v-else type="warning" show-icon :closable="false" title="自动化配置尚未安装" description="本次仍可批量修改验证方式、兜底链接、运行状态和员工上下线;安装自动化数据表后即可批量修改接待、客户和欢迎语设置。" />
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<span v-if="batchConfigBusy" class="uploading-save-tip">正在处理标签或素材,请稍候</span>
|
||||||
|
<el-button :disabled="savingBatchConfig || batchConfigBusy" @click="batchConfigDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="savingBatchConfig" :disabled="batchConfigBusy" @click="saveBatchConfig">批量保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="accessDialogVisible"
|
v-model="accessDialogVisible"
|
||||||
title="批量设置他人访问操作"
|
title="批量设置他人访问操作"
|
||||||
@@ -525,6 +686,7 @@ import {
|
|||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
wecomPromotionBatchSetOperators,
|
wecomPromotionBatchSetOperators,
|
||||||
|
wecomPromotionBatchUpdatePools,
|
||||||
wecomPromotionCheckApiPermission,
|
wecomPromotionCheckApiPermission,
|
||||||
wecomPromotionCustomerStats,
|
wecomPromotionCustomerStats,
|
||||||
wecomPromotionDeletePool,
|
wecomPromotionDeletePool,
|
||||||
@@ -539,8 +701,10 @@ import type { WecomPromotionCustomerChatStatus } 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'
|
||||||
import { cloneAutomationConfig, defaultAutomationConfig, isWebUrl, serializeAutomationConfig, validateAutomationConfig } from './components/promotion-automation'
|
import { cloneAutomationConfig, defaultAutomationConfig, isWebUrl, serializeAutomationConfig, validateAutomationConfig } from './components/promotion-automation'
|
||||||
|
import type { PromotionAutomationConfig } from './components/promotion-automation'
|
||||||
|
|
||||||
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
|
type TabName = 'links' | 'customer-stats' | 'configuration' | 'install'
|
||||||
|
type BatchAutomationSection = 'reception' | 'customer' | 'welcome'
|
||||||
|
|
||||||
interface PromotionDepartmentOption {
|
interface PromotionDepartmentOption {
|
||||||
id: number | string
|
id: number | string
|
||||||
@@ -557,6 +721,12 @@ interface PromotionMemberOption {
|
|||||||
dept_names: string[]
|
dept_names: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchMemberStatusOption extends PromotionMemberOption {
|
||||||
|
pool_count: number
|
||||||
|
online_count: number
|
||||||
|
offline_count: number
|
||||||
|
}
|
||||||
|
|
||||||
interface PromotionOperatorOption {
|
interface PromotionOperatorOption {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -606,11 +776,18 @@ const togglingMemberId = ref(0)
|
|||||||
const deletingPoolId = ref(0)
|
const deletingPoolId = ref(0)
|
||||||
const accessDialogVisible = ref(false)
|
const accessDialogVisible = ref(false)
|
||||||
const savingAccess = ref(false)
|
const savingAccess = ref(false)
|
||||||
|
const batchConfigDialogVisible = ref(false)
|
||||||
|
const savingBatchConfig = ref(false)
|
||||||
|
const batchConfigBusy = ref(false)
|
||||||
|
const batchConfigScroll = ref<HTMLElement>()
|
||||||
|
const batchConfigError = ref('')
|
||||||
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '', automation_config: defaultAutomationConfig() })
|
const poolForm = reactive({ id: 0, name: '', fallback_url: '', status: 1, member_admin_ids: [] as number[], skip_verify: 0, main_url: '', automation_config: defaultAutomationConfig() })
|
||||||
const poolFormScroll = ref<HTMLElement>()
|
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, member_status: false, reception: false, customer: false, welcome: false })
|
||||||
|
const batchConfigForm = reactive({ pool_ids: [] as number[], skip_verify: 0, fallback_url: '', status: 1, member_admin_ids: [] as number[], member_status: 1 as 0 | 1, automation_config: defaultAutomationConfig() })
|
||||||
const memberForm = reactive({ id: 0, name: '', userid: '', daily_limit: 0, status: 1, active_range: [] as string[], remark: '' })
|
const 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)
|
||||||
@@ -633,6 +810,97 @@ const selectedPool = computed(() => overview.pools.find((item: any) => Number(it
|
|||||||
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
||||||
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
||||||
const accessDialogPools = computed(() => overview.pools.filter((item: any) => accessForm.pool_ids.includes(Number(item.id))))
|
const accessDialogPools = computed(() => overview.pools.filter((item: any) => accessForm.pool_ids.includes(Number(item.id))))
|
||||||
|
const manageablePoolIds = computed(() => overview.pools
|
||||||
|
.filter((pool: any) => pool.can_manage_access)
|
||||||
|
.map((pool: any) => Number(pool.id)))
|
||||||
|
const selectedManageablePoolCount = computed(() => {
|
||||||
|
const selected = new Set(selectedPoolIds.value)
|
||||||
|
return manageablePoolIds.value.filter((id) => selected.has(id)).length
|
||||||
|
})
|
||||||
|
const allManageablePoolsSelected = computed(() => manageablePoolIds.value.length > 0
|
||||||
|
&& selectedManageablePoolCount.value === manageablePoolIds.value.length)
|
||||||
|
const someManageablePoolsSelected = computed(() => selectedManageablePoolCount.value > 0
|
||||||
|
&& selectedManageablePoolCount.value < manageablePoolIds.value.length)
|
||||||
|
const batchConfigPools = computed(() => overview.pools.filter((item: any) => batchConfigForm.pool_ids.includes(Number(item.id))))
|
||||||
|
const batchMemberStatusOptions = computed<BatchMemberStatusOption[]>(() => {
|
||||||
|
const memberOptionById = new Map(overview.member_options.map((member) => [Number(member.id), member]))
|
||||||
|
const grouped = new Map<number, BatchMemberStatusOption & { pool_ids: Set<number> }>()
|
||||||
|
batchConfigPools.value.forEach((pool: any) => {
|
||||||
|
const poolId = Number(pool.id)
|
||||||
|
const memberRules = Array.isArray(pool.member_rules) ? pool.member_rules : []
|
||||||
|
memberRules.forEach((rule: any) => {
|
||||||
|
const adminId = Number(rule.admin_id)
|
||||||
|
if (adminId <= 0) return
|
||||||
|
const member = memberOptionById.get(adminId)
|
||||||
|
const current = grouped.get(adminId) || {
|
||||||
|
id: adminId,
|
||||||
|
name: String(rule.name || member?.name || rule.userid || `员工 ${adminId}`),
|
||||||
|
userid: String(member?.userid || rule.userid || ''),
|
||||||
|
display_dept_id: Number(member?.display_dept_id || 0),
|
||||||
|
dept_ids: Array.isArray(member?.dept_ids) ? member.dept_ids.map(Number).filter((id) => id > 0) : [],
|
||||||
|
dept_names: Array.isArray(member?.dept_names)
|
||||||
|
? member.dept_names.map(String).filter(Boolean)
|
||||||
|
: (Array.isArray(rule.dept_names) ? rule.dept_names.map(String).filter(Boolean) : []),
|
||||||
|
pool_count: 0,
|
||||||
|
online_count: 0,
|
||||||
|
offline_count: 0,
|
||||||
|
pool_ids: new Set<number>()
|
||||||
|
}
|
||||||
|
if (!current.pool_ids.has(poolId)) {
|
||||||
|
current.pool_ids.add(poolId)
|
||||||
|
current.pool_count++
|
||||||
|
if (Number(rule.enabled) === 1) current.online_count++
|
||||||
|
else current.offline_count++
|
||||||
|
}
|
||||||
|
grouped.set(adminId, current)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return [...grouped.values()]
|
||||||
|
.map(({ pool_ids: _poolIds, ...member }) => member)
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||||
|
})
|
||||||
|
const batchMemberStatusById = computed(() => new Map(
|
||||||
|
batchMemberStatusOptions.value.map((member) => [member.id, member])
|
||||||
|
))
|
||||||
|
const batchMemberDepartmentTree = computed(() => buildMemberDepartmentTree(
|
||||||
|
overview.department_options,
|
||||||
|
batchMemberStatusOptions.value,
|
||||||
|
{
|
||||||
|
selectableDepartments: true,
|
||||||
|
memberDetail: (member) => {
|
||||||
|
const status = batchMemberStatusById.value.get(member.id)
|
||||||
|
return status
|
||||||
|
? `${status.pool_count} 个方案 · 已上线 ${status.online_count} / 已下线 ${status.offline_count}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
const batchMemberTreeDefaultExpandedKeys = computed(() => batchMemberDepartmentTree.value.map((node) => node.value))
|
||||||
|
const batchMemberOfflineBlockedPools = computed(() => {
|
||||||
|
if (!batchConfigApply.member_status || batchConfigForm.member_status !== 0) return []
|
||||||
|
const targetAdminIds = new Set(batchConfigForm.member_admin_ids.map(Number))
|
||||||
|
return batchConfigPools.value.filter((pool: any) => {
|
||||||
|
const rules = Array.isArray(pool.member_rules) ? pool.member_rules : []
|
||||||
|
const targetedEnabled = rules.some((rule: any) => Number(rule.enabled) === 1 && targetAdminIds.has(Number(rule.admin_id)))
|
||||||
|
const remainingAvailable = rules.some((rule: any) => Number(rule.enabled) === 1
|
||||||
|
&& (batchConfigApply.reception || rule.reception_available)
|
||||||
|
&& !targetAdminIds.has(Number(rule.admin_id)))
|
||||||
|
return targetedEnabled && !remainingAvailable
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const batchPrimaryMemberSets = computed(() => batchConfigPools.value.map((pool: any) => new Set<number>(
|
||||||
|
(Array.isArray(pool.member_admin_ids) ? pool.member_admin_ids : []).map(Number)
|
||||||
|
)))
|
||||||
|
const batchPrimaryMemberUnion = computed(() => [...new Set(batchPrimaryMemberSets.value.flatMap((ids) => [...ids]))])
|
||||||
|
const batchSharedPrimaryMemberIds = computed(() => {
|
||||||
|
const sets = batchPrimaryMemberSets.value
|
||||||
|
if (!sets.length) return []
|
||||||
|
return [...sets[0]].filter((id) => sets.slice(1).every((ids) => ids.has(id)))
|
||||||
|
})
|
||||||
|
const batchDisabledAutomationSections = computed<BatchAutomationSection[]>(() => (
|
||||||
|
(['reception', 'customer', 'welcome'] as BatchAutomationSection[])
|
||||||
|
.filter((section) => !batchConfigApply[section])
|
||||||
|
))
|
||||||
const memberTreeProps = { value: 'value', label: 'label', children: 'children', disabled: 'disabled' }
|
const memberTreeProps = { value: 'value', label: 'label', children: 'children', disabled: 'disabled' }
|
||||||
const memberDepartmentTree = computed(() => buildMemberDepartmentTree(overview.department_options, overview.member_options))
|
const memberDepartmentTree = computed(() => buildMemberDepartmentTree(overview.department_options, overview.member_options))
|
||||||
const memberTreeDefaultExpandedKeys = computed(() => memberDepartmentTree.value.map((node) => node.value))
|
const memberTreeDefaultExpandedKeys = computed(() => memberDepartmentTree.value.map((node) => node.value))
|
||||||
@@ -692,12 +960,176 @@ 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) {
|
||||||
|
const manageableIds = new Set(overview.pools
|
||||||
|
.filter((pool: any) => pool.can_manage_access)
|
||||||
|
.map((pool: any) => Number(pool.id)))
|
||||||
|
const ids = [...new Set(poolIds.map(Number).filter((id) => manageableIds.has(id)))]
|
||||||
|
if (!ids.length) return ElMessage.warning('请先选择可管理的分流方案')
|
||||||
|
if (ids.length > 100) return ElMessage.warning('单次最多设置 100 个分流方案')
|
||||||
|
const reference = overview.pools.find((pool: any) => Number(pool.id) === ids[0])
|
||||||
|
Object.assign(batchConfigApply, {
|
||||||
|
skip_verify: false,
|
||||||
|
fallback_url: false,
|
||||||
|
status: false,
|
||||||
|
member_status: false,
|
||||||
|
reception: false,
|
||||||
|
customer: false,
|
||||||
|
welcome: false
|
||||||
|
})
|
||||||
|
Object.assign(batchConfigForm, {
|
||||||
|
pool_ids: ids,
|
||||||
|
skip_verify: Number(reference?.skip_verify) === 1 ? 1 : 0,
|
||||||
|
fallback_url: String(reference?.fallback_url || ''),
|
||||||
|
status: Number(reference?.status) === 1 ? 1 : 0,
|
||||||
|
member_admin_ids: [],
|
||||||
|
member_status: 1,
|
||||||
|
automation_config: cloneAutomationConfig(reference?.automation_config)
|
||||||
|
})
|
||||||
|
batchConfigError.value = ''
|
||||||
|
batchConfigBusy.value = false
|
||||||
|
batchConfigDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyAutomationSection(
|
||||||
|
target: PromotionAutomationConfig,
|
||||||
|
source: PromotionAutomationConfig,
|
||||||
|
keys: Array<keyof PromotionAutomationConfig>
|
||||||
|
) {
|
||||||
|
const targetRecord = target as unknown as Record<string, unknown>
|
||||||
|
const sourceRecord = source as unknown as Record<string, unknown>
|
||||||
|
keys.forEach((key) => { targetRecord[key] = JSON.parse(JSON.stringify(sourceRecord[key])) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBatchConfigError(message: string) {
|
||||||
|
batchConfigError.value = message
|
||||||
|
batchConfigScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
ElMessage.warning(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBatchConfig() {
|
||||||
|
if (savingBatchConfig.value || batchConfigBusy.value) return
|
||||||
|
batchConfigError.value = ''
|
||||||
|
const hasAutomationChange = overview.automation_installed
|
||||||
|
&& (batchConfigApply.reception || batchConfigApply.customer || batchConfigApply.welcome)
|
||||||
|
const hasMemberStatusChange = batchConfigApply.member_status
|
||||||
|
const hasPoolConfigChange = batchConfigApply.skip_verify || batchConfigApply.fallback_url || batchConfigApply.status || hasAutomationChange
|
||||||
|
if (!batchConfigApply.skip_verify && !batchConfigApply.fallback_url && !batchConfigApply.status && !hasMemberStatusChange && !hasAutomationChange) {
|
||||||
|
return setBatchConfigError('请至少勾选一项需要批量修改的配置')
|
||||||
|
}
|
||||||
|
if (hasMemberStatusChange && hasPoolConfigChange) {
|
||||||
|
return setBatchConfigError('员工上下线需要单独批量保存,请取消其他方案配置修改')
|
||||||
|
}
|
||||||
|
if (hasMemberStatusChange && !batchConfigForm.member_admin_ids.length) {
|
||||||
|
return setBatchConfigError('请至少选择一名需要批量上线或下线的员工')
|
||||||
|
}
|
||||||
|
if (hasMemberStatusChange && batchConfigForm.member_admin_ids.length > 100) {
|
||||||
|
return setBatchConfigError('单次最多设置 100 名员工,请减少选择范围')
|
||||||
|
}
|
||||||
|
if (hasMemberStatusChange && batchConfigForm.member_status === 0 && batchMemberOfflineBlockedPools.value.length) {
|
||||||
|
return setBatchConfigError(`${batchMemberOfflineBlockedPools.value[0].name} 至少需要保留一名当前可用的上线员工`)
|
||||||
|
}
|
||||||
|
if (batchConfigApply.fallback_url && batchConfigForm.fallback_url.trim() && !isWebUrl(batchConfigForm.fallback_url.trim())) {
|
||||||
|
return setBatchConfigError('兜底获客助手链接必须为有效的 HTTP/HTTPS 地址')
|
||||||
|
}
|
||||||
|
|
||||||
|
const automation = cloneAutomationConfig(batchConfigForm.automation_config)
|
||||||
|
if (batchConfigApply.reception && automation.reception_mode !== 'scheduled') automation.reception_schedule = []
|
||||||
|
if (batchConfigApply.welcome && automation.welcome_mode !== 'channel') automation.welcome = { text: '', attachments: [] }
|
||||||
|
if (batchConfigApply.welcome && (automation.welcome_mode !== 'channel' || !automation.welcome_schedule_enabled)) automation.welcome_schedule = []
|
||||||
|
if (hasAutomationChange) {
|
||||||
|
const validationConfig = defaultAutomationConfig()
|
||||||
|
if (batchConfigApply.reception) copyAutomationSection(validationConfig, automation, ['reception_mode', 'reception_schedule', 'backup_member_admin_ids'])
|
||||||
|
if (batchConfigApply.customer) copyAutomationSection(validationConfig, automation, ['tags_enabled', 'tag_ids', 'remark_enabled', 'remark_template', 'description_enabled', 'description'])
|
||||||
|
if (batchConfigApply.welcome) copyAutomationSection(validationConfig, automation, ['welcome_mode', 'welcome', 'welcome_schedule_enabled', 'welcome_schedule'])
|
||||||
|
const backupConflict = batchConfigApply.reception
|
||||||
|
&& automation.backup_member_admin_ids.some((id) => batchPrimaryMemberUnion.value.includes(Number(id)))
|
||||||
|
const validationError = backupConflict
|
||||||
|
? '备用成员不能是任一所选方案的主接待成员'
|
||||||
|
: validateAutomationConfig(validationConfig, batchSharedPrimaryMemberIds.value)
|
||||||
|
if (validationError) return setBatchConfigError(validationError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: {
|
||||||
|
skip_verify?: 0 | 1
|
||||||
|
fallback_url?: string
|
||||||
|
status?: 0 | 1
|
||||||
|
automation_config?: Record<string, unknown>
|
||||||
|
member_status?: { member_admin_ids: number[]; status: 0 | 1 }
|
||||||
|
} = {}
|
||||||
|
if (batchConfigApply.skip_verify) changes.skip_verify = batchConfigForm.skip_verify === 1 ? 1 : 0
|
||||||
|
if (batchConfigApply.fallback_url) changes.fallback_url = batchConfigForm.fallback_url.trim()
|
||||||
|
if (batchConfigApply.status) changes.status = batchConfigForm.status === 1 ? 1 : 0
|
||||||
|
if (hasMemberStatusChange) {
|
||||||
|
changes.member_status = {
|
||||||
|
member_admin_ids: [...batchConfigForm.member_admin_ids],
|
||||||
|
status: batchConfigForm.member_status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasAutomationChange) {
|
||||||
|
const serialized = serializeAutomationConfig(automation) as unknown as Record<string, unknown>
|
||||||
|
const automationPatch: Record<string, unknown> = {}
|
||||||
|
const assignKeys = (keys: string[]) => keys.forEach((key) => { automationPatch[key] = serialized[key] })
|
||||||
|
if (batchConfigApply.reception) assignKeys(['reception_mode', 'reception_schedule', 'backup_member_admin_ids'])
|
||||||
|
if (batchConfigApply.customer) assignKeys(['tags_enabled', 'tag_ids', 'remark_enabled', 'remark_template', 'description_enabled', 'description'])
|
||||||
|
if (batchConfigApply.welcome) assignKeys(['welcome_mode', 'welcome', 'welcome_schedule_enabled', 'welcome_schedule'])
|
||||||
|
changes.automation_config = automationPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
savingBatchConfig.value = true
|
||||||
|
try {
|
||||||
|
const result = await wecomPromotionBatchUpdatePools({
|
||||||
|
pool_ids: [...batchConfigForm.pool_ids],
|
||||||
|
changes
|
||||||
|
})
|
||||||
|
if (result.updated === 0) {
|
||||||
|
const detail = result.results
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`)
|
||||||
|
.join(';')
|
||||||
|
return setBatchConfigError(detail || '所选方案均未能保存,请检查配置后重试')
|
||||||
|
}
|
||||||
|
batchConfigDialogVisible.value = false
|
||||||
|
selectedPoolIds.value = []
|
||||||
|
await loadOverview()
|
||||||
|
const memberStatusText = batchConfigForm.member_status === 1 ? '上线' : '下线'
|
||||||
|
const memberResultText = hasMemberStatusChange
|
||||||
|
? (result.member_updated > 0
|
||||||
|
? `,${result.member_updated} 条员工规则已${memberStatusText}`
|
||||||
|
: `,所选员工均已处于${memberStatusText}状态`)
|
||||||
|
: ''
|
||||||
|
if (result.failed > 0) {
|
||||||
|
const detail = result.results
|
||||||
|
.filter((item) => !item.success)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`)
|
||||||
|
.join(';')
|
||||||
|
ElMessage.warning(`已更新 ${result.updated} 个方案,${result.failed} 个失败${memberResultText}${detail ? `。${detail}` : ''}`)
|
||||||
|
} else if (result.sync_error_count > 0) {
|
||||||
|
ElMessage.warning(`已更新 ${result.updated} 个方案${memberResultText},其中 ${result.sync_error_count} 个企微范围将在后台自动重试同步`)
|
||||||
|
} else if (result.sync_queued_count > 0) {
|
||||||
|
ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText},${result.sync_queued_count} 个企微链接配置将在后台同步`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(`已批量更新 ${result.updated} 个分流方案${memberResultText}`)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setBatchConfigError(error?.message || '批量修改分流方案失败')
|
||||||
|
} finally {
|
||||||
|
savingBatchConfig.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openAccessDialog(poolIds: number[] = selectedPoolIds.value) {
|
function openAccessDialog(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)
|
||||||
.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: [],
|
||||||
@@ -1038,7 +1470,11 @@ function todayCount(row: any) {
|
|||||||
|
|
||||||
function buildMemberDepartmentTree(
|
function buildMemberDepartmentTree(
|
||||||
departmentOptions: PromotionDepartmentOption[],
|
departmentOptions: PromotionDepartmentOption[],
|
||||||
memberOptions: PromotionMemberOption[]
|
memberOptions: PromotionMemberOption[],
|
||||||
|
options: {
|
||||||
|
selectableDepartments?: boolean
|
||||||
|
memberDetail?: (member: PromotionMemberOption) => string
|
||||||
|
} = {}
|
||||||
): PromotionMemberTreeNode[] {
|
): PromotionMemberTreeNode[] {
|
||||||
const availableDeptIds = new Set<number>()
|
const availableDeptIds = new Set<number>()
|
||||||
const collectDeptIds = (departments: PromotionDepartmentOption[]) => {
|
const collectDeptIds = (departments: PromotionDepartmentOption[]) => {
|
||||||
@@ -1083,7 +1519,8 @@ function buildMemberDepartmentTree(
|
|||||||
const placedMemberIds = new Set<number>()
|
const placedMemberIds = new Set<number>()
|
||||||
const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => {
|
const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => {
|
||||||
const departments = member.dept_names.length ? member.dept_names.join(' / ') : '未分部门'
|
const departments = member.dept_names.length ? member.dept_names.join(' / ') : '未分部门'
|
||||||
const detail = member.userid ? `${departments} · ${member.userid}` : departments
|
const detail = options.memberDetail?.(member)
|
||||||
|
|| (member.userid ? `${departments} · ${member.userid}` : departments)
|
||||||
return {
|
return {
|
||||||
value: member.id,
|
value: member.id,
|
||||||
label: member.name,
|
label: member.name,
|
||||||
@@ -1118,7 +1555,7 @@ function buildMemberDepartmentTree(
|
|||||||
value: `dept:${departmentId}`,
|
value: `dept:${departmentId}`,
|
||||||
label: departmentName,
|
label: departmentName,
|
||||||
kind: 'department',
|
kind: 'department',
|
||||||
disabled: true,
|
disabled: !options.selectableDepartments,
|
||||||
member_count: memberCount,
|
member_count: memberCount,
|
||||||
search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
||||||
children
|
children
|
||||||
@@ -1135,7 +1572,7 @@ function buildMemberDepartmentTree(
|
|||||||
value: 'dept:unassigned',
|
value: 'dept:unassigned',
|
||||||
label: '未分部门',
|
label: '未分部门',
|
||||||
kind: 'department',
|
kind: 'department',
|
||||||
disabled: true,
|
disabled: !options.selectableDepartments,
|
||||||
member_count: children.length,
|
member_count: children.length,
|
||||||
search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
||||||
children
|
children
|
||||||
@@ -1319,18 +1756,19 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
.section-heading-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
.section-heading-actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
.section-heading-actions .el-button + .el-button { margin-left: 0; }
|
.section-heading-actions .el-button + .el-button { margin-left: 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); 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 { padding: 8px; border-right: 1px solid var(--line); 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; }
|
||||||
.pool-item:hover { background: #fff; }.pool-select-row.active .pool-item { border-color: #bfe1dc; background: #fff; box-shadow: 0 5px 16px rgba(25,69,70,.05); }
|
.pool-item:hover { background: #fff; }.pool-select-row.active .pool-item { border-color: #bfe1dc; background: #fff; box-shadow: 0 5px 16px rgba(25,69,70,.05); }
|
||||||
.pool-item strong, .pool-item small { display: block; }.pool-item strong { overflow: hidden; color: #253348; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.pool-item small { margin-top: 4px; color: #8c98a7; font-size: 10px; }
|
.pool-item strong, .pool-item small { display: block; }.pool-item strong { overflow: hidden; color: #253348; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.pool-item small { margin-top: 4px; color: #8c98a7; font-size: 10px; }
|
||||||
.pool-status { width: 8px; height: 8px; border-radius: 50%; }.pool-status.online { background: #18a277; }.pool-status.offline { background: #aab3bf; }.pool-item > .el-icon { color: #9ca7b4; }
|
.pool-status { width: 8px; height: 8px; border-radius: 50%; }.pool-status.online { background: #18a277; }.pool-status.offline { background: #aab3bf; }.pool-item > .el-icon { color: #9ca7b4; }
|
||||||
.pool-main { min-width: 0; }.pool-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 78px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
|
.pool-main { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }.pool-toolbar { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 16px; min-height: 78px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
|
||||||
.pool-title-row { gap: 8px; }.pool-title-row h3 { font-size: 15px; }.pool-toolbar p { margin-top: 6px; color: #8b97a6; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }.toolbar-actions { flex-wrap: wrap; justify-content: flex-end; gap: 7px; }.toolbar-actions .el-button + .el-button { margin-left: 0; }
|
.pool-title-row { gap: 8px; }.pool-title-row h3 { font-size: 15px; }.pool-toolbar p { margin-top: 6px; color: #8b97a6; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }.toolbar-actions { flex-wrap: wrap; justify-content: flex-end; gap: 7px; }.toolbar-actions .el-button + .el-button { margin-left: 0; }
|
||||||
.status-tag, .owner-tag, .availability { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 11px; font-size: 10px; }.status-tag.is-online, .availability.is-ok { color: #16895f; background: #eaf8ef; }.status-tag.is-offline, .availability.is-muted { color: #788696; background: #eef2f5; }.owner-tag { color: #50728c; background: #edf4f8; }.availability.is-error { color: #d94b4b; background: #ffeded; }.availability.is-waiting { color: #b86c1f; background: #fff1dd; }
|
.status-tag, .owner-tag, .availability { display: inline-flex; align-items: center; min-height: 22px; padding: 0 8px; border-radius: 11px; font-size: 10px; }.status-tag.is-online, .availability.is-ok { color: #16895f; background: #eaf8ef; }.status-tag.is-offline, .availability.is-muted { color: #788696; background: #eef2f5; }.owner-tag { color: #50728c; background: #edf4f8; }.availability.is-error { color: #d94b4b; background: #ffeded; }.availability.is-waiting { color: #b86c1f; background: #fff1dd; }
|
||||||
.link-table, .account-table { --el-table-header-bg-color: #f7f9fb; }.link-table :deep(th.el-table__cell), .account-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.member-cell strong, .member-cell small { display: block; }.member-cell small { margin-top: 3px; color: #8d98a7; font-size: 10px; }.muted { color: #9aa4b0; }
|
.legacy-sync-alert { flex: 0 0 auto; }.member-table-area { min-height: 0; flex: 1 1 auto; overflow: hidden; }.link-table, .account-table { --el-table-header-bg-color: #f7f9fb; }.link-table :deep(th.el-table__cell), .account-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.member-cell strong, .member-cell small { display: block; }.member-cell small { margin-top: 3px; color: #8d98a7; font-size: 10px; }.muted { color: #9aa4b0; }
|
||||||
.remote-id { display: block; overflow: hidden; color: #68778b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
.remote-id { display: block; overflow: hidden; color: #68778b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.link-id-cell { display: grid; gap: 4px; min-width: 0; }
|
.link-id-cell { display: grid; gap: 4px; min-width: 0; }
|
||||||
.wecom-url { display: block; overflow: hidden; color: #148f83; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.4; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
|
.wecom-url { display: block; overflow: hidden; color: #148f83; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; line-height: 1.4; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -1368,9 +1806,16 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
.access-pool-preview p { margin-top: 7px; }
|
.access-pool-preview p { margin-top: 7px; }
|
||||||
.pool-form-scroll { max-height: 70vh; overflow-y: auto; overflow-x: hidden; padding: 0 10px 6px 2px; }
|
.pool-form-scroll { max-height: 70vh; overflow-y: auto; overflow-x: hidden; padding: 0 10px 6px 2px; }
|
||||||
.pool-form-error { margin-bottom: 18px; }
|
.pool-form-error { margin-bottom: 18px; }
|
||||||
|
.batch-config-error { margin-top: 14px; }
|
||||||
|
.batch-pool-summary { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-top: 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: #f8fafb; }
|
||||||
|
.batch-pool-summary strong { margin-right: 3px; font-size: 12px; }.batch-pool-summary span { padding: 4px 8px; border-radius: 12px; color: #426078; background: #eaf1f5; font-size: 10px; }.batch-pool-summary small { color: #8491a2; font-size: 10px; }
|
||||||
|
.batch-config-form { margin-top: 16px; }.batch-config-section { margin-bottom: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 9px; background: #fff; }.batch-config-section > h3 { margin: 0 0 14px; color: #303b4d; font-size: 14px; }
|
||||||
|
.batch-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }.batch-field { padding: 12px; border: 1px solid #dfe6ec; border-radius: 8px; transition: opacity .2s ease, background .2s ease; }.batch-field.is-disabled { opacity: .58; background: #f7f8fa; }.batch-field :deep(.el-form-item) { margin: 10px 0 0; }
|
||||||
|
.batch-member-status-field { padding: 14px; }.batch-member-status-grid { display: grid; grid-template-columns: minmax(190px, .7fr) minmax(0, 1.6fr); gap: 14px; }
|
||||||
|
.batch-section-tip { margin: -6px 0 12px; color: #8491a2; font-size: 11px; line-height: 1.6; }.batch-section-selectors { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.batch-section-selectors :deep(.el-checkbox) { margin-right: 0; }.automation-batch-section :deep(.automation-note) { margin-top: 14px; }
|
||||||
.uploading-save-tip { color: #b88230; font-size: 12px; margin-right: 16px; }
|
.uploading-save-tip { color: #b88230; font-size: 12px; margin-right: 16px; }
|
||||||
.sync-retry-tip { color: #b88230; font-size: 10px; line-height: 1.6; margin-top: 4px; }
|
.sync-retry-tip { color: #b88230; font-size: 10px; line-height: 1.6; margin-top: 4px; }
|
||||||
:global(.promotion-pool-dialog) { max-width: calc(100vw - 32px); margin-top: 5vh; }
|
:global(.promotion-pool-dialog) { max-width: calc(100vw - 32px); margin-top: 5vh; }
|
||||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
|
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
|
||||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.section-heading-actions { width: 100%; justify-content: flex-start; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid { grid-template-columns: 1fr; }.tab-nav { overflow-x: auto; }.tab-nav button { min-width: 112px; }.tab-content { padding: 14px; }.pool-layout { grid-template-columns: 1fr; }.pool-sidebar { display: flex; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }.pool-select-row { min-width: 220px; }.pool-item { min-width: 190px; }.callback-list > div { grid-template-columns: 1fr 48px; padding: 8px 0; }.callback-list dt { grid-column: 1 / -1; }.install-pool-select { width: 100%; }.customer-heading-actions { width: 100%; justify-content: flex-end; }.customer-filter-bar :deep(.el-form-item) { width: 100%; margin-right: 0; }.customer-filter-bar :deep(.el-form-item__content), .customer-filter-bar .el-select { width: 100%; }.customer-filter-bar .filter-actions :deep(.el-form-item__content) { justify-content: flex-end; }.customer-pagination { align-items: flex-start; flex-direction: column; }.customer-pagination :deep(.el-pagination) { max-width: 100%; flex-wrap: wrap; justify-content: flex-start; }.access-pool-preview > div { grid-template-columns: 1fr; gap: 3px; } }
|
@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>
|
||||||
|
|||||||
@@ -677,6 +677,32 @@
|
|||||||
<el-option label="驼奶费用" :value="8" />
|
<el-option label="驼奶费用" :value="8" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-if="canEditOrderTime && isEditPaymentTimeEditable"
|
||||||
|
label="支付时间"
|
||||||
|
prop="payment_time"
|
||||||
|
>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="editOrderForm.payment_time"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="请选择支付时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:clearable="false"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="canEditOrderTime" label="创建时间" prop="create_time">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="editOrderForm.create_time"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="请选择创建时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:clearable="false"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="editOrderDialogVisible = false">取消</el-button>
|
<el-button @click="editOrderDialogVisible = false">取消</el-button>
|
||||||
@@ -782,6 +808,7 @@
|
|||||||
<script setup lang="ts" name="orderList">
|
<script setup lang="ts" name="orderList">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
import {
|
import {
|
||||||
orderLists,
|
orderLists,
|
||||||
orderDetail,
|
orderDetail,
|
||||||
@@ -1015,9 +1042,33 @@ const editOrderFormRef = ref()
|
|||||||
const editOrderLoading = ref(false)
|
const editOrderLoading = ref(false)
|
||||||
const editPatientLoading = ref(false)
|
const editPatientLoading = ref(false)
|
||||||
const editPatientList = ref<any[]>([])
|
const editPatientList = ref<any[]>([])
|
||||||
const editOrderForm = ref<{ id: number; patient_id: number | null; order_type: number } | null>(null)
|
type EditOrderForm = {
|
||||||
|
id: number
|
||||||
|
patient_id: number | null
|
||||||
|
order_type: number
|
||||||
|
status: number
|
||||||
|
payment_time: string
|
||||||
|
create_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const editOrderForm = ref<EditOrderForm | null>(null)
|
||||||
|
const canEditOrderTime = computed(() => hasPermission(['order.order/editTime']))
|
||||||
|
const isEditPaymentTimeEditable = computed(() => [2, 4].includes(editOrderForm.value?.status ?? 0))
|
||||||
const editOrderRules = {
|
const editOrderRules = {
|
||||||
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }]
|
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
|
||||||
|
payment_time: [
|
||||||
|
{
|
||||||
|
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||||
|
if (isEditPaymentTimeEditable.value && !value) {
|
||||||
|
callback(new Error('请选择支付时间'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索患者
|
// 搜索患者
|
||||||
@@ -1289,11 +1340,37 @@ const getCreateTypeText = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 编辑订单
|
// 编辑订单
|
||||||
|
const normalizeOrderDateTime = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined || value === '' || value === '-') return ''
|
||||||
|
|
||||||
|
const raw = String(value).trim()
|
||||||
|
const canonicalDateTime = raw.match(/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/)?.[0]
|
||||||
|
if (canonicalDateTime) return canonicalDateTime.replace('T', ' ')
|
||||||
|
|
||||||
|
const numericTimestamp = /^\d{10,13}$/.test(raw) ? Number(raw) : 0
|
||||||
|
const parsed = new Date(
|
||||||
|
numericTimestamp
|
||||||
|
? numericTimestamp < 1_000_000_000_000
|
||||||
|
? numericTimestamp * 1000
|
||||||
|
: numericTimestamp
|
||||||
|
: raw
|
||||||
|
)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return ''
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(
|
||||||
|
parsed.getHours()
|
||||||
|
)}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
const handleEditOrder = (row: any) => {
|
const handleEditOrder = (row: any) => {
|
||||||
editOrderForm.value = {
|
editOrderForm.value = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
patient_id: row.patient_id || null,
|
patient_id: row.patient_id || null,
|
||||||
order_type: row.order_type
|
order_type: row.order_type,
|
||||||
|
status: Number(row.status),
|
||||||
|
payment_time: [2, 4].includes(Number(row.status)) ? normalizeOrderDateTime(row.payment_time) : '',
|
||||||
|
create_time: normalizeOrderDateTime(row.create_time)
|
||||||
}
|
}
|
||||||
editPatientList.value = row.patient ? [row.patient] : []
|
editPatientList.value = row.patient ? [row.patient] : []
|
||||||
editOrderDialogVisible.value = true
|
editOrderDialogVisible.value = true
|
||||||
@@ -1320,11 +1397,18 @@ const submitEditOrder = async () => {
|
|||||||
try {
|
try {
|
||||||
await editOrderFormRef.value?.validate()
|
await editOrderFormRef.value?.validate()
|
||||||
editOrderLoading.value = true
|
editOrderLoading.value = true
|
||||||
await orderEdit({
|
const payload: Record<string, unknown> = {
|
||||||
id: editOrderForm.value.id,
|
id: editOrderForm.value.id,
|
||||||
patient_id: editOrderForm.value.patient_id ?? 0,
|
patient_id: editOrderForm.value.patient_id ?? 0,
|
||||||
order_type: editOrderForm.value.order_type
|
order_type: editOrderForm.value.order_type
|
||||||
})
|
}
|
||||||
|
if (canEditOrderTime.value) {
|
||||||
|
payload.create_time = editOrderForm.value.create_time
|
||||||
|
if (isEditPaymentTimeEditable.value) {
|
||||||
|
payload.payment_time = editOrderForm.value.payment_time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await orderEdit(payload)
|
||||||
feedback.msgSuccess('保存成功')
|
feedback.msgSuccess('保存成功')
|
||||||
editOrderDialogVisible.value = false
|
editOrderDialogVisible.value = false
|
||||||
getLists()
|
getLists()
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<code>一键打包</code>
|
<code>一键打包</code>
|
||||||
产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用
|
产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用
|
||||||
Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。
|
Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。
|
||||||
安装包通常超过 200MB,优先传到对象存储 / CDN 后粘贴地址。
|
安装包通常超过 200MB,本页上传按钮会直传到已配置的腾讯云 COS;也可以
|
||||||
|
自行上传到其他对象存储 / CDN 后粘贴地址。
|
||||||
</div>
|
</div>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<div class="text-xl font-medium mb-[20px]">升级策略</div>
|
<div class="text-xl font-medium mb-[20px]">升级策略</div>
|
||||||
@@ -125,7 +126,9 @@
|
|||||||
<el-form-item label="上传安装包">
|
<el-form-item label="上传安装包">
|
||||||
<div>
|
<div>
|
||||||
<upload
|
<upload
|
||||||
type="file"
|
v-perms="['setting.desktop_workstation/setConfig']"
|
||||||
|
type="desktop_package"
|
||||||
|
direct
|
||||||
:limit="1"
|
:limit="1"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:show-progress="true"
|
:show-progress="true"
|
||||||
@@ -136,8 +139,8 @@
|
|||||||
<el-button type="primary" plain>选择安装包并上传</el-button>
|
<el-button type="primary" plain>选择安装包并上传</el-button>
|
||||||
</upload>
|
</upload>
|
||||||
<div class="form-tips">
|
<div class="form-tips">
|
||||||
仅建议上传较小的包。大文件请先传到对象存储,再把地址和 SHA-256
|
安装包将分片直传腾讯云 COS,不经过业务服务器(支持 EXE / ZIP,最大
|
||||||
填到上方。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
|
2GB)。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
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,9 +3,9 @@
|
|||||||
__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.2.0"
|
__version__ = "1.4.1"
|
||||||
|
|
||||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||||
DEBUG_MODE = True
|
DEBUG_MODE = False
|
||||||
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
ONLINE_API_BASE_URL = "https://admin.zhenyangtang.com.cn"
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ class DemoVideoDialog(QDialog):
|
|||||||
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
|
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
|
||||||
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
|
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
|
||||||
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
|
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
|
||||||
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
|
"QPushButton#Hangup{color:#FFFFFF;background:#C23D4E;border-color:#C23D4E;}"
|
||||||
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
|
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ class DemoVideoDialog(QDialog):
|
|||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch(1)
|
header.addStretch(1)
|
||||||
demo = QLabel("● 演示模式 · 未连接腾讯云")
|
demo = QLabel("● 演示模式 · 未连接腾讯云")
|
||||||
demo.setStyleSheet("color:#7886AA;font-size:12px;")
|
demo.setStyleSheet("color:#707584;font-size:12px;")
|
||||||
header.addWidget(demo)
|
header.addWidget(demo)
|
||||||
self.duration_label = QLabel("00:00")
|
self.duration_label = QLabel("00:00")
|
||||||
self.duration_label.setStyleSheet("font-weight:700;")
|
self.duration_label.setStyleSheet("font-weight:700;")
|
||||||
|
|||||||
@@ -55,26 +55,25 @@ from .widgets import (
|
|||||||
APPOINTMENT_DRAWER_QSS = r"""
|
APPOINTMENT_DRAWER_QSS = r"""
|
||||||
QDialog#AppointmentDrawerOverlay {
|
QDialog#AppointmentDrawerOverlay {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
font-size: 14px;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border-left: 1px solid #E6EAF5;
|
border-left: 1px solid #EDEDEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #E6EAF5;
|
border-bottom: 1px solid #EDEDEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
|
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
||||||
@@ -86,14 +85,14 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: #7886AA;
|
color: #606163;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
|
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
|
||||||
color: #4451E2;
|
color: #1A1C1F;
|
||||||
background-color: #F0F2FF;
|
background-color: #F0F0F0;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
|
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
|
||||||
@@ -109,13 +108,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
|
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
|
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
|
||||||
color: #7886AA;
|
color: #606163; font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox,
|
QDialog#AppointmentDrawerOverlay QComboBox,
|
||||||
@@ -123,28 +122,28 @@ QDialog#AppointmentDrawerOverlay QLineEdit,
|
|||||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: 0 11px;
|
padding: 0 11px;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
selection-background-color: #5761F4;
|
selection-background-color: #EEF1FA;
|
||||||
selection-color: #FFFFFF;
|
selection-color: #1A1C1F; font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||||||
padding: 7px 11px;
|
padding: 7px 11px; font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox:hover,
|
QDialog#AppointmentDrawerOverlay QComboBox:hover,
|
||||||
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
|
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
|
||||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
|
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
|
||||||
border-color: #5761F4;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox:focus,
|
QDialog#AppointmentDrawerOverlay QComboBox:focus,
|
||||||
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
|
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
|
||||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
|
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
|
||||||
border: 2px solid #8D9BFF;
|
border: 2px solid #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
||||||
@@ -154,81 +153,81 @@ QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
|||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
|
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
selection-background-color: #5761F4;
|
selection-background-color: #EEF1FA;
|
||||||
selection-color: #FFFFFF;
|
selection-color: #1A1C1F;
|
||||||
outline: 0;
|
outline: 0; font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton {
|
QDialog#AppointmentDrawerOverlay QRadioButton {
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
spacing: 8px;
|
spacing: 8px;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
|
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
|
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
|
||||||
border-color: #5761F4;
|
border-color: #4156C4;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
|
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border: 5px solid #5761F4;
|
border: 5px solid #4F63D9;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
|
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
|
||||||
color: #4451E2;
|
color: #4F63D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
max-height: 38px;
|
max-height: 38px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
border-color: #5761F4;
|
border-color: #4156C4;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
background-color: #5761F4;
|
background-color: #4F63D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
|
||||||
background-color: #F7F9FE;
|
background-color: #F7F7F7;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
|
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
||||||
@@ -238,87 +237,83 @@ QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: #4451E2;
|
color: #4F63D9;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
|
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
|
||||||
min-width: 110px;
|
min-width: 110px;
|
||||||
min-height: 70px;
|
min-height: 70px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: 2px solid #E6EAF5;
|
border: 2px solid #EDEDEE;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #F4F4F5;
|
background-color: #F7F7F7;
|
||||||
color: #7886AA;
|
color: #606163;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
|
||||||
color: #17A77D;
|
color: #287B65;
|
||||||
background-color: #EAF9F3;
|
background-color: #EAF9F3;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
border-color: #5761F4;
|
border-color: #4156C4;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
background: qlineargradient(
|
background: #4F63D9;
|
||||||
x1:0, y1:0, x2:1, y2:1,
|
|
||||||
stop:0 #5761F4,
|
|
||||||
stop:1 #7769F7
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotTime,
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime[slotSelected="true"],
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||||||
background-color: rgba(255, 255, 255, 46);
|
background-color: rgba(255, 255, 255, 46);
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
|
||||||
color: #A4ADC3;
|
color: #8E8F90;
|
||||||
border-color: #E6EAF5;
|
border-color: #EDEDEE;
|
||||||
background-color: #F0F2F8;
|
background-color: #F7F7F7;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotTime,
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime:disabled,
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||||||
color: #A4ADC3;
|
color: #8E8F90;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||||||
background-color: #F0F2F8;
|
background-color: #F7F7F7;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
||||||
@@ -326,13 +321,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
|
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
|
||||||
color: #7886AA;
|
color: #606163;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
|
||||||
background-color: #F0F4FF;
|
background-color: #EEF1FA;
|
||||||
border: 1px solid #DDE5FF;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,65 +350,65 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
|
||||||
color: #4D69ED;
|
color: #4F63D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
|
||||||
color: #D38625;
|
color: #A9691D;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
|
||||||
color: #F15B67;
|
color: #BE4B58;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
|
||||||
color: #17A77D;
|
color: #287B65;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-top: 1px solid #E6EAF5;
|
border-top: 1px solid #EDEDEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
border-color: #5761F4;
|
border-color: #4156C4;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5761F4, stop:1 #7769F7);
|
background: #4F63D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #4C57E9;
|
border-color: #4156C4;
|
||||||
background-color: #4C57E9;
|
background-color: #4156C4;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #E6EAF5;
|
border-color: #EDEDEE;
|
||||||
background-color: #A4ADC3;
|
background-color: #8E8F90;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
||||||
@@ -422,7 +417,7 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
|
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
|
||||||
color: #7886AA;
|
color: #606163;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,11 +431,11 @@ QDialog#AppointmentDrawerOverlay QScrollBar:vertical {
|
|||||||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
|
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: #E6EAF5;
|
background-color: #EDEDEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
|
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
|
||||||
background-color: #8D9BFF;
|
background-color: #E4E4E5;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
|
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
|
||||||
@@ -722,15 +717,15 @@ class _EmptyIllustration(QWidget):
|
|||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
painter.setBrush(QColor("#EEF2F8"))
|
painter.setBrush(QColor("#F7F7F7"))
|
||||||
painter.drawEllipse(QRect(9, 48, 62, 8))
|
painter.drawEllipse(QRect(9, 48, 62, 8))
|
||||||
|
|
||||||
painter.setPen(QPen(QColor("#D8DEEA"), 1))
|
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||||||
painter.setBrush(QColor("#FFFFFF"))
|
painter.setBrush(QColor("#FFFFFF"))
|
||||||
painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4)
|
painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4)
|
||||||
painter.setBrush(QColor("#E9EDFF"))
|
painter.setBrush(QColor("#F0F0F0"))
|
||||||
painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4)
|
painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4)
|
||||||
painter.setPen(QPen(QColor("#667085"), 2))
|
painter.setPen(QPen(QColor("#606163"), 2))
|
||||||
painter.drawLine(30, 35, 50, 35)
|
painter.drawLine(30, 35, 50, 35)
|
||||||
painter.drawLine(34, 41, 46, 41)
|
painter.drawLine(34, 41, 46, 41)
|
||||||
painter.end()
|
painter.end()
|
||||||
@@ -751,7 +746,7 @@ class _HoverLiftButton(QPushButton):
|
|||||||
shadow = QGraphicsDropShadowEffect(self)
|
shadow = QGraphicsDropShadowEffect(self)
|
||||||
shadow.setBlurRadius(12)
|
shadow.setBlurRadius(12)
|
||||||
shadow.setOffset(0, 4)
|
shadow.setOffset(0, 4)
|
||||||
shadow.setColor(QColor(102, 117, 245, 72))
|
shadow.setColor(QColor(26, 28, 31, 72))
|
||||||
self.setGraphicsEffect(shadow)
|
self.setGraphicsEffect(shadow)
|
||||||
self._lifted = True
|
self._lifted = True
|
||||||
super().enterEvent(event)
|
super().enterEvent(event)
|
||||||
@@ -784,6 +779,15 @@ class _SlotCard(_HoverLiftButton):
|
|||||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||||
layout.addWidget(self.status_label)
|
layout.addWidget(self.status_label)
|
||||||
|
self.toggled.connect(self._sync_label_selection)
|
||||||
|
|
||||||
|
def _sync_label_selection(self, checked: bool) -> None:
|
||||||
|
# Qt does not reliably resolve ancestor pseudo states for child labels.
|
||||||
|
for label in (self.time_label, self.status_label):
|
||||||
|
label.setProperty("slotSelected", checked)
|
||||||
|
label.style().unpolish(label)
|
||||||
|
label.style().polish(label)
|
||||||
|
label.update()
|
||||||
|
|
||||||
|
|
||||||
class AppointmentDrawer(QDialog):
|
class AppointmentDrawer(QDialog):
|
||||||
@@ -1705,7 +1709,7 @@ class AppointmentDrawer(QDialog):
|
|||||||
|
|
||||||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.fillRect(self.rect(), QColor(8, 11, 20, 196))
|
painter.fillRect(self.rect(), QColor(26, 28, 31, 196))
|
||||||
painter.end()
|
painter.end()
|
||||||
super().paintEvent(event)
|
super().paintEvent(event)
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,13 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..infinite_list import InfiniteList
|
||||||
from ..theme import mark_business_dialog
|
from ..theme import mark_business_dialog
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
BusyOverlay,
|
BusyOverlay,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
MessageBanner,
|
MessageBanner,
|
||||||
OverlayHost,
|
OverlayHost,
|
||||||
Pager,
|
|
||||||
SortableTable,
|
SortableTable,
|
||||||
TableColumn,
|
TableColumn,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -33,7 +33,6 @@ from ..widgets import (
|
|||||||
get_value,
|
get_value,
|
||||||
invoke,
|
invoke,
|
||||||
page_items,
|
page_items,
|
||||||
page_total,
|
|
||||||
run_async,
|
run_async,
|
||||||
)
|
)
|
||||||
from .ai_consult import can_open_ai_consult, present_ai_consult
|
from .ai_consult import can_open_ai_consult, present_ai_consult
|
||||||
@@ -220,8 +219,8 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
self.body.busy_overlay = self.busy_overlay
|
self.body.busy_overlay = self.busy_overlay
|
||||||
root.addWidget(self.body, 1)
|
root.addWidget(self.body, 1)
|
||||||
|
|
||||||
self.pager = Pager(self.PAGE_SIZE, self)
|
self.pager = InfiniteList(self.PAGE_SIZE, self)
|
||||||
self.pager.page_changed.connect(self._change_page)
|
self.pager.bind(self.table)
|
||||||
root.addWidget(self.pager)
|
root.addWidget(self.pager)
|
||||||
|
|
||||||
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
|
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
|
||||||
@@ -266,42 +265,41 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
self.load(1)
|
self.load(1)
|
||||||
|
|
||||||
def retry(self) -> None:
|
def retry(self) -> None:
|
||||||
self.load(self._page)
|
self.load(1)
|
||||||
|
|
||||||
def _change_page(self, page: int) -> None:
|
|
||||||
self.load(page)
|
|
||||||
|
|
||||||
def load(self, page: int) -> None:
|
def load(self, page: int) -> None:
|
||||||
if not self._active:
|
if not self._active:
|
||||||
return
|
return
|
||||||
self._page = max(1, int(page))
|
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
generation = self._generation
|
generation = self._generation
|
||||||
page_snapshot = self._page
|
|
||||||
keyword_snapshot = self.search_edit.text().strip()
|
keyword_snapshot = self.search_edit.text().strip()
|
||||||
self._invalidate_selection()
|
if keyword_snapshot != getattr(self, "_loaded_keyword", None):
|
||||||
self._set_loading(True)
|
self._invalidate_selection()
|
||||||
|
self._loaded_keyword = keyword_snapshot
|
||||||
|
self._set_loading(not self.pager.rows)
|
||||||
self.banner.clear()
|
self.banner.clear()
|
||||||
|
|
||||||
run_async(
|
self.pager.reload(
|
||||||
lambda: invoke(
|
lambda requested_page: invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"list_ai_patient_options",
|
"list_ai_patient_options",
|
||||||
page_no=page_snapshot,
|
page_no=requested_page,
|
||||||
page_size=self.PAGE_SIZE,
|
page_size=self.PAGE_SIZE,
|
||||||
keyword=keyword_snapshot,
|
keyword=keyword_snapshot,
|
||||||
),
|
),
|
||||||
on_success=lambda result: self._apply_result(
|
apply=lambda result: self._apply_result(
|
||||||
result, generation, page_snapshot, keyword_snapshot
|
result, generation, keyword_snapshot
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._apply_error(error, generation),
|
on_error=lambda error: self._apply_error(error, generation),
|
||||||
|
runner=run_async,
|
||||||
|
query_key=(keyword_snapshot,),
|
||||||
|
on_finished=lambda: self._finish_loading(generation),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_result(
|
def _apply_result(
|
||||||
self,
|
self,
|
||||||
result: Any,
|
result: Any,
|
||||||
generation: int,
|
generation: int,
|
||||||
page_snapshot: int,
|
|
||||||
keyword_snapshot: str,
|
keyword_snapshot: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not self._is_current(generation):
|
if not self._is_current(generation):
|
||||||
@@ -316,36 +314,32 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
for target in (AiConsultTarget.from_row(row) for row in page_items(result))
|
for target in (AiConsultTarget.from_row(row) for row in page_items(result))
|
||||||
if target is not None
|
if target is not None
|
||||||
]
|
]
|
||||||
total = max(0, page_total(result, len(targets)))
|
|
||||||
page_count = max(1, (total + self.PAGE_SIZE - 1) // self.PAGE_SIZE)
|
|
||||||
if page_snapshot > page_count:
|
|
||||||
self.load(page_count)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._page = page_snapshot
|
|
||||||
self.table.set_rows(targets)
|
self.table.set_rows(targets)
|
||||||
self.table.setSortingEnabled(False)
|
self.table.setSortingEnabled(False)
|
||||||
self.table.clearSelection()
|
self._page = self.pager.page
|
||||||
self.pager.update_state(page_snapshot, total)
|
self.empty_state.setVisible(not targets and not self.pager.has_more)
|
||||||
self.empty_state.setVisible(not targets)
|
self.table.setVisible(bool(targets) or self.pager.has_more)
|
||||||
self.table.setVisible(bool(targets))
|
|
||||||
self.banner.clear()
|
self.banner.clear()
|
||||||
self._set_loading(False)
|
self._set_loading(False)
|
||||||
|
|
||||||
def _apply_error(self, error: Exception, generation: int) -> None:
|
def _apply_error(self, error: Exception, generation: int) -> None:
|
||||||
if not self._is_current(generation):
|
if not self._is_current(generation):
|
||||||
return
|
return
|
||||||
self.table.set_rows(())
|
if not self.pager.rows:
|
||||||
self.table.setSortingEnabled(False)
|
self.table.set_rows(())
|
||||||
self.table.clearSelection()
|
self.table.setSortingEnabled(False)
|
||||||
self.table.hide()
|
self.table.clearSelection()
|
||||||
self.empty_state.show()
|
self.table.hide()
|
||||||
self.pager.update_state(1, 0)
|
self.empty_state.show()
|
||||||
self.banner.show_message(
|
self.banner.show_message(
|
||||||
f"患者诊单加载失败:{friendly_error(error)}", "danger"
|
f"患者诊单加载失败:{friendly_error(error)}", "danger"
|
||||||
)
|
)
|
||||||
self._set_loading(False)
|
self._set_loading(False)
|
||||||
|
|
||||||
|
def _finish_loading(self, generation: int) -> None:
|
||||||
|
if self._is_current(generation):
|
||||||
|
self._set_loading(False)
|
||||||
|
|
||||||
def _is_current(self, generation: int) -> bool:
|
def _is_current(self, generation: int) -> bool:
|
||||||
return self._active and generation == self._generation
|
return self._active and generation == self._generation
|
||||||
|
|
||||||
@@ -365,7 +359,6 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
def _set_loading(self, loading: bool) -> None:
|
def _set_loading(self, loading: bool) -> None:
|
||||||
self._loading = loading
|
self._loading = loading
|
||||||
self.table.setEnabled(not loading)
|
self.table.setEnabled(not loading)
|
||||||
self.pager.setEnabled(not loading)
|
|
||||||
self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0)
|
self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0)
|
||||||
self.busy_overlay.setVisible(loading)
|
self.busy_overlay.setVisible(loading)
|
||||||
if loading:
|
if loading:
|
||||||
@@ -383,6 +376,7 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
def done(self, result: int) -> None:
|
def done(self, result: int) -> None:
|
||||||
self._active = False
|
self._active = False
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
|
self.pager.invalidate()
|
||||||
self._search_timer.stop()
|
self._search_timer.stop()
|
||||||
super().done(result)
|
super().done(result)
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ class AppUpdateDialog(QDialog):
|
|||||||
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
|
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
|
||||||
self.badge.setObjectName("UpdateBadge")
|
self.badge.setObjectName("UpdateBadge")
|
||||||
self.badge.setStyleSheet(
|
self.badge.setStyleSheet(
|
||||||
"color:#B45309;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
|
"color:#A9691D;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||||
if offer.force
|
if offer.force
|
||||||
else "color:#4451E2;background:#F0F2FF;border-radius:8px;padding:4px 10px;font-weight:600;"
|
else "color:#4F63D9;background:#EEF1FA;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||||
)
|
)
|
||||||
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
|
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
latest = offer.latest_version or "新版本"
|
latest = offer.latest_version or "新版本"
|
||||||
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
|
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
|
||||||
self.version_label.setObjectName("UpdateVersionLabel")
|
self.version_label.setObjectName("UpdateVersionLabel")
|
||||||
self.version_label.setStyleSheet("color:#7886AA;")
|
self.version_label.setStyleSheet("color:#606163;")
|
||||||
root.addWidget(self.version_label)
|
root.addWidget(self.version_label)
|
||||||
|
|
||||||
self.notes = QTextEdit()
|
self.notes = QTextEdit()
|
||||||
@@ -160,7 +160,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
self.status_label = QLabel("")
|
self.status_label = QLabel("")
|
||||||
self.status_label.setObjectName("UpdateStatus")
|
self.status_label.setObjectName("UpdateStatus")
|
||||||
self.status_label.setWordWrap(True)
|
self.status_label.setWordWrap(True)
|
||||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||||
self.status_label.hide()
|
self.status_label.hide()
|
||||||
root.addWidget(self.status_label)
|
root.addWidget(self.status_label)
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
|
|
||||||
self.progress_text = QLabel("")
|
self.progress_text = QLabel("")
|
||||||
self.progress_text.setObjectName("UpdateProgressText")
|
self.progress_text.setObjectName("UpdateProgressText")
|
||||||
self.progress_text.setStyleSheet("color:#7886AA;font-size:12px;")
|
self.progress_text.setStyleSheet("color:#606163;font-size:12px;")
|
||||||
self.progress_text.hide()
|
self.progress_text.hide()
|
||||||
root.addWidget(self.progress_text)
|
root.addWidget(self.progress_text)
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
def show_download_progress(self, received: int, total: int) -> None:
|
def show_download_progress(self, received: int, total: int) -> None:
|
||||||
self.progress.show()
|
self.progress.show()
|
||||||
self.progress_text.show()
|
self.progress_text.show()
|
||||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||||
self.status_label.setText("正在下载安装包…")
|
self.status_label.setText("正在下载安装包…")
|
||||||
self.status_label.show()
|
self.status_label.show()
|
||||||
if total > 0:
|
if total > 0:
|
||||||
@@ -237,7 +237,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
self.progress_text.setText(_format_bytes(received))
|
self.progress_text.setText(_format_bytes(received))
|
||||||
|
|
||||||
def show_status(self, message: str, *, determinate: bool = False) -> None:
|
def show_status(self, message: str, *, determinate: bool = False) -> None:
|
||||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
self.status_label.show()
|
self.status_label.show()
|
||||||
self.progress.show()
|
self.progress.show()
|
||||||
@@ -251,7 +251,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
def show_error(self, message: str) -> None:
|
def show_error(self, message: str) -> None:
|
||||||
self._busy = False
|
self._busy = False
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
self.status_label.setStyleSheet("color:#F15B67;")
|
self.status_label.setStyleSheet("color:#BE4B58;")
|
||||||
self.status_label.show()
|
self.status_label.show()
|
||||||
self.progress.hide()
|
self.progress.hide()
|
||||||
self.progress_text.hide()
|
self.progress_text.hide()
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from ..diagnosis_drawer import (
|
|||||||
)
|
)
|
||||||
from ..diagnosis_editors import DailyRecordEditorDialog
|
from ..diagnosis_editors import DailyRecordEditorDialog
|
||||||
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
|
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
|
||||||
|
from ..infinite_list import InfiniteList
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -190,74 +191,74 @@ _ORDER_OFFSET_HELP = (
|
|||||||
|
|
||||||
_ORDER_DETAIL_QSS = """
|
_ORDER_DETAIL_QSS = """
|
||||||
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
|
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
|
||||||
QFrame#DiagnosisOrderDetailScrim { background: rgba(30, 64, 175, 0.18); border: 0; }
|
QFrame#DiagnosisOrderDetailScrim { background: rgba(26, 28, 31, 0.18); border: 0; }
|
||||||
QFrame#DiagnosisOrderDetailDrawer {
|
QFrame#DiagnosisOrderDetailDrawer {
|
||||||
background: #F7F9FE;
|
background: #F7F7F7;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-left: 1px solid #DDE7FF;
|
border-left: 1px solid #F0F0F0;
|
||||||
}
|
}
|
||||||
QFrame#DiagnosisOrderDetailHeader {
|
QFrame#DiagnosisOrderDetailHeader {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #DDE7FF;
|
border-bottom: 1px solid #F0F0F0;
|
||||||
}
|
}
|
||||||
QLabel#DiagnosisOrderDetailTitle { color: #15224A; font-size: 19px; font-weight: 650; }
|
QLabel#DiagnosisOrderDetailTitle { color: #1A1C1F; font-size: 19px; font-weight: 650; }
|
||||||
QLabel#DiagnosisOrderDetailMeta { color: #7481A3; font-size: 12px; }
|
QLabel#DiagnosisOrderDetailMeta { color: #606163; font-size: 12px; }
|
||||||
QLabel#DiagnosisOrderReadonlyBadge {
|
QLabel#DiagnosisOrderReadonlyBadge {
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
background: #F7F9FE;
|
background: #F7F7F7;
|
||||||
border: 1px solid #E2E7F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F9FE; }
|
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F7F7; }
|
||||||
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F9FE; }
|
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F7F7; }
|
||||||
QFrame[orderAmountCard="true"] {
|
QFrame[orderAmountCard="true"] {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #DDE7FF;
|
border: 1px solid #F0F0F0;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
QLabel[orderAmountTitle="true"] { color: #7481A3; font-size: 11px; font-weight: 550; }
|
QLabel[orderAmountTitle="true"] { color: #606163; font-size: 11px; font-weight: 550; }
|
||||||
QLabel[orderAmountValue="true"] {
|
QLabel[orderAmountValue="true"] {
|
||||||
color: #15224A;
|
color: #1A1C1F;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
QLabel[orderAmountTone="danger"] { color: #C43E55; }
|
QLabel[orderAmountTone="danger"] { color: #BE4B58; }
|
||||||
QLabel[orderAmountTone="success"] { color: #16876C; }
|
QLabel[orderAmountTone="success"] { color: #287B65; }
|
||||||
QLabel[orderAmountTone="warning"] { color: #9A6813; }
|
QLabel[orderAmountTone="warning"] { color: #A9691D; }
|
||||||
QFrame[orderDetailSection="true"] {
|
QFrame[orderDetailSection="true"] {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #E2E7F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
QLabel[orderSectionTitle="true"] { color: #15224A; font-size: 15px; font-weight: 650; }
|
QLabel[orderSectionTitle="true"] { color: #1A1C1F; font-size: 15px; font-weight: 650; }
|
||||||
QLabel[orderSectionHint="true"] { color: #7481A3; font-size: 11px; }
|
QLabel[orderSectionHint="true"] { color: #606163; font-size: 11px; }
|
||||||
QFrame[orderField="true"] {
|
QFrame[orderField="true"] {
|
||||||
background: #F2F6FE;
|
background: #F7F7F7;
|
||||||
border: 1px solid #E2E7F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
QLabel[orderFieldLabel="true"] { color: #7481A3; font-size: 11px; }
|
QLabel[orderFieldLabel="true"] { color: #6A6B6D; font-size: 11px; }
|
||||||
QLabel[orderFieldValue="true"] { color: #15224A; font-size: 13px; }
|
QLabel[orderFieldValue="true"] { color: #1A1C1F; font-size: 13px; }
|
||||||
QLabel[orderEmptyState="true"] {
|
QLabel[orderEmptyState="true"] {
|
||||||
color: #7481A3;
|
color: #606163;
|
||||||
background: #F2F6FE;
|
background: #F7F7F7;
|
||||||
border: 1px dashed #C9D8F2;
|
border: 1px dashed #E4E4E5;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 18px 12px;
|
padding: 18px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #93B4F4; }
|
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #8B9AD9; }
|
||||||
QLabel#DiagnosisOrderTimelineTime { color: #7481A3; font-size: 11px; }
|
QLabel#DiagnosisOrderTimelineTime { color: #606163; font-size: 11px; }
|
||||||
QLabel#DiagnosisOrderTimelineTitle { color: #15224A; font-size: 12px; font-weight: 600; }
|
QLabel#DiagnosisOrderTimelineTitle { color: #1A1C1F; font-size: 12px; font-weight: 600; }
|
||||||
QLabel#DiagnosisOrderTimelineBody { color: #7481A3; font-size: 12px; }
|
QLabel#DiagnosisOrderTimelineBody { color: #606163; font-size: 12px; }
|
||||||
QFrame#DiagnosisOrderDetailFooter {
|
QFrame#DiagnosisOrderDetailFooter {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-top: 1px solid #DDE7FF;
|
border-top: 1px solid #F0F0F0;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -758,9 +759,11 @@ class DiagnosisDialog(QDialog):
|
|||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
*,
|
*,
|
||||||
permissions: Any = None,
|
permissions: Any = None,
|
||||||
|
embedded: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
|
self._embedded = bool(embedded)
|
||||||
self.permissions = (
|
self.permissions = (
|
||||||
permissions
|
permissions
|
||||||
if permissions is not None
|
if permissions is not None
|
||||||
@@ -882,11 +885,17 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.setObjectName("DiagnosisDialogRoot")
|
self.setObjectName("DiagnosisDialogRoot")
|
||||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
if self._embedded:
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
self.setWindowFlags(Qt.WindowType.Widget)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False)
|
||||||
|
self.setMinimumSize(0, 0)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
|
||||||
|
else:
|
||||||
|
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||||
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
|
self.setMinimumSize(760, 520)
|
||||||
|
self.resize(1024, 640)
|
||||||
self.setWindowTitle("患者信息详情")
|
self.setWindowTitle("患者信息详情")
|
||||||
self.setMinimumSize(760, 520)
|
|
||||||
self.resize(1024, 640)
|
|
||||||
self.setStyleSheet(DIAGNOSIS_QSS)
|
self.setStyleSheet(DIAGNOSIS_QSS)
|
||||||
|
|
||||||
self.view_stack = QStackedLayout(self)
|
self.view_stack = QStackedLayout(self)
|
||||||
@@ -911,16 +920,25 @@ class DiagnosisDialog(QDialog):
|
|||||||
root = QVBoxLayout(page)
|
root = QVBoxLayout(page)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
root.setSpacing(0)
|
root.setSpacing(0)
|
||||||
|
self.readonly_header = QWidget(page)
|
||||||
|
self.readonly_header.setObjectName("DiagnosisReadonlyHeader")
|
||||||
|
header_layout = QVBoxLayout(self.readonly_header)
|
||||||
|
header_layout.setContentsMargins(16, 16, 16, 0)
|
||||||
|
header_layout.setSpacing(0)
|
||||||
|
self.readonly_hero = self._build_readonly_hero()
|
||||||
|
header_layout.addWidget(self.readonly_hero)
|
||||||
|
root.addWidget(self.readonly_header)
|
||||||
self.readonly_scroll = QScrollArea()
|
self.readonly_scroll = QScrollArea()
|
||||||
self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll")
|
self.readonly_scroll.setObjectName("DiagnosisReadonlyScroll")
|
||||||
self.readonly_scroll.setWidgetResizable(True)
|
self.readonly_scroll.setWidgetResizable(True)
|
||||||
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.readonly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.readonly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
|
self.readonly_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.readonly_scroll.verticalScrollBar().setSingleStep(28)
|
||||||
content = QWidget()
|
content = QWidget()
|
||||||
self.readonly_content_layout = QVBoxLayout(content)
|
self.readonly_content_layout = QVBoxLayout(content)
|
||||||
self.readonly_content_layout.setContentsMargins(16, 16, 16, 16)
|
self.readonly_content_layout.setContentsMargins(16, 16, 16, 16)
|
||||||
self.readonly_content_layout.setSpacing(16)
|
self.readonly_content_layout.setSpacing(16)
|
||||||
self.readonly_hero = self._build_readonly_hero()
|
|
||||||
self.readonly_content_layout.addWidget(self.readonly_hero)
|
|
||||||
self.readonly_error = QFrame()
|
self.readonly_error = QFrame()
|
||||||
self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard")
|
self.readonly_error.setObjectName("DiagnosisReadonlyErrorCard")
|
||||||
self.readonly_error.setProperty("diagnosisReadonlyCard", True)
|
self.readonly_error.setProperty("diagnosisReadonlyCard", True)
|
||||||
@@ -981,16 +999,16 @@ class DiagnosisDialog(QDialog):
|
|||||||
left_layout = QHBoxLayout(self.readonly_hero_left)
|
left_layout = QHBoxLayout(self.readonly_hero_left)
|
||||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
left_layout.setSpacing(12)
|
left_layout.setSpacing(12)
|
||||||
back = QPushButton("← 返回")
|
self.readonly_back_button = QPushButton("← 收起资料" if self._embedded else "← 返回")
|
||||||
back.setObjectName("DiagnosisReadonlyBack")
|
self.readonly_back_button.setObjectName("DiagnosisReadonlyBack")
|
||||||
back.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
back.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;}"
|
||||||
)
|
)
|
||||||
back.clicked.connect(self.reject)
|
self.readonly_back_button.clicked.connect(self.reject)
|
||||||
left_layout.addWidget(back)
|
left_layout.addWidget(self.readonly_back_button)
|
||||||
title = QLabel("患者信息详情")
|
title = QLabel("患者信息详情")
|
||||||
title.setObjectName("DiagnosisReadonlyTitle")
|
title.setObjectName("DiagnosisReadonlyTitle")
|
||||||
left_layout.addWidget(title)
|
left_layout.addWidget(title)
|
||||||
@@ -1009,6 +1027,13 @@ class DiagnosisDialog(QDialog):
|
|||||||
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
|
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
|
||||||
self.readonly_status.setProperty("severity", "neutral")
|
self.readonly_status.setProperty("severity", "neutral")
|
||||||
right_layout.addWidget(self.readonly_status)
|
right_layout.addWidget(self.readonly_status)
|
||||||
|
self.readonly_close_button = QPushButton("×")
|
||||||
|
self.readonly_close_button.setObjectName("DiagnosisCloseButton")
|
||||||
|
self.readonly_close_button.setToolTip("关闭诊单详情")
|
||||||
|
self.readonly_close_button.setAccessibleName("关闭诊单详情")
|
||||||
|
self.readonly_close_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.readonly_close_button.clicked.connect(self.reject)
|
||||||
|
right_layout.addWidget(self.readonly_close_button)
|
||||||
layout.addWidget(self.readonly_hero_left, 0, 0)
|
layout.addWidget(self.readonly_hero_left, 0, 0)
|
||||||
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
||||||
layout.setColumnStretch(0, 1)
|
layout.setColumnStretch(0, 1)
|
||||||
@@ -1508,20 +1533,11 @@ class DiagnosisDialog(QDialog):
|
|||||||
layout.addWidget(toolbar)
|
layout.addWidget(toolbar)
|
||||||
self.orders_table = self._new_table("orders", "DiagnosisTableOrders")
|
self.orders_table = self._new_table("orders", "DiagnosisTableOrders")
|
||||||
layout.addWidget(self.orders_table, 1)
|
layout.addWidget(self.orders_table, 1)
|
||||||
footer = QHBoxLayout()
|
self.orders_list = InfiniteList(self._orders_page_size, page)
|
||||||
self.orders_summary = QLabel("共 0 条")
|
for table in self._table_registry["orders"]:
|
||||||
self.orders_summary.setObjectName("DiagnosisOrdersSummary")
|
self.orders_list.bind(table)
|
||||||
footer.addWidget(self.orders_summary)
|
self._orders_footer_layout = layout
|
||||||
footer.addStretch(1)
|
layout.addWidget(self.orders_list)
|
||||||
self.orders_previous = QPushButton("上一页")
|
|
||||||
self.orders_previous.clicked.connect(lambda: self._change_orders_page(-1))
|
|
||||||
footer.addWidget(self.orders_previous)
|
|
||||||
self.orders_page_label = QLabel("1 / 1")
|
|
||||||
footer.addWidget(self.orders_page_label)
|
|
||||||
self.orders_next = QPushButton("下一页")
|
|
||||||
self.orders_next.clicked.connect(lambda: self._change_orders_page(1))
|
|
||||||
footer.addWidget(self.orders_next)
|
|
||||||
layout.addLayout(footer)
|
|
||||||
return page
|
return page
|
||||||
|
|
||||||
def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea:
|
def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea:
|
||||||
@@ -1790,6 +1806,8 @@ class DiagnosisDialog(QDialog):
|
|||||||
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
|
||||||
def _sync_host_geometry(self) -> None:
|
def _sync_host_geometry(self) -> None:
|
||||||
|
if self._embedded:
|
||||||
|
return
|
||||||
owner = self._owner
|
owner = self._owner
|
||||||
if owner is None:
|
if owner is None:
|
||||||
if self.width() < 760 or self.height() < 520:
|
if self.width() < 760 or self.height() < 520:
|
||||||
@@ -1823,6 +1841,8 @@ class DiagnosisDialog(QDialog):
|
|||||||
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
layout.addWidget(self.readonly_hero_right, 0, 1, Qt.AlignmentFlag.AlignRight)
|
||||||
|
|
||||||
def _install_owner_filter(self) -> None:
|
def _install_owner_filter(self) -> None:
|
||||||
|
if self._embedded:
|
||||||
|
return
|
||||||
if self._owner is not None and not self._owner_filter_installed:
|
if self._owner is not None and not self._owner_filter_installed:
|
||||||
self._owner.installEventFilter(self)
|
self._owner.installEventFilter(self)
|
||||||
self._owner_filter_installed = True
|
self._owner_filter_installed = True
|
||||||
@@ -1830,6 +1850,10 @@ class DiagnosisDialog(QDialog):
|
|||||||
def _rebind_owner(self) -> None:
|
def _rebind_owner(self) -> None:
|
||||||
"""Resolve the live Shell window for every open/show cycle."""
|
"""Resolve the live Shell window for every open/show cycle."""
|
||||||
|
|
||||||
|
if self._embedded:
|
||||||
|
self._owner = None
|
||||||
|
self._owner_filter_installed = False
|
||||||
|
return
|
||||||
parent = self.parentWidget()
|
parent = self.parentWidget()
|
||||||
candidate = parent.window() if parent is not None else None
|
candidate = parent.window() if parent is not None else None
|
||||||
if candidate is self:
|
if candidate is self:
|
||||||
@@ -1852,8 +1876,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
|
|
||||||
def showEvent(self, event: Any) -> None:
|
def showEvent(self, event: Any) -> None:
|
||||||
self._rebind_owner()
|
if not self._embedded:
|
||||||
self._sync_host_geometry()
|
self._rebind_owner()
|
||||||
|
self._sync_host_geometry()
|
||||||
self._update_drawer_geometry()
|
self._update_drawer_geometry()
|
||||||
self._reflow_readonly_hero()
|
self._reflow_readonly_hero()
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
@@ -1938,10 +1963,12 @@ class DiagnosisDialog(QDialog):
|
|||||||
*,
|
*,
|
||||||
editable: bool = False,
|
editable: bool = False,
|
||||||
seed: Any = None,
|
seed: Any = None,
|
||||||
|
authoritative_detail: Any = None,
|
||||||
view_only: bool = False,
|
view_only: bool = False,
|
||||||
modeless: bool = False,
|
modeless: bool = False,
|
||||||
|
auto_show: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Open immediately, then replace the seed with authoritative server data."""
|
"""Prepare a diagnosis view, optionally showing it immediately."""
|
||||||
|
|
||||||
self._rebind_owner()
|
self._rebind_owner()
|
||||||
for player in list(self._recording_players):
|
for player in list(self._recording_players):
|
||||||
@@ -1986,13 +2013,20 @@ 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._detail = seed
|
self.orders_list.reset()
|
||||||
|
footer_layout = (
|
||||||
|
self._readonly_sections["orders"].layout()
|
||||||
|
if self._standalone_readonly
|
||||||
|
else self._orders_footer_layout
|
||||||
|
)
|
||||||
|
footer_layout.addWidget(self.orders_list)
|
||||||
|
self._detail = authoritative_detail if authoritative_detail is not None else seed
|
||||||
self.save_button.set_state("idle")
|
self.save_button.set_state("idle")
|
||||||
self.refresh_permissions()
|
self.refresh_permissions()
|
||||||
self.view_stack.setCurrentWidget(
|
self.view_stack.setCurrentWidget(
|
||||||
self.readonly_page if self._standalone_readonly else self.drawer_overlay
|
self.readonly_page if self._standalone_readonly else self.drawer_overlay
|
||||||
)
|
)
|
||||||
self.setModal(not modeless and not self._standalone_readonly)
|
self.setModal(False if self._embedded else not modeless and not self._standalone_readonly)
|
||||||
self.setWindowTitle(
|
self.setWindowTitle(
|
||||||
"患者信息详情"
|
"患者信息详情"
|
||||||
if self._standalone_readonly
|
if self._standalone_readonly
|
||||||
@@ -2018,15 +2052,38 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self._clear_tables()
|
self._clear_tables()
|
||||||
self._clear_message()
|
self._clear_message()
|
||||||
if seed is not None:
|
if self._detail is not None:
|
||||||
self._render(seed, [], [])
|
self._render(self._detail, [], [])
|
||||||
self._sync_form_interactivity()
|
self._sync_form_interactivity()
|
||||||
self._sync_save_button()
|
self._sync_save_button()
|
||||||
|
self._sync_host_geometry()
|
||||||
|
if auto_show:
|
||||||
|
self.show()
|
||||||
|
if not self._embedded:
|
||||||
|
self.raise_()
|
||||||
|
if authoritative_detail is not None:
|
||||||
|
diagnosis = get_value(authoritative_detail, "diagnosis", None) or authoritative_detail
|
||||||
|
patient = get_value(authoritative_detail, "patient", None) or {}
|
||||||
|
self._patient_id = _int(
|
||||||
|
first_value(
|
||||||
|
diagnosis,
|
||||||
|
"patient_id",
|
||||||
|
"source_patient_id",
|
||||||
|
default=first_value(patient, "patient_id", "id", default=0),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self._authoritative_detail_loaded = True
|
||||||
|
self._show_authoritative_content(True)
|
||||||
|
self._clear_message()
|
||||||
|
self._set_loading(False)
|
||||||
|
if self._standalone_readonly:
|
||||||
|
self._load_visible_readonly_sections()
|
||||||
|
else:
|
||||||
|
self._ensure_tab_loaded(self._current_tab_key())
|
||||||
|
return
|
||||||
self._show_message("正在加载权威诊单详情…", "info")
|
self._show_message("正在加载权威诊单详情…", "info")
|
||||||
self._set_loading(True)
|
self._set_loading(True)
|
||||||
self._sync_host_geometry()
|
|
||||||
self.show()
|
|
||||||
self.raise_()
|
|
||||||
self._start_detail_load()
|
self._start_detail_load()
|
||||||
|
|
||||||
def _start_detail_load(self) -> None:
|
def _start_detail_load(self) -> None:
|
||||||
@@ -2257,6 +2314,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._save_generation += 1
|
self._save_generation += 1
|
||||||
self._orders_generation += 1
|
self._orders_generation += 1
|
||||||
|
self.orders_list.invalidate()
|
||||||
self._order_detail_generation += 1
|
self._order_detail_generation += 1
|
||||||
self._daily_mutation_generation += 1
|
self._daily_mutation_generation += 1
|
||||||
self._notes_mutation_generation += 1
|
self._notes_mutation_generation += 1
|
||||||
@@ -2581,6 +2639,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
return
|
return
|
||||||
if not force and (key in self._loaded_tabs or key in self._loading_tabs):
|
if not force and (key in self._loaded_tabs or key in self._loading_tabs):
|
||||||
return
|
return
|
||||||
|
if key == "orders":
|
||||||
|
self._load_orders()
|
||||||
|
return
|
||||||
self._tab_generations[key] += 1
|
self._tab_generations[key] += 1
|
||||||
generation = self._tab_generations[key]
|
generation = self._tab_generations[key]
|
||||||
diagnosis_id = self._diagnosis_id
|
diagnosis_id = self._diagnosis_id
|
||||||
@@ -2674,10 +2735,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._fill_prescriptions(page_items(result))
|
self._fill_prescriptions(page_items(result))
|
||||||
elif key == "orders":
|
elif key == "orders":
|
||||||
rows = page_items(result)
|
rows = page_items(result)
|
||||||
self._orders_page = 1
|
self._orders_page = self.orders_list.page
|
||||||
self._orders_total = page_total(result, len(rows))
|
self._orders_total = page_total(result, len(rows))
|
||||||
self._fill_orders(rows)
|
self._fill_orders(rows)
|
||||||
self._update_orders_pager()
|
|
||||||
elif key == "assign":
|
elif key == "assign":
|
||||||
self._fill_assignments(page_items(result))
|
self._fill_assignments(page_items(result))
|
||||||
elif key == "appointment":
|
elif key == "appointment":
|
||||||
@@ -3390,8 +3450,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
panel.set_unavailable("切换到聊天记录后加载归档数据。")
|
panel.set_unavailable("切换到聊天记录后加载归档数据。")
|
||||||
for panel in self._daily_panels:
|
for panel in self._daily_panels:
|
||||||
panel.clear()
|
panel.clear()
|
||||||
self.orders_summary.setText("共 0 条")
|
self.orders_list.update_state(1, 0)
|
||||||
self.orders_page_label.setText("1 / 1")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
|
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
|
||||||
@@ -4009,6 +4068,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
for row_index, row in enumerate(rows):
|
for row_index, row in enumerate(rows):
|
||||||
order_id = _int(first_value(row, "id", "order_id"), 0)
|
order_id = _int(first_value(row, "id", "order_id"), 0)
|
||||||
|
item = table.item(row_index, 0)
|
||||||
|
if item is not None:
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||||
if (
|
if (
|
||||||
self._can_order_detail
|
self._can_order_detail
|
||||||
and order_id > 0
|
and order_id > 0
|
||||||
@@ -4680,52 +4742,44 @@ class DiagnosisDialog(QDialog):
|
|||||||
if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id:
|
if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id:
|
||||||
self._show_message(friendly_error(error), "danger")
|
self._show_message(friendly_error(error), "danger")
|
||||||
|
|
||||||
def _update_orders_pager(self) -> None:
|
def _load_orders(self) -> None:
|
||||||
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
|
|
||||||
self.orders_summary.setText(f"共 {self._orders_total} 条")
|
|
||||||
self.orders_page_label.setText(f"{self._orders_page} / {pages}")
|
|
||||||
self.orders_previous.setEnabled(self._orders_page > 1)
|
|
||||||
self.orders_next.setEnabled(self._orders_page < pages)
|
|
||||||
|
|
||||||
def _change_orders_page(self, offset: int) -> None:
|
|
||||||
if not self._can_patient_orders or self._diagnosis_id <= 0:
|
if not self._can_patient_orders or self._diagnosis_id <= 0:
|
||||||
return
|
return
|
||||||
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
|
self._tab_generations["orders"] += 1
|
||||||
target_page = self._orders_page + offset
|
generation = self._tab_generations["orders"]
|
||||||
if target_page < 1 or target_page > pages:
|
|
||||||
return
|
|
||||||
self._orders_generation += 1
|
|
||||||
generation = self._orders_generation
|
|
||||||
diagnosis_id = self._diagnosis_id
|
diagnosis_id = self._diagnosis_id
|
||||||
patient_id = self._patient_id
|
patient_id = self._patient_id
|
||||||
self._show_message("正在加载患者订单…", "info")
|
self._loading_tabs.add("orders")
|
||||||
run_async(
|
if not self.orders_list.rows:
|
||||||
lambda: self._query_orders(diagnosis_id, patient_id, target_page),
|
self._set_tab_loading("orders")
|
||||||
on_success=lambda result: self._apply_orders_page(
|
self.orders_list.reload(
|
||||||
result, diagnosis_id, target_page, generation
|
lambda page: self._query_orders(diagnosis_id, patient_id, page),
|
||||||
|
apply=lambda result: self._apply_orders_result(
|
||||||
|
result, diagnosis_id, generation
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._orders_error(error, diagnosis_id, generation),
|
on_error=lambda error: self._tab_load_error(
|
||||||
|
"orders", error, diagnosis_id, generation
|
||||||
|
),
|
||||||
|
runner=run_async,
|
||||||
|
query_key=(diagnosis_id, patient_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_orders_page(
|
def _apply_orders_result(
|
||||||
self,
|
self,
|
||||||
result: Any,
|
result: Any,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
page: int,
|
|
||||||
generation: int,
|
generation: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
if generation != self._orders_generation or diagnosis_id != self._diagnosis_id:
|
if (
|
||||||
|
generation != self._tab_generations["orders"]
|
||||||
|
or diagnosis_id != self._diagnosis_id
|
||||||
|
or not self._authoritative_detail_loaded
|
||||||
|
):
|
||||||
return
|
return
|
||||||
rows = page_items(result)
|
if self.orders_list.page == 0:
|
||||||
self._orders_page = page
|
self._fill_orders([])
|
||||||
self._orders_total = page_total(result, len(rows))
|
return
|
||||||
self._fill_orders(rows)
|
self._apply_tab_result("orders", result, diagnosis_id, generation)
|
||||||
self._update_orders_pager()
|
|
||||||
self._clear_message()
|
|
||||||
|
|
||||||
def _orders_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
|
|
||||||
if generation == self._orders_generation and diagnosis_id == self._diagnosis_id:
|
|
||||||
self._show_message(friendly_error(error), "danger")
|
|
||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -40,76 +40,76 @@ _STATUS_LABELS = {
|
|||||||
"invalid": "无效录音",
|
"invalid": "无效录音",
|
||||||
}
|
}
|
||||||
_STATUS_COLORS = {
|
_STATUS_COLORS = {
|
||||||
"recording": "#5364F5",
|
"recording": "#1A1C1F",
|
||||||
"pending": "#B26A00",
|
"pending": "#A9691D",
|
||||||
"uploading": "#2F6FEB",
|
"uploading": "#1A1C1F",
|
||||||
"uploaded": "#07966B",
|
"uploaded": "#287B65",
|
||||||
"failed": "#DC4054",
|
"failed": "#BE4B58",
|
||||||
"invalid": "#7886AA",
|
"invalid": "#606163",
|
||||||
}
|
}
|
||||||
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
|
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
_LOCAL_AUDIO_QSS = """
|
_LOCAL_AUDIO_QSS = """
|
||||||
QDialog#LocalAudioQueueDialog {
|
QDialog#LocalAudioQueueDialog {
|
||||||
background: #F6F8FD;
|
background: #F7F7F7;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
}
|
}
|
||||||
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
|
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
|
||||||
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
|
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #E2E7F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
QLabel#LocalAudioQueueTitle {
|
QLabel#LocalAudioQueueTitle {
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
|
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
|
||||||
color: #6E7C9F;
|
color: #6A6B6D;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
QLabel[queueSummary="true"] {
|
QLabel[queueSummary="true"] {
|
||||||
background: #F3F5FB;
|
background: #F7F7F7;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
QPushButton {
|
QPushButton {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
border: 1px solid #D9E0F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
color: #354365;
|
color: #1A1C1F;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; }
|
QPushButton:hover { background: #EEF1FA; border-color: #8B9AD9; }
|
||||||
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; }
|
QPushButton:disabled { color: #8E8F90; background: #F7F7F7; }
|
||||||
QPushButton[variant="primary"] {
|
QPushButton[variant="primary"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background: #5661F4;
|
background: #4F63D9;
|
||||||
border-color: #5661F4;
|
border-color: #4F63D9;
|
||||||
}
|
}
|
||||||
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; }
|
QPushButton[variant="danger"] { color: #BE4B58; background: #FFF6F7; }
|
||||||
QTableWidget#LocalAudioQueueTable {
|
QTableWidget#LocalAudioQueueTable {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
alternate-background-color: #FAFBFE;
|
alternate-background-color: #F7F7F7;
|
||||||
border: 0;
|
border: 0;
|
||||||
gridline-color: #E8ECF5;
|
gridline-color: #EDEDEE;
|
||||||
color: #263452;
|
color: #1A1C1F;
|
||||||
selection-background-color: #EEF1FF;
|
selection-background-color: #EEF1FA;
|
||||||
selection-color: #111F46;
|
selection-color: #1A1C1F;
|
||||||
}
|
}
|
||||||
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
|
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
|
||||||
QHeaderView::section {
|
QHeaderView::section {
|
||||||
background: #F5F7FC;
|
background: #F7F7F7;
|
||||||
color: #53617F;
|
color: #6A6B6D;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #E1E6F1;
|
border-bottom: 1px solid #EDEDEE;
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -371,7 +371,7 @@ class LocalAudioQueueDialog(QDialog):
|
|||||||
self._status_column,
|
self._status_column,
|
||||||
_STATUS_LABELS.get(record.status, record.status),
|
_STATUS_LABELS.get(record.status, record.status),
|
||||||
)
|
)
|
||||||
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#53617F")))
|
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#606163")))
|
||||||
status_item.setToolTip(
|
status_item.setToolTip(
|
||||||
f"已尝试 {record.attempts} 次"
|
f"已尝试 {record.attempts} 次"
|
||||||
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "")
|
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -143,75 +143,75 @@ def diagnosis_ai_task(prompt: str) -> str:
|
|||||||
|
|
||||||
PRESCRIPTION_AI_QSS = """
|
PRESCRIPTION_AI_QSS = """
|
||||||
QDialog#PrescriptionAiDialog {
|
QDialog#PrescriptionAiDialog {
|
||||||
color: #17203F;
|
color: #1A1C1F;
|
||||||
background-color: #F7F9FE;
|
background-color: #F7F7F7;
|
||||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton {
|
QDialog#PrescriptionAiDialog QPushButton {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
color: #4F5B75;
|
color: #1A1C1F;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton:hover {
|
QDialog#PrescriptionAiDialog QPushButton:hover {
|
||||||
color: #4D57D8;
|
color: #4156C4;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
border-color: #D8DCFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton:pressed {
|
QDialog#PrescriptionAiDialog QPushButton:pressed {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background-color: #4D57D8;
|
background-color: #354BB4;
|
||||||
border-color: #4D57D8;
|
border-color: #354BB4;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton[variant="primary"] {
|
QDialog#PrescriptionAiDialog QPushButton[variant="primary"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background-color: #5761F4;
|
background-color: #4F63D9;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton[variant="primary"]:hover {
|
QDialog#PrescriptionAiDialog QPushButton[variant="primary"]:hover {
|
||||||
background-color: #6871F6;
|
background-color: #4156C4;
|
||||||
border-color: #6871F6;
|
border-color: #4156C4;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton[variant="link"] {
|
QDialog#PrescriptionAiDialog QPushButton[variant="link"] {
|
||||||
color: #4D57D8;
|
color: #4F63D9;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton[variant="link"]:hover {
|
QDialog#PrescriptionAiDialog QPushButton[variant="link"]:hover {
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiTitle {
|
QLabel#PrescriptionAiTitle {
|
||||||
color: #17203F;
|
color: #1A1C1F;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiSubtitle {
|
QLabel#PrescriptionAiSubtitle {
|
||||||
color: #78849D;
|
color: #606163;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
QFrame#PrescriptionAiSnapshot {
|
QFrame#PrescriptionAiSnapshot {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiSnapshotLabel {
|
QLabel#PrescriptionAiSnapshotLabel {
|
||||||
color: #4D57D8;
|
color: #1A1C1F;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiSnapshotBody {
|
QLabel#PrescriptionAiSnapshotBody {
|
||||||
color: #26304F;
|
color: #1A1C1F;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
QFrame#PrescriptionAiSummary {
|
QFrame#PrescriptionAiSummary {
|
||||||
background-color: #F0F2FF;
|
background-color: #F0F0F0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-left: 3px solid #5761F4;
|
border-left: 3px solid #4F63D9;
|
||||||
border-radius: 0 9px 9px 0;
|
border-radius: 0 9px 9px 0;
|
||||||
}
|
}
|
||||||
QFrame#PrescriptionAiCaution {
|
QFrame#PrescriptionAiCaution {
|
||||||
@@ -220,56 +220,56 @@ QFrame#PrescriptionAiCaution {
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiSectionTitle {
|
QLabel#PrescriptionAiSectionTitle {
|
||||||
color: #17203F;
|
color: #1A1C1F;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiBody {
|
QLabel#PrescriptionAiBody {
|
||||||
color: #37415E;
|
color: #1A1C1F;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
QTextBrowser#PrescriptionAiAnswer {
|
QTextBrowser#PrescriptionAiAnswer {
|
||||||
color: #34436B;
|
color: #1A1C1F;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #E3E8F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
selection-color: #17203F;
|
selection-color: #1A1C1F;
|
||||||
selection-background-color: #DDE2FF;
|
selection-background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiMuted {
|
QLabel#PrescriptionAiMuted {
|
||||||
color: #8A94AA;
|
color: #6A6B6D;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
QFrame#PrescriptionAiMeta {
|
QFrame#PrescriptionAiMeta {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #E3E8F4;
|
border-bottom: 1px solid #EDEDEE;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTabWidget::pane {
|
QDialog#PrescriptionAiDialog QTabWidget::pane {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
top: -1px;
|
top: -1px;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTabBar::tab {
|
QDialog#PrescriptionAiDialog QTabBar::tab {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
color: #78849D;
|
color: #606163;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTabBar::tab:hover {
|
QDialog#PrescriptionAiDialog QTabBar::tab:hover {
|
||||||
color: #4D57D8;
|
color: #4156C4;
|
||||||
background-color: #F5F7FC;
|
background-color: #F7F7F7;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTabBar::tab:selected {
|
QDialog#PrescriptionAiDialog QTabBar::tab:selected {
|
||||||
color: #4D57D8;
|
color: #4F63D9;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
border-bottom: 2px solid #5761F4;
|
border-bottom: 2px solid #4F63D9;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QScrollArea,
|
QDialog#PrescriptionAiDialog QScrollArea,
|
||||||
@@ -278,15 +278,15 @@ QDialog#PrescriptionAiDialog QScrollArea > QWidget > QWidget {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTextEdit {
|
QDialog#PrescriptionAiDialog QTextEdit {
|
||||||
color: #17203F;
|
color: #1A1C1F;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
selection-background-color: #E3E6FF;
|
selection-background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTextEdit:focus {
|
QDialog#PrescriptionAiDialog QTextEdit:focus {
|
||||||
border: 1px solid #5761F4;
|
border: 1px solid #8B9AD9;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
@@ -295,7 +295,7 @@ QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
|||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QScrollBar::handle:vertical {
|
QDialog#PrescriptionAiDialog QScrollBar::handle:vertical {
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
background-color: #C8D0E0;
|
background-color: #D2D2D3;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
|
QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
|
||||||
@@ -305,20 +305,20 @@ QDialog#PrescriptionAiDialog QScrollBar::sub-line:vertical {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_AI_ANSWER_DOCUMENT_CSS = (
|
_AI_ANSWER_DOCUMENT_CSS = (
|
||||||
"body { color:#34436B; font-size:14px; line-height:1.72; } "
|
"body { color:#1A1C1F; font-size:14px; line-height:1.72; } "
|
||||||
"h1 { color:#15224A; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
|
"h1 { color:#1A1C1F; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
|
||||||
"h2 { color:#15224A; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
|
"h2 { color:#1A1C1F; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
|
||||||
"h3,h4 { color:#15224A; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
"h3,h4 { color:#1A1C1F; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
||||||
"p { margin:6px 0; line-height:1.72; } "
|
"p { margin:6px 0; line-height:1.72; } "
|
||||||
"ul,ol { margin:7px 0 8px 22px; } li { margin:4px 0; line-height:1.65; } "
|
"ul,ol { margin:7px 0 8px 22px; } li { margin:4px 0; line-height:1.65; } "
|
||||||
"strong { color:#15224A; font-weight:700; } "
|
"strong { color:#1A1C1F; font-weight:700; } "
|
||||||
"blockquote { color:#596788; background:#F5F7FC; border-left:3px solid #7B84F7; "
|
"blockquote { color:#606163; background:#F7F7F7; border-left:3px solid #8B9AD9; "
|
||||||
"margin:9px 0; padding:7px 10px; } "
|
"margin:9px 0; padding:7px 10px; } "
|
||||||
"code { color:#33406B; background:#EEF1FF; } "
|
"code { color:#1A1C1F; background:#F0F0F0; } "
|
||||||
"pre { color:#33406B; background:#F0F3FA; margin:8px 0; padding:9px; } "
|
"pre { color:#1A1C1F; background:#F7F7F7; margin:8px 0; padding:9px; } "
|
||||||
"table { border-collapse:collapse; margin:8px 0; } "
|
"table { border-collapse:collapse; margin:8px 0; } "
|
||||||
"th,td { border:1px solid #DCE3F2; padding:6px 8px; } "
|
"th,td { border:1px solid #EDEDEE; padding:6px 8px; } "
|
||||||
"th { color:#15224A; background:#F5F7FC; font-weight:700; }"
|
"th { color:#1A1C1F; background:#F7F7F7; font-weight:700; }"
|
||||||
)
|
)
|
||||||
_AI_ANSWER_MARKDOWN_FEATURES = (
|
_AI_ANSWER_MARKDOWN_FEATURES = (
|
||||||
QTextDocument.MarkdownFeature.MarkdownDialectGitHub
|
QTextDocument.MarkdownFeature.MarkdownDialectGitHub
|
||||||
@@ -1181,7 +1181,7 @@ class PrescriptionAiReportDialog(QDialog):
|
|||||||
def _list_html(self, items: Any, empty: str) -> str:
|
def _list_html(self, items: Any, empty: str) -> str:
|
||||||
values = [str(item).strip() for item in (items or []) if str(item).strip()]
|
values = [str(item).strip() for item in (items or []) if str(item).strip()]
|
||||||
if not values:
|
if not values:
|
||||||
return f'<span style="color:#8A94AA;">{html.escape(empty)}</span>'
|
return f'<span style="color:#6A6B6D;">{html.escape(empty)}</span>'
|
||||||
bullets = "".join(f"<li>{html.escape(item)}</li>" for item in values)
|
bullets = "".join(f"<li>{html.escape(item)}</li>" for item in values)
|
||||||
return f'<ul style="margin:0;padding-left:18px;">{bullets}</ul>'
|
return f'<ul style="margin:0;padding-left:18px;">{bullets}</ul>'
|
||||||
|
|
||||||
@@ -1398,7 +1398,7 @@ class PrescriptionAiReportDialog(QDialog):
|
|||||||
content.setTextFormat(Qt.TextFormat.RichText)
|
content.setTextFormat(Qt.TextFormat.RichText)
|
||||||
cell_layout.addWidget(heading)
|
cell_layout.addWidget(heading)
|
||||||
cell_layout.addWidget(content)
|
cell_layout.addWidget(content)
|
||||||
grid.addWidget(cell, index // 2, index % 2)
|
grid.addWidget(cell, index // 2, index % 2, Qt.AlignmentFlag.AlignTop)
|
||||||
self.host_layout.addWidget(grid_host)
|
self.host_layout.addWidget(grid_host)
|
||||||
if report.get("compatibility_analysis"):
|
if report.get("compatibility_analysis"):
|
||||||
self._section("配伍分析", str(report.get("compatibility_analysis") or ""))
|
self._section("配伍分析", str(report.get("compatibility_analysis") or ""))
|
||||||
|
|||||||
@@ -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,7 +10,7 @@ from html import escape
|
|||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
|
from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
|
||||||
from PySide6.QtGui import QDesktopServices, QPixmap
|
from PySide6.QtGui import QDesktopServices, QPixmap
|
||||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -22,7 +22,6 @@ from PySide6.QtWidgets import (
|
|||||||
QFormLayout,
|
QFormLayout,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
@@ -38,11 +37,11 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .. import icons
|
||||||
|
from ..consultations_style import consultations_stylesheet
|
||||||
from ..diagnosis_index_widgets import (
|
from ..diagnosis_index_widgets import (
|
||||||
DIAGNOSIS_INDEX_QSS,
|
|
||||||
DiagnosisChip,
|
DiagnosisChip,
|
||||||
DiagnosisLoadingOverlay,
|
DiagnosisLoadingOverlay,
|
||||||
DiagnosisPager,
|
|
||||||
DiagnosisTableHost,
|
DiagnosisTableHost,
|
||||||
FlowWidget,
|
FlowWidget,
|
||||||
prescription_action,
|
prescription_action,
|
||||||
@@ -56,6 +55,8 @@ from ..dialogs.prescription import (
|
|||||||
build_prescription_clinical_diagnosis,
|
build_prescription_clinical_diagnosis,
|
||||||
build_prescription_visit_no,
|
build_prescription_visit_no,
|
||||||
)
|
)
|
||||||
|
from ..filter_disclosure import FilterDisclosure
|
||||||
|
from ..infinite_list import InfiniteList
|
||||||
from ..theme import mark_business_dialog
|
from ..theme import mark_business_dialog
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
MessageBanner,
|
MessageBanner,
|
||||||
@@ -72,118 +73,9 @@ from ..widgets import (
|
|||||||
show_toast,
|
show_toast,
|
||||||
)
|
)
|
||||||
|
|
||||||
_PAGE_HEADER_HEIGHT = 62
|
_STATUS_CARD_HEIGHT = 54
|
||||||
_STATUS_CARD_HEIGHT = 50
|
_FILTERS_BASE_HEIGHT = 236
|
||||||
_FILTERS_COLLAPSED_HEIGHT = 90
|
|
||||||
|
|
||||||
CONSULTATIONS_REFERENCE_QSS = """
|
|
||||||
#DiagnosisIndex QWidget#PageHeader { min-height: 62px; max-height: 62px; }
|
|
||||||
#DiagnosisIndex QLabel[role="pageTitle"] {
|
|
||||||
color: #15224A; font-size: 22px; font-weight: 700;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QLabel[role="muted"] { color: #7481A3; font-size: 12px; }
|
|
||||||
#DiagnosisIndex QLabel[role="breadcrumb"],
|
|
||||||
#DiagnosisIndex QLabel[role="breadcrumbCurrent"] { font-size: 12px; }
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisStatusCard,
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisFilterCard,
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisListCard {
|
|
||||||
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisStatusCard {
|
|
||||||
min-height: 50px; max-height: 50px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"] {
|
|
||||||
min-height: 34px; max-height: 34px; min-width: 56px;
|
|
||||||
padding: 0 8px; border: 0; border-radius: 8px;
|
|
||||||
background: transparent; color: #25345E; font-size: 13px; font-weight: 600;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"]:hover {
|
|
||||||
background: #F7F8FF; color: #5265F6;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"]:checked {
|
|
||||||
background: #F0F1FF; color: #5265F6;
|
|
||||||
border-bottom: 2px solid #6573F7;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit {
|
|
||||||
min-width: 78px; max-width: 138px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QDateEdit#DiagnosisCustomDate {
|
|
||||||
min-width: 116px; max-width: 116px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisFilterCard {
|
|
||||||
min-height: 88px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QWidget#DiagnosisDateFilters,
|
|
||||||
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters {
|
|
||||||
background: transparent; border: 0;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters QLineEdit#DiagnosisKeyword {
|
|
||||||
min-width: 260px; max-width: 380px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QComboBox#DiagnosisConfirmationFilter { min-width: 136px; max-width: 136px; }
|
|
||||||
#DiagnosisIndex QComboBox#DiagnosisDepartmentFilter { min-width: 210px; max-width: 230px; }
|
|
||||||
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
|
|
||||||
min-height: 20px; padding: 6px 12px; border-radius: 8px;
|
|
||||||
background: #F7F9FE; color: #405074; font-size: 12px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked {
|
|
||||||
background: #EEF1FF; color: #5265F6; border-color: #C9D0FF;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="primary"],
|
|
||||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="info"] {
|
|
||||||
background: #EEF1FF; color: #5265F6; border-color: #C9D0FF;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="success"] {
|
|
||||||
background: #EAF9F4; color: #159C79; border-color: #BFE9DC;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="warning"] {
|
|
||||||
background: #FFF5E4; color: #C17A16; border-color: #F2D49D;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"] {
|
|
||||||
min-height: 18px; padding: 4px 10px; font-size: 12px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QLineEdit,
|
|
||||||
#DiagnosisIndex QComboBox,
|
|
||||||
#DiagnosisIndex QDateEdit,
|
|
||||||
#DiagnosisIndex QSpinBox {
|
|
||||||
min-height: 32px; max-height: 32px; border-radius: 8px;
|
|
||||||
background: #FFFFFF; border-color: #E2E7F4; color: #15224A;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QPushButton {
|
|
||||||
min-height: 34px; max-height: 34px; padding: 0 14px;
|
|
||||||
border-radius: 8px; font-size: 12px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisListToolbar {
|
|
||||||
min-height: 44px; max-height: 44px; background: #FFFFFF;
|
|
||||||
border-bottom: 1px solid #E7EBF5;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton {
|
|
||||||
padding: 0 13px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton[consultationTool="true"] {
|
|
||||||
color: #5265F6; background: #F7F8FF; border: 1px solid #E0E5F8;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton[consultationDanger="true"] {
|
|
||||||
color: #F15B67; background: #FFF7F8; border: 1px solid #FFD6DB;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QFrame#DiagnosisListCard { border-radius: 12px; }
|
|
||||||
#DiagnosisIndex QHeaderView::section {
|
|
||||||
min-height: 38px; max-height: 38px; padding: 0 8px;
|
|
||||||
background: #F7F9FE; color: #7481A3;
|
|
||||||
border-bottom: 1px solid #E7EBF5; font-size: 12px;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QTableView { background: #FFFFFF; alternate-background-color: #FBFCFF; }
|
|
||||||
#DiagnosisIndex QToolButton[rowLink] { font-size: 11px; padding: 2px; }
|
|
||||||
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 42px; max-height: 42px; }
|
|
||||||
#DiagnosisIndex QToolButton[pagerButton="true"] {
|
|
||||||
min-width: 32px; min-height: 32px; max-height: 32px;
|
|
||||||
border: 1px solid #E2E7F4; border-radius: 7px; background: #FFFFFF;
|
|
||||||
}
|
|
||||||
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] {
|
|
||||||
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
APPOINTMENT_STATUS = {
|
APPOINTMENT_STATUS = {
|
||||||
1: ("已预约", "warning"),
|
1: ("已预约", "warning"),
|
||||||
@@ -829,8 +721,8 @@ class _QrImagePreview(QLabel):
|
|||||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.setWordWrap(True)
|
self.setWordWrap(True)
|
||||||
self.setStyleSheet(
|
self.setStyleSheet(
|
||||||
"QLabel#DiagnosisOrderQrPreview{background:#FFFFFF;border:1px solid #E1E6F2;"
|
"QLabel#DiagnosisOrderQrPreview{background:#FFFFFF;border:1px solid #EDEDEE;"
|
||||||
"border-radius:12px;color:#7481A3;padding:8px;}"
|
"border-radius:12px;color:#606163;padding:8px;}"
|
||||||
)
|
)
|
||||||
self._manager = QNetworkAccessManager(self)
|
self._manager = QNetworkAccessManager(self)
|
||||||
self._reply: QNetworkReply | None = None
|
self._reply: QNetworkReply | None = None
|
||||||
@@ -973,7 +865,7 @@ class _DiagnosisOrderQrDialog(QDialog):
|
|||||||
self.qrcode_url = ""
|
self.qrcode_url = ""
|
||||||
self.url_edit.clear()
|
self.url_edit.clear()
|
||||||
self.status_label.setText("正在生成付款二维码...")
|
self.status_label.setText("正在生成付款二维码...")
|
||||||
self.status_label.setStyleSheet("color:#7481A3;")
|
self.status_label.setStyleSheet("color:#606163;")
|
||||||
self.preview.show_loading()
|
self.preview.show_loading()
|
||||||
self.open_button.setEnabled(False)
|
self.open_button.setEnabled(False)
|
||||||
self.retry_button.setEnabled(False)
|
self.retry_button.setEnabled(False)
|
||||||
@@ -982,7 +874,7 @@ class _DiagnosisOrderQrDialog(QDialog):
|
|||||||
self.qrcode_url = ""
|
self.qrcode_url = ""
|
||||||
self.url_edit.clear()
|
self.url_edit.clear()
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
self.status_label.setStyleSheet("color:#EC5266;")
|
self.status_label.setStyleSheet("color:#BE4B58;")
|
||||||
self.preview.show_failure("付款二维码生成失败")
|
self.preview.show_failure("付款二维码生成失败")
|
||||||
self.open_button.setEnabled(False)
|
self.open_button.setEnabled(False)
|
||||||
self.retry_button.setEnabled(retryable)
|
self.retry_button.setEnabled(retryable)
|
||||||
@@ -993,7 +885,7 @@ class _DiagnosisOrderQrDialog(QDialog):
|
|||||||
self.url_edit.setCursorPosition(0)
|
self.url_edit.setCursorPosition(0)
|
||||||
self.url_edit.setToolTip(qrcode_url)
|
self.url_edit.setToolTip(qrcode_url)
|
||||||
self.status_label.setText("付款二维码已生成")
|
self.status_label.setText("付款二维码已生成")
|
||||||
self.status_label.setStyleSheet("color:#159C79;")
|
self.status_label.setStyleSheet("color:#287B65;")
|
||||||
self.preview.load_url(qrcode_url)
|
self.preview.load_url(qrcode_url)
|
||||||
self.open_button.setEnabled(True)
|
self.open_button.setEnabled(True)
|
||||||
self.retry_button.setEnabled(True)
|
self.retry_button.setEnabled(True)
|
||||||
@@ -1003,6 +895,19 @@ class _DiagnosisOrderQrDialog(QDialog):
|
|||||||
QDesktopServices.openUrl(QUrl.fromUserInput(self.qrcode_url))
|
QDesktopServices.openUrl(QUrl.fromUserInput(self.qrcode_url))
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_icon(button: QPushButton, kind: str, role: str = "soft") -> QPushButton:
|
||||||
|
"""Put a shared glyph on a toolbar button.
|
||||||
|
|
||||||
|
This page's toolbar was the only one in the product with no icons at all -
|
||||||
|
seven bare text buttons in a row - and it faked the one it did want with a
|
||||||
|
full-width "+" character in the label.
|
||||||
|
"""
|
||||||
|
|
||||||
|
button.setIcon(icons.icon(kind, role, 15))
|
||||||
|
button.setIconSize(QSize(15, 15))
|
||||||
|
return button
|
||||||
|
|
||||||
|
|
||||||
class ConsultationsPage(QWidget):
|
class ConsultationsPage(QWidget):
|
||||||
"""Diagnosis workspace with canonical filters and guarded row actions."""
|
"""Diagnosis workspace with canonical filters and guarded row actions."""
|
||||||
|
|
||||||
@@ -1017,7 +922,7 @@ class ConsultationsPage(QWidget):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("DiagnosisIndex")
|
self.setObjectName("DiagnosisIndex")
|
||||||
self.setStyleSheet(DIAGNOSIS_INDEX_QSS + CONSULTATIONS_REFERENCE_QSS)
|
self.setStyleSheet(consultations_stylesheet())
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
self.current_user = current_user
|
self.current_user = current_user
|
||||||
@@ -1076,15 +981,20 @@ class ConsultationsPage(QWidget):
|
|||||||
content.setAutoFillBackground(False)
|
content.setAutoFillBackground(False)
|
||||||
self.page_scroll.setWidget(content)
|
self.page_scroll.setWidget(content)
|
||||||
page_layout = QVBoxLayout(content)
|
page_layout = QVBoxLayout(content)
|
||||||
page_layout.setContentsMargins(18, 10, 18, 10)
|
page_layout.setContentsMargins(27, 24, 26, 8)
|
||||||
page_layout.setSpacing(8)
|
page_layout.setSpacing(10)
|
||||||
|
|
||||||
self.page_header = PageHeader(
|
self.page_header = PageHeader(
|
||||||
"问诊列表",
|
"问诊列表",
|
||||||
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
|
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
|
||||||
content,
|
content,
|
||||||
)
|
)
|
||||||
self.page_header.setFixedHeight(_PAGE_HEADER_HEIGHT)
|
self.page_header.setFixedHeight(90)
|
||||||
|
self.page_header.layout().setContentsMargins(0, 0, 0, 6)
|
||||||
|
self.page_header.layout().setSpacing(12)
|
||||||
|
self.page_header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(10)
|
||||||
|
self.page_header.actions.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
self.page_header.set_compact(True)
|
||||||
page_layout.addWidget(self.page_header)
|
page_layout.addWidget(self.page_header)
|
||||||
|
|
||||||
status_card = QFrame()
|
status_card = QFrame()
|
||||||
@@ -1092,8 +1002,11 @@ class ConsultationsPage(QWidget):
|
|||||||
status_card.setFixedHeight(_STATUS_CARD_HEIGHT)
|
status_card.setFixedHeight(_STATUS_CARD_HEIGHT)
|
||||||
self.status_card = status_card
|
self.status_card = status_card
|
||||||
status_card_layout = QHBoxLayout(status_card)
|
status_card_layout = QHBoxLayout(status_card)
|
||||||
status_card_layout.setContentsMargins(12, 5, 12, 5)
|
status_card_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
status_card_layout.setSpacing(10)
|
status_card_layout.setSpacing(8)
|
||||||
|
status_label = self._filter_label("问诊状态:")
|
||||||
|
status_label.setFixedWidth(80)
|
||||||
|
status_card_layout.addWidget(status_label)
|
||||||
|
|
||||||
status_tabs = QWidget(status_card)
|
status_tabs = QWidget(status_card)
|
||||||
status_tabs.setObjectName("DiagnosisStatusTabs")
|
status_tabs.setObjectName("DiagnosisStatusTabs")
|
||||||
@@ -1114,31 +1027,36 @@ class ConsultationsPage(QWidget):
|
|||||||
button.clicked.connect(
|
button.clicked.connect(
|
||||||
lambda _checked=False, selected=value: self._choose_status(selected)
|
lambda _checked=False, selected=value: self._choose_status(selected)
|
||||||
)
|
)
|
||||||
status_tabs_layout.addWidget(button, 1)
|
status_tabs_layout.addWidget(button)
|
||||||
self.status_buttons[value] = button
|
self.status_buttons[value] = button
|
||||||
self.completed_button = self.status_buttons["3"]
|
self.completed_button = self.status_buttons["3"]
|
||||||
status_card_layout.addWidget(status_tabs, 3)
|
status_card_layout.addWidget(status_tabs)
|
||||||
|
status_card_layout.addStretch(1)
|
||||||
|
|
||||||
status_search = QWidget(status_card)
|
status_search = QWidget(status_card)
|
||||||
status_search.setObjectName("DiagnosisStatusSearch")
|
status_search.setObjectName("DiagnosisStatusSearch")
|
||||||
|
status_search.setFixedWidth(518)
|
||||||
status_search_layout = QHBoxLayout(status_search)
|
status_search_layout = QHBoxLayout(status_search)
|
||||||
status_search_layout.setContentsMargins(0, 0, 0, 0)
|
status_search_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
status_search_layout.setSpacing(6)
|
status_search_layout.setSpacing(16)
|
||||||
self.patient_name_edit = QLineEdit(status_search)
|
self.patient_name_edit = QLineEdit(status_search)
|
||||||
self.patient_name_edit.setObjectName("DiagnosisPatientName")
|
self.patient_name_edit.setObjectName("DiagnosisPatientName")
|
||||||
|
self.patient_name_edit.setFixedWidth(172)
|
||||||
|
self.patient_name_edit.addAction(icons.icon("search", "muted", 16), QLineEdit.ActionPosition.LeadingPosition)
|
||||||
self.patient_name_edit.setPlaceholderText("患者姓名")
|
self.patient_name_edit.setPlaceholderText("患者姓名")
|
||||||
self.patient_name_edit.setClearButtonEnabled(True)
|
self.patient_name_edit.setClearButtonEnabled(True)
|
||||||
self.patient_name_edit.returnPressed.connect(self._search)
|
self.patient_name_edit.returnPressed.connect(self._search)
|
||||||
status_search_layout.addWidget(self.patient_name_edit, 1)
|
status_search_layout.addWidget(self.patient_name_edit, 1)
|
||||||
self.doctor_edit = QLineEdit(status_search)
|
self.doctor_edit = QLineEdit(status_search)
|
||||||
self.doctor_edit.setObjectName("DiagnosisDoctorName")
|
self.doctor_edit.setObjectName("DiagnosisDoctorName")
|
||||||
|
self.doctor_edit.setFixedWidth(153)
|
||||||
self.doctor_edit.setPlaceholderText("医生")
|
self.doctor_edit.setPlaceholderText("医生")
|
||||||
self.doctor_edit.setClearButtonEnabled(True)
|
self.doctor_edit.setClearButtonEnabled(True)
|
||||||
self.doctor_edit.returnPressed.connect(self._search)
|
self.doctor_edit.returnPressed.connect(self._search)
|
||||||
status_search_layout.addWidget(self.doctor_edit, 1)
|
status_search_layout.addWidget(self.doctor_edit, 1)
|
||||||
self.search_button = QPushButton("查询", status_search)
|
self.search_button = QPushButton("查询", status_search)
|
||||||
self.search_button.setProperty("variant", "primary")
|
self.search_button.setProperty("variant", "primary")
|
||||||
self.search_button.setFixedWidth(62)
|
self.search_button.setFixedWidth(73)
|
||||||
self.search_button.clicked.connect(self._search)
|
self.search_button.clicked.connect(self._search)
|
||||||
status_search_layout.addWidget(self.search_button)
|
status_search_layout.addWidget(self.search_button)
|
||||||
self.custom_date_edit = QDateEdit(status_search)
|
self.custom_date_edit = QDateEdit(status_search)
|
||||||
@@ -1150,29 +1068,38 @@ class ConsultationsPage(QWidget):
|
|||||||
self.custom_date_edit.setDate(_OPTIONAL_DATE_MINIMUM)
|
self.custom_date_edit.setDate(_OPTIONAL_DATE_MINIMUM)
|
||||||
self.custom_date_edit.setToolTip("自定义挂号日期")
|
self.custom_date_edit.setToolTip("自定义挂号日期")
|
||||||
self.custom_date_edit.dateChanged.connect(self._custom_date_changed)
|
self.custom_date_edit.dateChanged.connect(self._custom_date_changed)
|
||||||
status_search_layout.addWidget(self.custom_date_edit)
|
self.custom_date_edit.setFixedWidth(202)
|
||||||
self.reset_button = QPushButton("重置", status_search)
|
self.reset_button = QPushButton("重置", status_search)
|
||||||
self.reset_button.setFixedWidth(58)
|
self.reset_button.setFixedWidth(72)
|
||||||
self.reset_button.clicked.connect(self._reset)
|
self.reset_button.clicked.connect(self._reset)
|
||||||
status_search_layout.addWidget(self.reset_button)
|
status_search_layout.addWidget(self.reset_button)
|
||||||
status_card_layout.addWidget(status_search, 2)
|
|
||||||
page_layout.addWidget(status_card)
|
|
||||||
|
|
||||||
filters = QFrame()
|
filters = QFrame()
|
||||||
filters.setObjectName("DiagnosisFilterCard")
|
filters.setObjectName("DiagnosisFilterCard")
|
||||||
self.filters_card = filters
|
self.filters_card = filters
|
||||||
filter_layout = QVBoxLayout(filters)
|
filter_layout = QVBoxLayout(filters)
|
||||||
filter_layout.setContentsMargins(12, 6, 12, 6)
|
filter_layout.setContentsMargins(18, 8, 18, 12)
|
||||||
filter_layout.setSpacing(4)
|
filter_layout.setSpacing(8)
|
||||||
|
search_row = QHBoxLayout()
|
||||||
|
search_row.setContentsMargins(0, 0, 0, 0)
|
||||||
|
search_row.addStretch(1)
|
||||||
|
search_row.addWidget(status_search)
|
||||||
|
status_search.setFixedHeight(44)
|
||||||
|
filter_layout.addLayout(search_row)
|
||||||
|
filter_layout.addWidget(status_card)
|
||||||
|
|
||||||
date_filters = QWidget(filters)
|
date_filters = QWidget(filters)
|
||||||
date_filters.setObjectName("DiagnosisDateFilters")
|
date_filters.setObjectName("DiagnosisDateFilters")
|
||||||
|
date_filters.setMinimumHeight(48)
|
||||||
|
self.date_filters = date_filters
|
||||||
main_filters = QHBoxLayout(date_filters)
|
main_filters = QHBoxLayout(date_filters)
|
||||||
main_filters.setContentsMargins(0, 0, 0, 0)
|
main_filters.setContentsMargins(0, 0, 0, 0)
|
||||||
main_filters.setSpacing(8)
|
main_filters.setSpacing(8)
|
||||||
main_filters.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
main_filters.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||||
main_filters.addWidget(self._filter_label("日期:"))
|
date_label = self._filter_label("日期:")
|
||||||
self.main_chip_flow = FlowWidget(horizontal_spacing=6, vertical_spacing=6)
|
date_label.setFixedWidth(80)
|
||||||
|
main_filters.addWidget(date_label)
|
||||||
|
self.main_chip_flow = FlowWidget(horizontal_spacing=24, vertical_spacing=6)
|
||||||
self.date_buttons: dict[str, DiagnosisChip] = {}
|
self.date_buttons: dict[str, DiagnosisChip] = {}
|
||||||
self._date_button_labels: dict[str, str] = {}
|
self._date_button_labels: dict[str, str] = {}
|
||||||
for label, offset in (
|
for label, offset in (
|
||||||
@@ -1184,6 +1111,7 @@ class ConsultationsPage(QWidget):
|
|||||||
):
|
):
|
||||||
value = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
|
value = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
|
||||||
button = DiagnosisChip(label)
|
button = DiagnosisChip(label)
|
||||||
|
button.setProperty("dateChoice", True)
|
||||||
button.clicked.connect(
|
button.clicked.connect(
|
||||||
lambda _checked=False, date_value=value: self._choose_date(date_value)
|
lambda _checked=False, date_value=value: self._choose_date(date_value)
|
||||||
)
|
)
|
||||||
@@ -1191,6 +1119,7 @@ class ConsultationsPage(QWidget):
|
|||||||
self._date_button_labels[value] = label
|
self._date_button_labels[value] = label
|
||||||
self.main_chip_flow.flow.addWidget(button)
|
self.main_chip_flow.flow.addWidget(button)
|
||||||
all_button = DiagnosisChip("全部")
|
all_button = DiagnosisChip("全部")
|
||||||
|
all_button.setProperty("dateChoice", True)
|
||||||
all_button.clicked.connect(lambda _checked=False: self._choose_date(""))
|
all_button.clicked.connect(lambda _checked=False: self._choose_date(""))
|
||||||
self.date_buttons[""] = all_button
|
self.date_buttons[""] = all_button
|
||||||
self._date_button_labels[""] = "全部"
|
self._date_button_labels[""] = "全部"
|
||||||
@@ -1229,6 +1158,7 @@ class ConsultationsPage(QWidget):
|
|||||||
self.pending_assign_filters.hide()
|
self.pending_assign_filters.hide()
|
||||||
self.pending_assign_wrap.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
|
self.pending_assign_wrap.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
|
||||||
main_filters.addWidget(self.main_chip_flow, 1)
|
main_filters.addWidget(self.main_chip_flow, 1)
|
||||||
|
main_filters.addWidget(self.custom_date_edit)
|
||||||
filter_layout.addWidget(date_filters)
|
filter_layout.addWidget(date_filters)
|
||||||
|
|
||||||
self.has_appointment_combo = self._fixed_combo(
|
self.has_appointment_combo = self._fixed_combo(
|
||||||
@@ -1261,26 +1191,39 @@ class ConsultationsPage(QWidget):
|
|||||||
|
|
||||||
secondary_filters = QWidget(filters)
|
secondary_filters = QWidget(filters)
|
||||||
secondary_filters.setObjectName("DiagnosisSecondaryFilters")
|
secondary_filters.setObjectName("DiagnosisSecondaryFilters")
|
||||||
|
secondary_filters.setMinimumHeight(46)
|
||||||
|
self.secondary_filters = secondary_filters
|
||||||
secondary_layout = QHBoxLayout(secondary_filters)
|
secondary_layout = QHBoxLayout(secondary_filters)
|
||||||
secondary_layout.setContentsMargins(0, 0, 0, 0)
|
secondary_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
secondary_layout.setSpacing(8)
|
secondary_layout.setSpacing(8)
|
||||||
secondary_layout.addWidget(self._filter_label("确认诊单:"))
|
self.diagnosis_confirmed_combo.setFixedWidth(126)
|
||||||
secondary_layout.addWidget(self.diagnosis_confirmed_combo)
|
self.department_combo.setFixedWidth(178)
|
||||||
secondary_layout.addWidget(self._filter_label("部门:"))
|
self.secondary_filter_flow = FlowWidget(horizontal_spacing=40, vertical_spacing=8)
|
||||||
secondary_layout.addWidget(self.department_combo)
|
self.secondary_filter_flow.flow.addWidget(
|
||||||
|
self._filter_field("确认诊单:", self.diagnosis_confirmed_combo)
|
||||||
|
)
|
||||||
|
self.secondary_filter_flow.flow.addWidget(
|
||||||
|
self._filter_field("部门:", self.department_combo)
|
||||||
|
)
|
||||||
self.keyword_edit = QLineEdit(secondary_filters)
|
self.keyword_edit = QLineEdit(secondary_filters)
|
||||||
self.keyword_edit.setObjectName("DiagnosisKeyword")
|
self.keyword_edit.setObjectName("DiagnosisKeyword")
|
||||||
self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号")
|
self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号")
|
||||||
self.keyword_edit.setClearButtonEnabled(True)
|
self.keyword_edit.setClearButtonEnabled(True)
|
||||||
self.keyword_edit.setMinimumWidth(240)
|
self.keyword_edit.addAction(icons.icon("search", "muted", 16), QLineEdit.ActionPosition.LeadingPosition)
|
||||||
self.keyword_edit.setMaximumWidth(380)
|
self.keyword_edit.setMinimumWidth(185)
|
||||||
|
self.keyword_edit.setMaximumWidth(340)
|
||||||
self.keyword_edit.returnPressed.connect(self._search)
|
self.keyword_edit.returnPressed.connect(self._search)
|
||||||
secondary_layout.addWidget(self.keyword_edit, 1)
|
self.keyword_edit.setFixedWidth(340)
|
||||||
secondary_layout.addStretch(1)
|
self.secondary_filter_flow.flow.addWidget(self.keyword_edit)
|
||||||
|
secondary_layout.addWidget(self.secondary_filter_flow, 1)
|
||||||
self.more_filter_button = QToolButton(secondary_filters)
|
self.more_filter_button = QToolButton(secondary_filters)
|
||||||
self.more_filter_button.setObjectName("DiagnosisMoreFilter")
|
self.more_filter_button.setObjectName("DiagnosisMoreFilter")
|
||||||
self.more_filter_button.setText("更多筛选")
|
self.more_filter_button.setText("更多筛选")
|
||||||
self.more_filter_button.setArrowType(Qt.ArrowType.DownArrow)
|
# The same Fusion solid triangle the row overflow used - a filled mark in
|
||||||
|
# an all-stroke set. A disclosure caret does belong before its label, so
|
||||||
|
# only the glyph changes here, not the side.
|
||||||
|
self.more_filter_button.setIcon(icons.icon("down", "muted", 14))
|
||||||
|
self.more_filter_button.setIconSize(QSize(14, 14))
|
||||||
self.more_filter_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
self.more_filter_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
||||||
self.more_filter_button.setCheckable(True)
|
self.more_filter_button.setCheckable(True)
|
||||||
self.more_filter_button.clicked.connect(self._toggle_advanced_filters)
|
self.more_filter_button.clicked.connect(self._toggle_advanced_filters)
|
||||||
@@ -1367,8 +1310,10 @@ class ConsultationsPage(QWidget):
|
|||||||
advanced_layout.addWidget(self.advanced_filter_flow)
|
advanced_layout.addWidget(self.advanced_filter_flow)
|
||||||
self.advanced_filters.hide()
|
self.advanced_filters.hide()
|
||||||
filter_layout.addWidget(self.advanced_filters)
|
filter_layout.addWidget(self.advanced_filters)
|
||||||
filters.setFixedHeight(_FILTERS_COLLAPSED_HEIGHT)
|
filters.setFixedHeight(_FILTERS_BASE_HEIGHT)
|
||||||
page_layout.addWidget(filters)
|
page_layout.addWidget(filters)
|
||||||
|
self.filter_disclosure = FilterDisclosure(self, [filters])
|
||||||
|
self.page_header.add_action(self.filter_disclosure.button)
|
||||||
|
|
||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setObjectName("DiagnosisListCard")
|
card.setObjectName("DiagnosisListCard")
|
||||||
@@ -1380,14 +1325,17 @@ class ConsultationsPage(QWidget):
|
|||||||
toolbar = QFrame()
|
toolbar = QFrame()
|
||||||
toolbar.setObjectName("DiagnosisListToolbar")
|
toolbar.setObjectName("DiagnosisListToolbar")
|
||||||
toolbar_layout = QHBoxLayout(toolbar)
|
toolbar_layout = QHBoxLayout(toolbar)
|
||||||
toolbar_layout.setContentsMargins(12, 4, 12, 4)
|
toolbar_layout.setContentsMargins(18, 12, 18, 12)
|
||||||
toolbar_layout.setSpacing(6)
|
toolbar_layout.setSpacing(8)
|
||||||
self.add_button = QPushButton("+ 新增患者", toolbar)
|
self.list_toolbar = toolbar
|
||||||
|
self.add_button = _tool_icon(QPushButton("新增患者", toolbar), "plus", "inverse")
|
||||||
self.add_button.setProperty("variant", "primary")
|
self.add_button.setProperty("variant", "primary")
|
||||||
self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add"))
|
self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add"))
|
||||||
self.add_button.clicked.connect(self._add_diagnosis)
|
self.add_button.clicked.connect(self._add_diagnosis)
|
||||||
toolbar_layout.addWidget(self.add_button)
|
toolbar_layout.addWidget(self.add_button)
|
||||||
self.video_qr_toolbar_button = QPushButton("视频二维码", toolbar)
|
self.video_qr_toolbar_button = _tool_icon(
|
||||||
|
QPushButton("视频二维码", toolbar), "qr"
|
||||||
|
)
|
||||||
self.video_qr_toolbar_button.setProperty("consultationTool", True)
|
self.video_qr_toolbar_button.setProperty("consultationTool", True)
|
||||||
self.video_qr_toolbar_button.setVisible(
|
self.video_qr_toolbar_button.setVisible(
|
||||||
bool(
|
bool(
|
||||||
@@ -1397,28 +1345,34 @@ class ConsultationsPage(QWidget):
|
|||||||
)
|
)
|
||||||
self.video_qr_toolbar_button.clicked.connect(self._request_video_qr)
|
self.video_qr_toolbar_button.clicked.connect(self._request_video_qr)
|
||||||
toolbar_layout.addWidget(self.video_qr_toolbar_button)
|
toolbar_layout.addWidget(self.video_qr_toolbar_button)
|
||||||
self.call_toolbar_button = QPushButton("通话", toolbar)
|
self.call_toolbar_button = _tool_icon(QPushButton("通话", toolbar), "video")
|
||||||
self.call_toolbar_button.setProperty("consultationTool", True)
|
self.call_toolbar_button.setProperty("consultationTool", True)
|
||||||
self.call_toolbar_button.setVisible(self._native_video_capable)
|
self.call_toolbar_button.setVisible(self._native_video_capable)
|
||||||
self.call_toolbar_button.clicked.connect(self._request_video)
|
self.call_toolbar_button.clicked.connect(self._request_video)
|
||||||
toolbar_layout.addWidget(self.call_toolbar_button)
|
toolbar_layout.addWidget(self.call_toolbar_button)
|
||||||
self.complete_toolbar_button = QPushButton("完成", toolbar)
|
self.complete_toolbar_button = _tool_icon(
|
||||||
|
QPushButton("完成", toolbar), "check_circle"
|
||||||
|
)
|
||||||
self.complete_toolbar_button.setProperty("consultationTool", True)
|
self.complete_toolbar_button.setProperty("consultationTool", True)
|
||||||
self.complete_toolbar_button.setToolTip("查看当前问诊详情并完成问诊")
|
self.complete_toolbar_button.setToolTip("查看当前问诊详情并完成问诊")
|
||||||
self.complete_toolbar_button.clicked.connect(self._open_readonly)
|
self.complete_toolbar_button.clicked.connect(self._open_readonly)
|
||||||
toolbar_layout.addWidget(self.complete_toolbar_button)
|
toolbar_layout.addWidget(self.complete_toolbar_button)
|
||||||
self.prescription_toolbar_button = QPushButton("开方", toolbar)
|
self.prescription_toolbar_button = _tool_icon(
|
||||||
|
QPushButton("开方", toolbar), "prescriptions"
|
||||||
|
)
|
||||||
self.prescription_toolbar_button.setProperty("consultationTool", True)
|
self.prescription_toolbar_button.setProperty("consultationTool", True)
|
||||||
self.prescription_toolbar_button.setVisible(
|
self.prescription_toolbar_button.setVisible(
|
||||||
_canonical_allowed(permissions, "tcm.diagnosis/kaifang")
|
_canonical_allowed(permissions, "tcm.diagnosis/kaifang")
|
||||||
)
|
)
|
||||||
self.prescription_toolbar_button.clicked.connect(self._open_prescription)
|
self.prescription_toolbar_button.clicked.connect(self._open_prescription)
|
||||||
toolbar_layout.addWidget(self.prescription_toolbar_button)
|
toolbar_layout.addWidget(self.prescription_toolbar_button)
|
||||||
self.case_toolbar_button = QPushButton("病历", toolbar)
|
self.case_toolbar_button = _tool_icon(QPushButton("病历", toolbar), "document")
|
||||||
self.case_toolbar_button.setProperty("consultationTool", True)
|
self.case_toolbar_button.setProperty("consultationTool", True)
|
||||||
self.case_toolbar_button.clicked.connect(self._open_readonly)
|
self.case_toolbar_button.clicked.connect(self._open_readonly)
|
||||||
toolbar_layout.addWidget(self.case_toolbar_button)
|
toolbar_layout.addWidget(self.case_toolbar_button)
|
||||||
self.cancel_toolbar_button = QPushButton("取消挂号", toolbar)
|
self.cancel_toolbar_button = _tool_icon(
|
||||||
|
QPushButton("取消挂号", toolbar), "stop", "danger"
|
||||||
|
)
|
||||||
self.cancel_toolbar_button.setProperty("consultationDanger", True)
|
self.cancel_toolbar_button.setProperty("consultationDanger", True)
|
||||||
self.cancel_toolbar_button.setVisible(
|
self.cancel_toolbar_button.setVisible(
|
||||||
bool(
|
bool(
|
||||||
@@ -1428,7 +1382,9 @@ class ConsultationsPage(QWidget):
|
|||||||
)
|
)
|
||||||
self.cancel_toolbar_button.clicked.connect(self._cancel_selected_appointment)
|
self.cancel_toolbar_button.clicked.connect(self._cancel_selected_appointment)
|
||||||
toolbar_layout.addWidget(self.cancel_toolbar_button)
|
toolbar_layout.addWidget(self.cancel_toolbar_button)
|
||||||
self.batch_assign_button = QPushButton("批量指派医助", toolbar)
|
self.batch_assign_button = _tool_icon(
|
||||||
|
QPushButton("批量指派医助", toolbar), "users", "inverse"
|
||||||
|
)
|
||||||
self.batch_assign_button.setProperty("variant", "success")
|
self.batch_assign_button.setProperty("variant", "success")
|
||||||
self.batch_assign_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
|
self.batch_assign_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
|
||||||
self.batch_assign_button.clicked.connect(self._batch_assign)
|
self.batch_assign_button.clicked.connect(self._batch_assign)
|
||||||
@@ -1477,16 +1433,29 @@ class ConsultationsPage(QWidget):
|
|||||||
self.delete_button = QPushButton("删除", self._compat_actions)
|
self.delete_button = QPushButton("删除", self._compat_actions)
|
||||||
self.delete_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/delete"))
|
self.delete_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/delete"))
|
||||||
self.delete_button.clicked.connect(self._delete_selected)
|
self.delete_button.clicked.connect(self._delete_selected)
|
||||||
self.refresh_button = QPushButton("刷新", toolbar)
|
self.refresh_button = _tool_icon(QPushButton("刷新", toolbar), "refresh")
|
||||||
self.refresh_button.clicked.connect(lambda: self.refresh())
|
self.refresh_button.clicked.connect(lambda: self.refresh())
|
||||||
toolbar_layout.addWidget(self.refresh_button)
|
toolbar_layout.addWidget(self.refresh_button)
|
||||||
|
self.toolbar_flow = FlowWidget(horizontal_spacing=8, vertical_spacing=8)
|
||||||
|
for index in reversed(range(toolbar_layout.count())):
|
||||||
|
if toolbar_layout.itemAt(index).spacerItem() is not None:
|
||||||
|
toolbar_layout.takeAt(index)
|
||||||
|
for button in (
|
||||||
|
self.add_button, self.video_qr_toolbar_button, self.call_toolbar_button,
|
||||||
|
self.complete_toolbar_button, self.prescription_toolbar_button,
|
||||||
|
self.case_toolbar_button, self.cancel_toolbar_button,
|
||||||
|
):
|
||||||
|
toolbar_layout.removeWidget(button)
|
||||||
|
self.toolbar_flow.flow.addWidget(button)
|
||||||
|
toolbar_layout.insertWidget(0, self.toolbar_flow, 1)
|
||||||
|
|
||||||
table_wrap = QWidget()
|
table_wrap = QWidget()
|
||||||
table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
table_wrap_layout = QVBoxLayout(table_wrap)
|
table_wrap_layout = QVBoxLayout(table_wrap)
|
||||||
table_wrap_layout.setContentsMargins(12, 4, 12, 0)
|
table_wrap_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
table_wrap_layout.setSpacing(0)
|
table_wrap_layout.setSpacing(0)
|
||||||
self.table_host = DiagnosisTableHost(
|
self.table_host = DiagnosisTableHost(
|
||||||
|
tech_blue=True,
|
||||||
action_policy={
|
action_policy={
|
||||||
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
|
||||||
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
|
||||||
@@ -1520,7 +1489,6 @@ class ConsultationsPage(QWidget):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.table = self.table_host.main
|
self.table = self.table_host.main
|
||||||
self.table.horizontalHeader().setSectionResizeMode(9, QHeaderView.ResizeMode.Stretch)
|
|
||||||
# The shared host's gradient is useful on dark pages but renders as a
|
# The shared host's gradient is useful on dark pages but renders as a
|
||||||
# heavy black strip in this light reference layout.
|
# heavy black strip in this light reference layout.
|
||||||
self.table_host.fixed_shadow.hide()
|
self.table_host.fixed_shadow.hide()
|
||||||
@@ -1534,9 +1502,8 @@ class ConsultationsPage(QWidget):
|
|||||||
table_wrap_layout.addWidget(self.table_host, 1)
|
table_wrap_layout.addWidget(self.table_host, 1)
|
||||||
self.loading_overlay = DiagnosisLoadingOverlay(self.table_host)
|
self.loading_overlay = DiagnosisLoadingOverlay(self.table_host)
|
||||||
card_layout.addWidget(table_wrap, 1)
|
card_layout.addWidget(table_wrap, 1)
|
||||||
self.pager = DiagnosisPager(self._page_size)
|
self.pager = InfiniteList(self._page_size)
|
||||||
self.pager.page_changed.connect(self._change_page)
|
self.pager.bind(self.table)
|
||||||
self.pager.page_size_changed.connect(self._change_page_size)
|
|
||||||
card_layout.addWidget(self.pager)
|
card_layout.addWidget(self.pager)
|
||||||
page_layout.addWidget(card, 1)
|
page_layout.addWidget(card, 1)
|
||||||
|
|
||||||
@@ -1586,7 +1553,9 @@ class ConsultationsPage(QWidget):
|
|||||||
layout = QHBoxLayout(host)
|
layout = QHBoxLayout(host)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(5)
|
layout.setSpacing(5)
|
||||||
layout.addWidget(self._filter_label(label_text))
|
label = self._filter_label(label_text)
|
||||||
|
label.setMinimumWidth(85 if label_text == "确认诊单:" else 46)
|
||||||
|
layout.addWidget(label)
|
||||||
layout.addWidget(control)
|
layout.addWidget(control)
|
||||||
return host
|
return host
|
||||||
|
|
||||||
@@ -1706,17 +1675,53 @@ class ConsultationsPage(QWidget):
|
|||||||
|
|
||||||
def _toggle_advanced_filters(self, checked: bool) -> None:
|
def _toggle_advanced_filters(self, checked: bool) -> None:
|
||||||
self.advanced_filters.setVisible(checked)
|
self.advanced_filters.setVisible(checked)
|
||||||
self.filters_card.setFixedHeight(
|
|
||||||
_FILTERS_COLLAPSED_HEIGHT + self.advanced_filters.sizeHint().height() + 8
|
|
||||||
if checked
|
|
||||||
else _FILTERS_COLLAPSED_HEIGHT
|
|
||||||
)
|
|
||||||
self.more_filter_button.setText("收起" if checked else "更多筛选")
|
self.more_filter_button.setText("收起" if checked else "更多筛选")
|
||||||
self.more_filter_button.setArrowType(
|
self.more_filter_button.setIcon(
|
||||||
Qt.ArrowType.UpArrow if checked else Qt.ArrowType.DownArrow
|
icons.icon("up" if checked else "down", "muted", 14)
|
||||||
)
|
)
|
||||||
self.main_chip_flow.updateGeometry()
|
self.main_chip_flow.updateGeometry()
|
||||||
self.quick_filter_flow.updateGeometry()
|
self.quick_filter_flow.updateGeometry()
|
||||||
|
self._apply_responsive_layout()
|
||||||
|
|
||||||
|
def resizeEvent(self, event: Any) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
|
if hasattr(self, "pager"):
|
||||||
|
self._apply_responsive_layout()
|
||||||
|
|
||||||
|
def _apply_responsive_layout(self) -> None:
|
||||||
|
"""Retain reference geometry while letting dense filters wrap on small windows."""
|
||||||
|
compact = self.height() < 800
|
||||||
|
narrow = self.width() < 1050
|
||||||
|
layout = self.page_scroll.widget().layout()
|
||||||
|
layout.setContentsMargins(20 if narrow else 27, 16 if compact else 24,
|
||||||
|
20 if narrow else 26, 8)
|
||||||
|
self.diagnosis_confirmed_combo.setFixedWidth(100 if narrow else 154)
|
||||||
|
self.department_combo.setFixedWidth(135 if narrow else 201)
|
||||||
|
self.keyword_edit.setFixedWidth(225 if narrow else 354)
|
||||||
|
self.secondary_filter_flow.flow._horizontal_spacing = 16 if narrow else 40
|
||||||
|
self.status_card.setFixedHeight(48 if compact else _STATUS_CARD_HEIGHT)
|
||||||
|
self.date_filters.setMinimumHeight(40 if compact else 48)
|
||||||
|
self.filters_card.layout().setSpacing(5 if compact else 8)
|
||||||
|
self.filters_card.layout().setContentsMargins(18, 6 if compact else 8,
|
||||||
|
18, 8 if compact else 12)
|
||||||
|
# Reserve the page scrollbar even before Qt has resolved its visibility.
|
||||||
|
# Otherwise the last field can wrap only after the height was measured.
|
||||||
|
usable = max(320, self.width() - (40 if narrow else 53) - 45)
|
||||||
|
date_height = max(40 if compact else 48,
|
||||||
|
self.main_chip_flow.flow.heightForWidth(usable - 298))
|
||||||
|
secondary_height = max(46, self.secondary_filter_flow.flow.heightForWidth(usable - 105))
|
||||||
|
self.date_filters.setFixedHeight(date_height)
|
||||||
|
self.secondary_filters.setFixedHeight(secondary_height)
|
||||||
|
filter_height = (44 + (5 if compact else 8)
|
||||||
|
+ (48 if compact else 54) + date_height + secondary_height
|
||||||
|
+ (24 if compact else 36))
|
||||||
|
if not self.advanced_filters.isHidden():
|
||||||
|
advanced_height = (self.quick_filter_flow.flow.heightForWidth(usable)
|
||||||
|
+ self.advanced_filter_flow.flow.heightForWidth(usable) + 25)
|
||||||
|
filter_height += advanced_height
|
||||||
|
self.filters_card.setFixedHeight(filter_height)
|
||||||
|
action_width = max(200, usable - self.refresh_button.sizeHint().width() - 8)
|
||||||
|
self.list_toolbar.setFixedHeight(self.toolbar_flow.flow.heightForWidth(action_width) + 24)
|
||||||
|
|
||||||
def _server_sort_changed(self, _index: int) -> None:
|
def _server_sort_changed(self, _index: int) -> None:
|
||||||
if not hasattr(self, "table_host"):
|
if not hasattr(self, "table_host"):
|
||||||
@@ -1812,6 +1817,8 @@ class ConsultationsPage(QWidget):
|
|||||||
self.pending_assign_filters.setVisible(bool(self._pending_assign))
|
self.pending_assign_filters.setVisible(bool(self._pending_assign))
|
||||||
for flow in (self.main_chip_flow, self.quick_filter_flow):
|
for flow in (self.main_chip_flow, self.quick_filter_flow):
|
||||||
flow.updateGeometry()
|
flow.updateGeometry()
|
||||||
|
if hasattr(self, "pager") and self.isVisible():
|
||||||
|
self._apply_responsive_layout()
|
||||||
|
|
||||||
def _clear_latest_appointment_filters(self) -> None:
|
def _clear_latest_appointment_filters(self) -> None:
|
||||||
self.latest_appointment_start_date.setDate(_OPTIONAL_DATE_MINIMUM)
|
self.latest_appointment_start_date.setDate(_OPTIONAL_DATE_MINIMUM)
|
||||||
@@ -1906,17 +1913,6 @@ class ConsultationsPage(QWidget):
|
|||||||
self._page = 1
|
self._page = 1
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def _change_page(self, page: int) -> None:
|
|
||||||
self._page = page
|
|
||||||
self.refresh(silent=True)
|
|
||||||
|
|
||||||
def _change_page_size(self, page_size: int) -> None:
|
|
||||||
if page_size not in {15, 20, 30, 40}:
|
|
||||||
return
|
|
||||||
self._page_size = page_size
|
|
||||||
self._page = 1
|
|
||||||
self.refresh(silent=True)
|
|
||||||
|
|
||||||
def _shared_filters(self) -> dict[str, Any]:
|
def _shared_filters(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"keyword": self.keyword_edit.text().strip(),
|
"keyword": self.keyword_edit.text().strip(),
|
||||||
@@ -1978,7 +1974,7 @@ class ConsultationsPage(QWidget):
|
|||||||
self._loading = True
|
self._loading = True
|
||||||
self.refresh_button.setEnabled(False)
|
self.refresh_button.setEnabled(False)
|
||||||
filters = MappingProxyType(self._filters())
|
filters = MappingProxyType(self._filters())
|
||||||
page = self._page
|
page_size = self._page_size
|
||||||
if not silent:
|
if not silent:
|
||||||
self._visible_loading_generation = generation
|
self._visible_loading_generation = generation
|
||||||
self.table_host.begin_loading()
|
self.table_host.begin_loading()
|
||||||
@@ -1986,16 +1982,18 @@ class ConsultationsPage(QWidget):
|
|||||||
self._refresh_counts()
|
self._refresh_counts()
|
||||||
elif self.loading_overlay.isVisible():
|
elif self.loading_overlay.isVisible():
|
||||||
self._visible_loading_generation = generation
|
self._visible_loading_generation = generation
|
||||||
run_async(
|
self.pager.reload(
|
||||||
lambda: invoke(
|
lambda page: invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"consultations",
|
"consultations",
|
||||||
**filters,
|
**filters,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=self._page_size,
|
page_size=page_size,
|
||||||
),
|
),
|
||||||
on_success=lambda result: self._apply_result(result, generation),
|
apply=lambda result: self._apply_result(result, generation),
|
||||||
on_error=lambda error: self._load_error(error, generation),
|
on_error=lambda error: self._load_error(error, generation),
|
||||||
|
runner=run_async,
|
||||||
|
query_key=filters,
|
||||||
on_finished=lambda: self._load_finished(generation),
|
on_finished=lambda: self._load_finished(generation),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2004,16 +2002,12 @@ class ConsultationsPage(QWidget):
|
|||||||
return
|
return
|
||||||
rows = page_items(result)
|
rows = page_items(result)
|
||||||
total = page_total(result, len(rows))
|
total = page_total(result, len(rows))
|
||||||
last_page = max(1, (total + self._page_size - 1) // self._page_size)
|
self._page = self.pager.page
|
||||||
if self._page > last_page:
|
|
||||||
self._page = last_page
|
|
||||||
self.refresh(silent=True)
|
|
||||||
return
|
|
||||||
self.table_host.force_open_prescription = bool(self._pending_assign)
|
self.table_host.force_open_prescription = bool(self._pending_assign)
|
||||||
self.table_host.set_rows(rows)
|
self.table_host.set_rows(rows)
|
||||||
self.pager.update_state(self._page, total)
|
self.pager.update_state(self._page, total)
|
||||||
self.summary_label.setText(
|
self.summary_label.setText(
|
||||||
f"共 {total} 条 · 第 {self._page} 页 · 每页 {self._page_size} 条"
|
f"共 {total} 条 · 已加载 {len(rows)} 条"
|
||||||
)
|
)
|
||||||
self.banner.clear()
|
self.banner.clear()
|
||||||
self._selection_changed()
|
self._selection_changed()
|
||||||
@@ -2958,6 +2952,14 @@ class ConsultationsPage(QWidget):
|
|||||||
and video_call_is_live(record)
|
and video_call_is_live(record)
|
||||||
and valid_ids
|
and valid_ids
|
||||||
)
|
)
|
||||||
|
self.call_toolbar_button.setEnabled(self.video_button.isEnabled())
|
||||||
|
self.video_qr_toolbar_button.setEnabled(has_record)
|
||||||
|
self.complete_toolbar_button.setEnabled(has_record)
|
||||||
|
self.case_toolbar_button.setEnabled(has_record)
|
||||||
|
self.prescription_toolbar_button.setEnabled(has_record and not self._prescription_busy)
|
||||||
|
self.cancel_toolbar_button.setEnabled(
|
||||||
|
has_record and _single_cancellable_appointment(record) is not None
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _diagnosis_dialog(self) -> DiagnosisDialog:
|
def _diagnosis_dialog(self) -> DiagnosisDialog:
|
||||||
@@ -3412,7 +3414,7 @@ class ConsultationsPage(QWidget):
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
not self.isVisible()
|
not self.isVisible()
|
||||||
or self._loading
|
or self.pager.loading
|
||||||
or self._order_flow_generation is not None
|
or self._order_flow_generation is not None
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,29 +8,38 @@ from typing import Any
|
|||||||
from PySide6.QtCore import QSize, Qt
|
from PySide6.QtCore import QSize, Qt
|
||||||
from PySide6.QtGui import QColor, QFont
|
from PySide6.QtGui import QColor, QFont
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
|
||||||
QDialog,
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
QStackedWidget,
|
QStackedWidget,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .. import motion
|
||||||
from ..dialogs.prescription import PrescriptionTemplateDialog
|
from ..dialogs.prescription import PrescriptionTemplateDialog
|
||||||
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
|
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
|
||||||
|
from ..filter_disclosure import FilterDisclosure
|
||||||
|
from ..infinite_list import InfiniteList
|
||||||
|
from ..prescription_library_style import (
|
||||||
|
LibraryComboBox,
|
||||||
|
LibraryItemDelegate,
|
||||||
|
LibraryTable,
|
||||||
|
library_stylesheet,
|
||||||
|
)
|
||||||
|
from ..reception_style import body_family
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
BusinessPager,
|
|
||||||
EmptyState,
|
EmptyState,
|
||||||
MessageBanner,
|
MessageBanner,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
SortableTable,
|
|
||||||
TableColumn,
|
TableColumn,
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -45,131 +54,12 @@ from ..widgets import (
|
|||||||
show_toast,
|
show_toast,
|
||||||
)
|
)
|
||||||
from .prescriptions import (
|
from .prescriptions import (
|
||||||
_cell_host,
|
_ROLE_LEAD_ICON,
|
||||||
|
_ROLE_TAG_KIND,
|
||||||
_painted_icon,
|
_painted_icon,
|
||||||
_row_action_button,
|
_row_action_button,
|
||||||
_style_row_host,
|
|
||||||
_tag_label,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
PRESCRIPTION_LIBRARY_PAGE_QSS = """
|
|
||||||
#PrescriptionLibraryPage { background: #F8FAFF; }
|
|
||||||
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
|
|
||||||
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumb"],
|
|
||||||
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
|
|
||||||
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
|
|
||||||
min-height: 14px; max-height: 14px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
|
|
||||||
color: #15224A; font-size: 20px; font-weight: 700;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="muted"] {
|
|
||||||
color: #7481A3; font-size: 12px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#MetricCard {
|
|
||||||
min-height: 64px; max-height: 64px;
|
|
||||||
border: 1px solid #E2E7F4; border-radius: 12px; background: #FFFFFF;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
|
|
||||||
color: #405074; font-size: 12px; font-weight: 600;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
|
|
||||||
color: #5265F6; font-size: 20px; font-weight: 700;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QLabel[metricIcon="true"] {
|
|
||||||
border: 1px solid #DCE3FF; border-radius: 11px; background: #EEF1FF;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="info"] {
|
|
||||||
border-color: #E5DFFF; background: #F2EEFF;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="success"] {
|
|
||||||
border-color: #CDEFE3; background: #E8F8F2;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="warning"] {
|
|
||||||
border-color: #F8DFC2; background: #FFF3E5;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
|
|
||||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
|
|
||||||
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar {
|
|
||||||
background: #FFFFFF; border: 0; border-bottom: 1px solid #E7EBF5;
|
|
||||||
border-top-left-radius: 13px; border-top-right-radius: 13px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
|
|
||||||
min-width: 82px; min-height: 32px; max-height: 32px;
|
|
||||||
padding: 0 8px; margin: 0 4px 0 0;
|
|
||||||
color: #59698E; background: transparent; border: 0;
|
|
||||||
border-bottom: 2px solid transparent; border-radius: 0;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
|
|
||||||
color: #5265F6; background: transparent; border-bottom-color: #5265F6;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QLineEdit,
|
|
||||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QComboBox {
|
|
||||||
min-height: 32px; max-height: 32px; padding: 0 11px;
|
|
||||||
border-radius: 8px; font-size: 12px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QPushButton {
|
|
||||||
min-height: 34px; max-height: 34px; padding: 0 14px;
|
|
||||||
border-radius: 8px; font-size: 12px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton {
|
|
||||||
min-height: 32px; max-height: 32px; padding: 0 13px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
|
|
||||||
min-width: 27px; max-width: 27px; min-height: 27px; max-height: 27px;
|
|
||||||
padding: 0; border-radius: 7px; background: #FFFFFF;
|
|
||||||
border: 1px solid #DCE3F5;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover {
|
|
||||||
background: #F3F5FF; border-color: #AAB7FF;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"] {
|
|
||||||
background: #FFF9FA; border-color: #FFD9DE;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover {
|
|
||||||
background: #FFF1F3; border-color: #FFACB8;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QTableWidget {
|
|
||||||
border: 0; border-radius: 0; background: #FFFFFF;
|
|
||||||
alternate-background-color: #FBFCFF; font-size: 12px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QTableWidget::item {
|
|
||||||
padding: 4px 8px; border-bottom: 1px solid #EDF0F7;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QTableWidget::item:selected {
|
|
||||||
color: #26365F; background: #FCFDFF;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QHeaderView::section {
|
|
||||||
min-height: 38px; max-height: 38px; padding: 0 8px;
|
|
||||||
background: #F7F9FE; color: #7481A3;
|
|
||||||
border: 0; border-bottom: 1px solid #E7EBF5;
|
|
||||||
font-size: 12px; font-weight: 600;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QWidget#Pager { min-height: 42px; }
|
|
||||||
#PrescriptionLibraryPage QWidget#Pager QPushButton {
|
|
||||||
min-width: 34px; max-height: 32px; min-height: 32px; padding: 0 10px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QWidget#Pager QLabel#PagerActive {
|
|
||||||
min-width: 48px; min-height: 30px; border-radius: 7px;
|
|
||||||
background: #5265F6; color: #FFFFFF; font-weight: 700;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"] {
|
|
||||||
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
|
|
||||||
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton {
|
|
||||||
min-height: 32px; max-height: 32px;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
|
|
||||||
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
|
|
||||||
}
|
|
||||||
#PrescriptionLibraryPage QWidget#BusinessPager QLabel[pagerSize="true"] {
|
|
||||||
min-width: 64px; color: #7481A3; font-size: 12px;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _formula_text(value: Any, _row: Any = None) -> str:
|
def _formula_text(value: Any, _row: Any = None) -> str:
|
||||||
text = str(value or "").strip().lower()
|
text = str(value or "").strip().lower()
|
||||||
@@ -197,7 +87,8 @@ def _herbs_detail(_value: Any, row: Any) -> str:
|
|||||||
for herb in herbs:
|
for herb in herbs:
|
||||||
name = first_value(herb, "name", "medicine_name", default="药材")
|
name = first_value(herb, "name", "medicine_name", default="药材")
|
||||||
dosage = first_value(herb, "dosage", "amount", default="")
|
dosage = first_value(herb, "dosage", "amount", default="")
|
||||||
pieces.append(f"{name} {dosage}g".strip())
|
unit = "" if str(dosage).strip().lower().endswith("g") else "g"
|
||||||
|
pieces.append(f"{name} {dosage}{unit}".strip())
|
||||||
return "、".join(pieces) if pieces else "暂无药材"
|
return "、".join(pieces) if pieces else "暂无药材"
|
||||||
|
|
||||||
|
|
||||||
@@ -220,22 +111,16 @@ def _create_time_cell(value: Any, _row: Any) -> str:
|
|||||||
return format_record_time(raw)
|
return format_record_time(raw)
|
||||||
|
|
||||||
|
|
||||||
def _metric_card(title: str, kind: str = "accent") -> MetricCard:
|
def _metric_card(title: str, kind: str = "accent", glyph: str = "layers") -> MetricCard:
|
||||||
card = MetricCard(title, "0", kind=kind, glyph="")
|
card = MetricCard(title, "0", kind=kind, glyph="")
|
||||||
card.setFixedHeight(64)
|
card.setFixedHeight(86)
|
||||||
card.layout().setContentsMargins(16, 8, 14, 8)
|
card.layout().setContentsMargins(18, 14, 18, 14)
|
||||||
icon = QLabel(card)
|
icon = QLabel(card)
|
||||||
icon.setProperty("metricIcon", True)
|
icon.setProperty("metricIcon", True)
|
||||||
icon.setProperty("kind", kind)
|
|
||||||
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
icon.setFixedSize(36, 36)
|
icon.setFixedSize(32, 32)
|
||||||
colors = {
|
color = "#1769E8" if kind == "accent" else "#60789F"
|
||||||
"accent": "#5365F5",
|
icon.setPixmap(_painted_icon(glyph, color, 25).pixmap(25, 25))
|
||||||
"info": "#8268E8",
|
|
||||||
"success": "#23A77D",
|
|
||||||
"warning": "#E6932C",
|
|
||||||
}
|
|
||||||
icon.setPixmap(_painted_icon("document", colors.get(kind, "#5365F5"), 18).pixmap(18, 18))
|
|
||||||
card.layout().addWidget(icon)
|
card.layout().addWidget(icon)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
@@ -252,7 +137,11 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("PrescriptionLibraryPage")
|
self.setObjectName("PrescriptionLibraryPage")
|
||||||
self.setStyleSheet(PRESCRIPTION_LIBRARY_PAGE_QSS)
|
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||||
|
self.setStyleSheet(library_stylesheet())
|
||||||
|
font = QFont(body_family())
|
||||||
|
font.setPixelSize(14)
|
||||||
|
self.setFont(font)
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
self.current_user = current_user
|
self.current_user = current_user
|
||||||
@@ -262,19 +151,35 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
self._page = 1
|
self._page = 1
|
||||||
self._page_size = 15
|
self._page_size = 15
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
root.setContentsMargins(24, 19, 24, 14)
|
outer.setContentsMargins(28, 16, 26, 8)
|
||||||
root.setSpacing(12)
|
self.scroll = QScrollArea()
|
||||||
|
self.scroll.setObjectName("PrescriptionLibraryScroll")
|
||||||
|
self.scroll.setWidgetResizable(True)
|
||||||
|
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.content = QWidget()
|
||||||
|
self.content.setObjectName("PrescriptionLibraryContent")
|
||||||
|
root = QVBoxLayout(self.content)
|
||||||
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
|
root.setSpacing(10)
|
||||||
|
self.scroll.setWidget(self.content)
|
||||||
|
outer.addWidget(self.scroll)
|
||||||
header = PageHeader(
|
header = PageHeader(
|
||||||
"处方库",
|
"处方库",
|
||||||
"管理常用处方模板,支持 AI 解析辅助开方。",
|
"管理常用处方模板,支持 AI 解析辅助开方。",
|
||||||
)
|
)
|
||||||
header.layout().setSpacing(4)
|
self.header = header
|
||||||
|
header.setFixedHeight(88)
|
||||||
|
header.layout().setSpacing(12)
|
||||||
|
header.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(7)
|
||||||
header.actions.setSpacing(16)
|
header.actions.setSpacing(16)
|
||||||
self.new_button = QPushButton("新增处方", header)
|
self.new_button = QPushButton("新增处方", header)
|
||||||
|
self.new_button.setObjectName("PrescriptionLibraryAddButton")
|
||||||
self.new_button.setMinimumWidth(124)
|
self.new_button.setMinimumWidth(124)
|
||||||
self.new_button.setProperty("variant", "primary")
|
self.new_button.setProperty("variant", "primary")
|
||||||
self.new_button.setIcon(_painted_icon("plus", "#FFFFFF", 15))
|
self.new_button.setIcon(_painted_icon("plus", "inverse", 15))
|
||||||
self.new_button.setIconSize(QSize(15, 15))
|
self.new_button.setIconSize(QSize(15, 15))
|
||||||
self.new_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.new_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add"))
|
self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add"))
|
||||||
@@ -282,18 +187,19 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
header.add_action(self.new_button)
|
header.add_action(self.new_button)
|
||||||
root.addWidget(header)
|
root.addWidget(header)
|
||||||
|
|
||||||
metrics = QHBoxLayout()
|
self.metrics_panel = QWidget(self.content)
|
||||||
|
metrics = QHBoxLayout(self.metrics_panel)
|
||||||
metrics.setContentsMargins(0, 0, 0, 0)
|
metrics.setContentsMargins(0, 0, 0, 0)
|
||||||
metrics.setSpacing(16)
|
metrics.setSpacing(16)
|
||||||
self.metric_cards = {
|
self.metric_cards = {
|
||||||
"total": _metric_card("全部处方"),
|
"total": _metric_card("全部处方", "accent", "layers"),
|
||||||
"private": _metric_card("仅自己", "info"),
|
"private": _metric_card("仅自己", "info", "lock"),
|
||||||
"public": _metric_card("公开处方", "success"),
|
"public": _metric_card("公开处方", "success", "users"),
|
||||||
"month": _metric_card("本月新增", "warning"),
|
"month": _metric_card("本月新增", "warning", "calendar"),
|
||||||
}
|
}
|
||||||
for metric in self.metric_cards.values():
|
for metric in self.metric_cards.values():
|
||||||
metrics.addWidget(metric)
|
metrics.addWidget(metric)
|
||||||
root.addLayout(metrics)
|
root.addWidget(self.metrics_panel)
|
||||||
|
|
||||||
self.hint_banner = MessageBanner(parent=self)
|
self.hint_banner = MessageBanner(parent=self)
|
||||||
self.hint_banner.show_message(
|
self.hint_banner.show_message(
|
||||||
@@ -304,43 +210,47 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
|
|
||||||
filters = QFrame()
|
filters = QFrame()
|
||||||
filters.setObjectName("PrescriptionLibraryFilterBar")
|
filters.setObjectName("PrescriptionLibraryFilterBar")
|
||||||
filters.setFixedHeight(52)
|
self.filter_card = filters
|
||||||
|
filters.setFixedHeight(90)
|
||||||
grid = QGridLayout(filters)
|
grid = QGridLayout(filters)
|
||||||
grid.setContentsMargins(16, 9, 16, 9)
|
self.filter_grid = grid
|
||||||
|
grid.setContentsMargins(18, 24, 18, 24)
|
||||||
|
grid.setVerticalSpacing(14)
|
||||||
grid.setHorizontalSpacing(12)
|
grid.setHorizontalSpacing(12)
|
||||||
self.name_filter = QLineEdit()
|
self.name_filter = QLineEdit()
|
||||||
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
|
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
|
||||||
self.name_filter.setClearButtonEnabled(True)
|
self.name_filter.setClearButtonEnabled(True)
|
||||||
self.name_filter.returnPressed.connect(self._search)
|
self.name_filter.returnPressed.connect(self._search)
|
||||||
grid.addWidget(self.name_filter, 0, 0)
|
grid.addWidget(self.name_filter, 0, 0)
|
||||||
self.formula_filter = QComboBox()
|
self.formula_filter = LibraryComboBox()
|
||||||
self.formula_filter.addItem("全部类型", "")
|
self.formula_filter.addItem("全部类型", "")
|
||||||
self.formula_filter.addItem("主方", "主方")
|
self.formula_filter.addItem("主方", "主方")
|
||||||
self.formula_filter.addItem("辅方", "辅方")
|
self.formula_filter.addItem("辅方", "辅方")
|
||||||
grid.addWidget(self.formula_filter, 0, 1)
|
grid.addWidget(self.formula_filter, 0, 1)
|
||||||
self.visibility_filter = QComboBox()
|
self.visibility_filter = LibraryComboBox()
|
||||||
self.visibility_filter.addItem("全部公开范围", "")
|
self.visibility_filter.addItem("全部公开范围", "")
|
||||||
self.visibility_filter.addItem("仅自己可见", 0)
|
self.visibility_filter.addItem("仅自己可见", 0)
|
||||||
self.visibility_filter.addItem("所有人可见", 1)
|
self.visibility_filter.addItem("所有人可见", 1)
|
||||||
grid.addWidget(self.visibility_filter, 0, 2)
|
grid.addWidget(self.visibility_filter, 0, 2)
|
||||||
self.effect_filter = QComboBox()
|
self.effect_filter = LibraryComboBox()
|
||||||
self.effect_filter.addItem("全部功效类型", "")
|
self.effect_filter.addItem("全部功效类型", "")
|
||||||
self.effect_filter.addItem("益气养阴", "益气养阴")
|
self.effect_filter.addItem("益气养阴", "益气养阴")
|
||||||
self.effect_filter.addItem("清热祛湿", "清热祛湿")
|
self.effect_filter.addItem("清热祛湿", "清热祛湿")
|
||||||
self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
|
self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
|
||||||
grid.addWidget(self.effect_filter, 0, 3)
|
grid.addWidget(self.effect_filter, 0, 3)
|
||||||
self.name_filter.setMinimumWidth(220)
|
self.name_filter.setMinimumWidth(260)
|
||||||
self.formula_filter.setMinimumWidth(132)
|
self.formula_filter.setMinimumWidth(140)
|
||||||
self.visibility_filter.setMinimumWidth(148)
|
self.visibility_filter.setMinimumWidth(168)
|
||||||
self.effect_filter.setMinimumWidth(144)
|
self.effect_filter.setMinimumWidth(162)
|
||||||
self.query_button = QPushButton("查询")
|
self.query_button = QPushButton("查询")
|
||||||
self.query_button.setFixedWidth(66)
|
self.query_button.setObjectName("PrescriptionLibraryQueryButton")
|
||||||
|
self.query_button.setFixedWidth(84)
|
||||||
self.query_button.setProperty("variant", "secondary")
|
self.query_button.setProperty("variant", "secondary")
|
||||||
self.query_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.query_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.query_button.clicked.connect(self._search)
|
self.query_button.clicked.connect(self._search)
|
||||||
grid.addWidget(self.query_button, 0, 4)
|
grid.addWidget(self.query_button, 0, 4)
|
||||||
self.reset_button = QPushButton("重置")
|
self.reset_button = QPushButton("重置")
|
||||||
self.reset_button.setFixedWidth(66)
|
self.reset_button.setFixedWidth(84)
|
||||||
self.reset_button.setProperty("variant", "ghost")
|
self.reset_button.setProperty("variant", "ghost")
|
||||||
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.reset_button.clicked.connect(self._reset_filters)
|
self.reset_button.clicked.connect(self._reset_filters)
|
||||||
@@ -350,19 +260,31 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
grid.setColumnStretch(2, 1)
|
grid.setColumnStretch(2, 1)
|
||||||
grid.setColumnStretch(3, 1)
|
grid.setColumnStretch(3, 1)
|
||||||
root.addWidget(filters)
|
root.addWidget(filters)
|
||||||
|
self.filter_disclosure = FilterDisclosure(self, [self.metrics_panel, filters])
|
||||||
|
header.add_action(self.filter_disclosure.button)
|
||||||
|
header.set_compact()
|
||||||
|
|
||||||
self.banner = MessageBanner()
|
self.banner = MessageBanner()
|
||||||
root.addWidget(self.banner)
|
root.addWidget(self.banner)
|
||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setObjectName("PrescriptionLibraryTableCard")
|
card.setObjectName("PrescriptionLibraryTableCard")
|
||||||
|
self.table_card = card
|
||||||
|
card.setMinimumHeight(320)
|
||||||
card_layout = QVBoxLayout(card)
|
card_layout = QVBoxLayout(card)
|
||||||
card_layout.setContentsMargins(0, 0, 0, 0)
|
card_layout.setContentsMargins(1, 0, 1, 1)
|
||||||
card_layout.setSpacing(0)
|
card_layout.setSpacing(0)
|
||||||
toolbar_host = QFrame(card)
|
toolbar_host = QFrame(card)
|
||||||
toolbar_host.setObjectName("PrescriptionLibraryToolbar")
|
toolbar_host.setObjectName("PrescriptionLibraryToolbar")
|
||||||
toolbar_host.setFixedHeight(46)
|
toolbar_host.setFixedHeight(64)
|
||||||
|
toolbar_scroll = QScrollArea(card)
|
||||||
|
toolbar_scroll.setObjectName("PrescriptionLibraryToolbarScroll")
|
||||||
|
toolbar_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
toolbar_scroll.setWidgetResizable(True)
|
||||||
|
toolbar_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
toolbar_scroll.setFixedHeight(64)
|
||||||
|
toolbar_scroll.setWidget(toolbar_host)
|
||||||
toolbar = QHBoxLayout(toolbar_host)
|
toolbar = QHBoxLayout(toolbar_host)
|
||||||
toolbar.setContentsMargins(16, 7, 16, 7)
|
toolbar.setContentsMargins(18, 7, 18, 7)
|
||||||
toolbar.setSpacing(8)
|
toolbar.setSpacing(8)
|
||||||
self.all_tab = QPushButton("处方列表", toolbar_host)
|
self.all_tab = QPushButton("处方列表", toolbar_host)
|
||||||
self.all_tab.setProperty("toolbarTab", True)
|
self.all_tab.setProperty("toolbarTab", True)
|
||||||
@@ -377,7 +299,7 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
toolbar.addWidget(self.favorite_tab)
|
toolbar.addWidget(self.favorite_tab)
|
||||||
toolbar.addStretch(1)
|
toolbar.addStretch(1)
|
||||||
self.view_button = QPushButton("查看", card)
|
self.view_button = QPushButton("查看", card)
|
||||||
self.view_button.setIcon(_painted_icon("eye", "#5265F6", 15))
|
self.view_button.setIcon(_painted_icon("eye", "accent", 15))
|
||||||
self.view_button.setIconSize(QSize(15, 15))
|
self.view_button.setIconSize(QSize(15, 15))
|
||||||
self.view_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.view_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read"))
|
self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read"))
|
||||||
@@ -386,7 +308,7 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
toolbar.addWidget(self.view_button)
|
toolbar.addWidget(self.view_button)
|
||||||
self.ai_button = QPushButton("AI解释", card)
|
self.ai_button = QPushButton("AI解释", card)
|
||||||
self.ai_button.setProperty("variant", "secondary")
|
self.ai_button.setProperty("variant", "secondary")
|
||||||
self.ai_button.setIcon(_painted_icon("spark", "#5265F6", 15))
|
self.ai_button.setIcon(_painted_icon("spark", "accent", 15))
|
||||||
self.ai_button.setIconSize(QSize(15, 15))
|
self.ai_button.setIconSize(QSize(15, 15))
|
||||||
self.ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.ai_button.setVisible(can_open_ai_explain(permissions))
|
self.ai_button.setVisible(can_open_ai_explain(permissions))
|
||||||
@@ -394,7 +316,7 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
self.ai_button.clicked.connect(self._explain_selected)
|
self.ai_button.clicked.connect(self._explain_selected)
|
||||||
toolbar.addWidget(self.ai_button)
|
toolbar.addWidget(self.ai_button)
|
||||||
self.edit_button = QPushButton("编辑", card)
|
self.edit_button = QPushButton("编辑", card)
|
||||||
self.edit_button.setIcon(_painted_icon("pencil", "#5265F6", 15))
|
self.edit_button.setIcon(_painted_icon("pencil", "accent", 15))
|
||||||
self.edit_button.setIconSize(QSize(15, 15))
|
self.edit_button.setIconSize(QSize(15, 15))
|
||||||
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit"))
|
self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit"))
|
||||||
@@ -403,7 +325,7 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
toolbar.addWidget(self.edit_button)
|
toolbar.addWidget(self.edit_button)
|
||||||
self.delete_button = QPushButton("删除", card)
|
self.delete_button = QPushButton("删除", card)
|
||||||
self.delete_button.setProperty("variant", "danger")
|
self.delete_button.setProperty("variant", "danger")
|
||||||
self.delete_button.setIcon(_painted_icon("trash", "#F34E64", 15))
|
self.delete_button.setIcon(_painted_icon("trash", "danger", 15))
|
||||||
self.delete_button.setIconSize(QSize(15, 15))
|
self.delete_button.setIconSize(QSize(15, 15))
|
||||||
self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete"))
|
self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete"))
|
||||||
@@ -412,18 +334,19 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
toolbar.addWidget(self.delete_button)
|
toolbar.addWidget(self.delete_button)
|
||||||
refresh = QPushButton("刷新")
|
refresh = QPushButton("刷新")
|
||||||
refresh.setProperty("variant", "ghost")
|
refresh.setProperty("variant", "ghost")
|
||||||
refresh.setIcon(_painted_icon("refresh", "#5D6E96", 15))
|
refresh.setIcon(_painted_icon("refresh", "soft", 15))
|
||||||
refresh.setIconSize(QSize(15, 15))
|
refresh.setIconSize(QSize(15, 15))
|
||||||
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
|
refresh.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
refresh.clicked.connect(self.refresh)
|
refresh.clicked.connect(self.refresh)
|
||||||
toolbar.addWidget(refresh)
|
toolbar.addWidget(refresh)
|
||||||
card_layout.addWidget(toolbar_host)
|
card_layout.addWidget(toolbar_scroll)
|
||||||
|
|
||||||
self.stack = QStackedWidget()
|
self.stack = QStackedWidget()
|
||||||
table_host = QWidget()
|
table_host = QWidget()
|
||||||
table_layout = QVBoxLayout(table_host)
|
table_layout = QVBoxLayout(table_host)
|
||||||
table_layout.setContentsMargins(0, 0, 0, 0)
|
table_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.table = SortableTable(
|
table_layout.setSpacing(0)
|
||||||
|
self.table = LibraryTable(
|
||||||
[
|
[
|
||||||
TableColumn("id", "ID", 62),
|
TableColumn("id", "ID", 62),
|
||||||
TableColumn("prescription_name", "处方名称", 162),
|
TableColumn("prescription_name", "处方名称", 162),
|
||||||
@@ -437,15 +360,27 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
|
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
self.table.setObjectName("PrescriptionLibraryTable")
|
||||||
self.table.horizontalHeader().setFixedHeight(38)
|
self.table.setItemDelegate(LibraryItemDelegate(self.table))
|
||||||
|
self.table.setMouseTracking(True)
|
||||||
|
self.table.setAlternatingRowColors(False)
|
||||||
|
self.table.verticalHeader().setMinimumSectionSize(78)
|
||||||
|
self.table.verticalHeader().setDefaultSectionSize(78)
|
||||||
|
self.table.horizontalHeader().setStretchLastSection(False)
|
||||||
|
self.table.horizontalHeader().setFixedHeight(44)
|
||||||
|
self.table.horizontalHeader().setMinimumSectionSize(36)
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||||
|
self.table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
for column, width in enumerate((54, 170, 76, 76, 260, 90, 134, 136, 122, 150)):
|
||||||
|
self.table.setColumnWidth(column, width)
|
||||||
|
for column in (0, 2, 3, 5, 9):
|
||||||
|
self.table.horizontalHeaderItem(column).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.table.setWordWrap(False)
|
self.table.setWordWrap(False)
|
||||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||||
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
|
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
|
||||||
table_layout.addWidget(self.table, 1)
|
table_layout.addWidget(self.table, 1)
|
||||||
self.pager = BusinessPager(self._page_size)
|
self.pager = InfiniteList(self._page_size)
|
||||||
self.pager.page_changed.connect(self._change_page)
|
self.pager.bind(self.table)
|
||||||
table_layout.addWidget(self.pager)
|
|
||||||
self.stack.addWidget(table_host)
|
self.stack.addWidget(table_host)
|
||||||
empty = EmptyState(
|
empty = EmptyState(
|
||||||
"还没有处方模板",
|
"还没有处方模板",
|
||||||
@@ -454,10 +389,43 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
)
|
)
|
||||||
empty.action_button.setVisible(self.new_button.isVisible())
|
empty.action_button.setVisible(self.new_button.isVisible())
|
||||||
empty.action_requested.connect(self._new_template)
|
empty.action_requested.connect(self._new_template)
|
||||||
|
empty.layout().itemAt(0).setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||||
self.stack.addWidget(empty)
|
self.stack.addWidget(empty)
|
||||||
card_layout.addWidget(self.stack, 1)
|
card_layout.addWidget(self.stack, 1)
|
||||||
|
card_layout.addWidget(self.pager)
|
||||||
root.addWidget(card, 1)
|
root.addWidget(card, 1)
|
||||||
|
|
||||||
|
def resizeEvent(self, event: Any) -> None:
|
||||||
|
super().resizeEvent(event)
|
||||||
|
if hasattr(self, "filter_grid"):
|
||||||
|
compact_height = self.height() < 760
|
||||||
|
self.layout().setContentsMargins(28, 16, 26, 8)
|
||||||
|
self.content.layout().setSpacing(8 if compact_height else 10)
|
||||||
|
self._reflow_filters()
|
||||||
|
|
||||||
|
def _reflow_filters(self) -> None:
|
||||||
|
compact = self.width() < 1120
|
||||||
|
if getattr(self, "_compact_filters", None) == compact:
|
||||||
|
return
|
||||||
|
self._compact_filters = compact
|
||||||
|
controls = (self.name_filter, self.formula_filter, self.visibility_filter,
|
||||||
|
self.effect_filter, self.query_button, self.reset_button)
|
||||||
|
for control in controls:
|
||||||
|
self.filter_grid.removeWidget(control)
|
||||||
|
for column in range(6):
|
||||||
|
self.filter_grid.setColumnStretch(column, 0)
|
||||||
|
if compact:
|
||||||
|
positions = ((0, 0, 2), (0, 2, 1), (0, 3, 1), (1, 0, 2), (1, 2, 1), (1, 3, 1))
|
||||||
|
stretches = (2, 2, 1, 1)
|
||||||
|
else:
|
||||||
|
positions = tuple((0, column, 1) for column in range(6))
|
||||||
|
stretches = (4, 1, 1, 1, 0, 0)
|
||||||
|
for control, (row, column, span) in zip(controls, positions, strict=True):
|
||||||
|
self.filter_grid.addWidget(control, row, column, 1, span)
|
||||||
|
for column, stretch in enumerate(stretches):
|
||||||
|
self.filter_grid.setColumnStretch(column, stretch)
|
||||||
|
self.filter_card.setFixedHeight(144 if compact else 90)
|
||||||
|
|
||||||
def _search(self) -> None:
|
def _search(self) -> None:
|
||||||
self._page = 1
|
self._page = 1
|
||||||
self.refresh()
|
self.refresh()
|
||||||
@@ -481,14 +449,7 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
self.effect_filter.setCurrentIndex(0)
|
self.effect_filter.setCurrentIndex(0)
|
||||||
self._search()
|
self._search()
|
||||||
|
|
||||||
def _change_page(self, page: int) -> None:
|
|
||||||
self._page = page
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
if self._loading:
|
|
||||||
self._refresh_pending = True
|
|
||||||
return
|
|
||||||
self._loading = True
|
self._loading = True
|
||||||
self._refresh_pending = False
|
self._refresh_pending = False
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
@@ -497,27 +458,26 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
"prescription_name": self.name_filter.text().strip(),
|
"prescription_name": self.name_filter.text().strip(),
|
||||||
"formula_type": self.formula_filter.currentData(),
|
"formula_type": self.formula_filter.currentData(),
|
||||||
"is_public": self.visibility_filter.currentData(),
|
"is_public": self.visibility_filter.currentData(),
|
||||||
"page": self._page,
|
|
||||||
"page_size": self._page_size,
|
"page_size": self._page_size,
|
||||||
}
|
}
|
||||||
requested_page = self._page
|
# 列表加载不再挂横幅。横幅占布局空间,弹出与收起各触发一次重排,
|
||||||
self.banner.show_message("正在加载处方库…", "info")
|
# 每刷新一次表格就上下跳一次——而轮询定时器每 5 秒就刷新一次。
|
||||||
run_async(
|
# 已有数据时保持旧行可见、静默替换;失败仍然照常报错。
|
||||||
lambda: invoke(
|
effect = str(self.effect_filter.currentData() or "").strip()
|
||||||
self.repository,
|
self.pager.reload(
|
||||||
"prescription_library",
|
lambda page: invoke(self.repository, "prescription_library", page=page, **query),
|
||||||
**query,
|
lambda result: self._apply_result(result, generation, self.pager.page, effect),
|
||||||
),
|
lambda error: self._load_error(error, generation),
|
||||||
on_success=lambda result: self._apply_result(result, generation, requested_page),
|
runner=run_async,
|
||||||
on_error=lambda error: self._load_error(error, generation),
|
query_key=(query, effect),
|
||||||
on_finished=lambda: self._load_finished(generation),
|
on_finished=lambda: self._load_finished(generation),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_result(self, result: Any, generation: int, requested_page: int) -> None:
|
def _apply_result(self, result: Any, generation: int, requested_page: int, effect_filter: str | None = None) -> None:
|
||||||
if generation != self._generation:
|
if generation != self._generation:
|
||||||
return
|
return
|
||||||
rows = page_items(result)
|
rows = page_items(result)
|
||||||
effect = str(self.effect_filter.currentData() or "").strip()
|
effect = str(self.effect_filter.currentData() or "").strip() if effect_filter is None else effect_filter
|
||||||
if effect:
|
if effect:
|
||||||
rows = [
|
rows = [
|
||||||
row
|
row
|
||||||
@@ -554,11 +514,26 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
self.metric_cards["private"].set_value(max(0, len(rows) - public_count))
|
self.metric_cards["private"].set_value(max(0, len(rows) - public_count))
|
||||||
self.metric_cards["public"].set_value(public_count)
|
self.metric_cards["public"].set_value(public_count)
|
||||||
self.metric_cards["month"].set_value(month_count)
|
self.metric_cards["month"].set_value(month_count)
|
||||||
self.stack.setCurrentIndex(0 if rows else 1)
|
motion.switch_stack(self.stack, 0 if rows or self.pager.has_more else 1)
|
||||||
self.banner.clear()
|
self.banner.clear()
|
||||||
self._selection_changed()
|
self._selection_changed()
|
||||||
|
|
||||||
def _decorate_rows(self, rows: list[Any]) -> None:
|
def _decorate_rows(self, rows: list[Any]) -> None:
|
||||||
|
# set_rows restores the active sort before decorating. Bind each action
|
||||||
|
# to the object now shown in that row, and keep rows still while tags
|
||||||
|
# are assigned (the type or visibility column may itself be sorted).
|
||||||
|
visible_rows = [
|
||||||
|
self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
for index in range(self.table.rowCount())
|
||||||
|
]
|
||||||
|
sorting = self.table.isSortingEnabled()
|
||||||
|
self.table.setSortingEnabled(False)
|
||||||
|
try:
|
||||||
|
self._decorate_rows_locked(visible_rows)
|
||||||
|
finally:
|
||||||
|
self.table.setSortingEnabled(sorting)
|
||||||
|
|
||||||
|
def _decorate_rows_locked(self, rows: list[Any]) -> None:
|
||||||
"""Install compact tags and permission-aware row actions from the comp."""
|
"""Install compact tags and permission-aware row actions from the comp."""
|
||||||
|
|
||||||
for row_index, row in enumerate(rows):
|
for row_index, row in enumerate(rows):
|
||||||
@@ -570,44 +545,32 @@ class PrescriptionLibraryPage(QWidget):
|
|||||||
|
|
||||||
name_item = self.table.item(row_index, 1)
|
name_item = self.table.item(row_index, 1)
|
||||||
if name_item is not None:
|
if name_item is not None:
|
||||||
name_item.setForeground(QColor("#24355F"))
|
name_item.setForeground(QColor("#1A1C1F"))
|
||||||
font = name_item.font()
|
font = name_item.font()
|
||||||
font.setWeight(QFont.Weight.DemiBold)
|
font.setWeight(QFont.Weight.Medium)
|
||||||
name_item.setFont(font)
|
name_item.setFont(font)
|
||||||
|
|
||||||
|
# 处方类型与公开范围改由 _RowDecorationDelegate 绘制:原先每行为这两列
|
||||||
|
# 各挂一个 QWidget,setCellWidget 是刷新路径上最贵的调用,而且白色的
|
||||||
|
# 宿主控件会把整行的选中底色挖出两个缺口。
|
||||||
formula = _formula_text(first_value(row, "formula_type", default="主方"))
|
formula = _formula_text(first_value(row, "formula_type", default="主方"))
|
||||||
formula_kind = "success" if formula == "主方" else "accent"
|
formula_item = self.table.item(row_index, 2)
|
||||||
self.table.setCellWidget(
|
if formula_item is not None:
|
||||||
row_index,
|
formula_item.setText(formula)
|
||||||
2,
|
formula_item.setData(
|
||||||
_style_row_host(
|
_ROLE_TAG_KIND, "success" if formula == "主方" else "accent"
|
||||||
_cell_host(
|
)
|
||||||
_tag_label(formula, formula_kind, self.table.viewport())
|
formula_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
),
|
|
||||||
row_index,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
visibility_host = QWidget(self.table.viewport())
|
visibility_item = self.table.item(row_index, 6)
|
||||||
_style_row_host(visibility_host, row_index)
|
if visibility_item is not None:
|
||||||
visibility_layout = QHBoxLayout(visibility_host)
|
visibility_item.setText(
|
||||||
visibility_layout.setContentsMargins(8, 0, 6, 0)
|
_visibility_text(first_value(row, "is_public", default=0))
|
||||||
visibility_layout.setSpacing(6)
|
)
|
||||||
visibility_icon = QLabel(visibility_host)
|
visibility_item.setData(_ROLE_LEAD_ICON, "lock")
|
||||||
visibility_icon.setFixedSize(15, 15)
|
|
||||||
visibility_icon.setPixmap(_painted_icon("lock", "#60709A", 14).pixmap(14, 14))
|
|
||||||
visibility_layout.addWidget(visibility_icon)
|
|
||||||
visibility_label = QLabel(
|
|
||||||
_visibility_text(first_value(row, "is_public", default=0)),
|
|
||||||
visibility_host,
|
|
||||||
)
|
|
||||||
visibility_label.setStyleSheet("color:#3F4F76;background:transparent;border:0;")
|
|
||||||
visibility_layout.addWidget(visibility_label)
|
|
||||||
visibility_layout.addStretch(1)
|
|
||||||
self.table.setCellWidget(row_index, 6, visibility_host)
|
|
||||||
|
|
||||||
|
# 保持宿主透明,让整行的斑马底色与选中底色透过操作列。
|
||||||
actions_host = QWidget(self.table.viewport())
|
actions_host = QWidget(self.table.viewport())
|
||||||
_style_row_host(actions_host, row_index)
|
|
||||||
actions = QHBoxLayout(actions_host)
|
actions = QHBoxLayout(actions_host)
|
||||||
actions.setContentsMargins(6, 0, 6, 0)
|
actions.setContentsMargins(6, 0, 6, 0)
|
||||||
actions.setSpacing(5)
|
actions.setSpacing(5)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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})
|
||||||
|
|||||||
@@ -639,6 +639,23 @@ def test_readonly_is_an_independent_vertical_page_flow(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_scroll_keeps_close_controls_reachable(application: QApplication) -> None:
|
||||||
|
dialog = _open_dialog(application, (760, 520), mode="readonly")
|
||||||
|
header_before = dialog.readonly_header.geometry()
|
||||||
|
scroll_bar = dialog.readonly_scroll.verticalScrollBar()
|
||||||
|
|
||||||
|
assert scroll_bar.maximum() > 0
|
||||||
|
assert dialog.readonly_close_button.isVisibleTo(dialog)
|
||||||
|
scroll_bar.setValue(scroll_bar.maximum())
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert dialog.readonly_header.geometry() == header_before
|
||||||
|
assert dialog.readonly_close_button.isVisibleTo(dialog)
|
||||||
|
dialog.readonly_close_button.click()
|
||||||
|
application.processEvents()
|
||||||
|
assert not dialog.isVisible()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)])
|
||||||
@pytest.mark.parametrize("mode", ["edit", "viewOnly"])
|
@pytest.mark.parametrize("mode", ["edit", "viewOnly"])
|
||||||
def test_drawer_is_full_height_rtl_and_sixty_percent_wide(
|
def test_drawer_is_full_height_rtl_and_sixty_percent_wide(
|
||||||
@@ -969,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()
|
||||||
|
|
||||||
@@ -996,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,270 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, Qt
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||||
|
DiagnosisTableHost,
|
||||||
|
_blue_appointment_layout,
|
||||||
|
_blue_appointment_text,
|
||||||
|
_blue_font,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(12):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(identifier: int = 501) -> dict:
|
||||||
|
return {
|
||||||
|
"id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000,
|
||||||
|
"patient_name": "林晓岚", "gender": 2, "age": 46, "doctor_name": "陈医生(演示)",
|
||||||
|
"appointment_id": identifier + 2000, "has_appointment": 1, "appointment_status": 1,
|
||||||
|
"appointment_date": "2026-09-05", "appointment_time": "09:00-09:30",
|
||||||
|
"appointments": [
|
||||||
|
{"id": identifier + 2000, "status": 1, "appointment_date": "2026-09-05"},
|
||||||
|
{"id": identifier + 1900, "status": 3, "appointment_date": "2026-08-06"},
|
||||||
|
],
|
||||||
|
"diagnosis_confirmed": 1, "assistant_name": "周医助", "has_prescription": 0,
|
||||||
|
"unserved_days": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_fallback_requires_exact_current_appointment_id(application: QApplication) -> None:
|
||||||
|
row = _row()
|
||||||
|
before = copy.deepcopy(row)
|
||||||
|
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
||||||
|
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
||||||
|
)
|
||||||
|
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||||
|
"—", "2026-08-06 时间 —",
|
||||||
|
)
|
||||||
|
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||||
|
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
||||||
|
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
||||||
|
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||||
|
for missing_id in (0, "", None):
|
||||||
|
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
||||||
|
assert row == before
|
||||||
|
legacy = DiagnosisTableHost()
|
||||||
|
blue = DiagnosisTableHost(tech_blue=True)
|
||||||
|
for host in (legacy, blue):
|
||||||
|
host.set_rows([row])
|
||||||
|
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||||
|
assert blue.model.index(0, 4).data().splitlines() == [
|
||||||
|
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
||||||
|
]
|
||||||
|
assert blue.model.index(0, 9).data() == "—"
|
||||||
|
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||||
|
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||||
|
blue.set_rows([{**row, "appointments": []}])
|
||||||
|
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
||||||
|
legacy.close()
|
||||||
|
blue.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QApplication) -> None:
|
||||||
|
blue = DiagnosisTableHost(tech_blue=True)
|
||||||
|
legacy = DiagnosisTableHost()
|
||||||
|
row = _row()
|
||||||
|
single = _row(502)
|
||||||
|
single["appointments"] = single["appointments"][:1]
|
||||||
|
long = copy.deepcopy(single)
|
||||||
|
long["id"] = long["diagnosis_id"] = 503
|
||||||
|
long["appointments"][0]["doctor_name"] = "超长完整医生姓名以及完整门诊部门需要换行" * 2
|
||||||
|
long["latest_appointment_channel_text"] = "完整保留的挂号渠道说明" * 4
|
||||||
|
long["has_prescription"] = 1
|
||||||
|
long["followup_time_text"] = "2026-10-15 09:00-09:30"
|
||||||
|
long["followup_doctor_name"] = "复诊医生姓名"
|
||||||
|
long["followup_rx_voided"] = True
|
||||||
|
blue.set_rows([row, single, long])
|
||||||
|
legacy.set_rows([row])
|
||||||
|
assert blue.model.columnCount() == 12
|
||||||
|
assert [blue.main.columnWidth(i) for i in range(10)] == [48, 70, 82, 102, 244, 90, 84, 90, 88, 122]
|
||||||
|
assert blue.main.horizontalHeader().height() == 41
|
||||||
|
assert legacy.main.horizontalHeader().height() == 39
|
||||||
|
assert blue.main.rowHeight(0) == 108
|
||||||
|
assert blue.main.rowHeight(1) == 72
|
||||||
|
assert blue.main.rowHeight(2) > 110
|
||||||
|
assert legacy.main.rowHeight(0) == 96
|
||||||
|
for index in range(3):
|
||||||
|
assert blue.main.rowHeight(index) == blue.fixed.rowHeight(index)
|
||||||
|
assert _blue_font(14).family() == body_family()
|
||||||
|
assert _blue_font(14, medium=True).family() == heading_family()
|
||||||
|
assert _blue_font(14, medium=True).weight() == QFont.Weight.Medium
|
||||||
|
assert _blue_font(13).pixelSize() == 13
|
||||||
|
blue.close()
|
||||||
|
legacy.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||||
|
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
|
||||||
|
application: QApplication, tech_blue: bool,
|
||||||
|
) -> None:
|
||||||
|
window = QWidget()
|
||||||
|
layout = QVBoxLayout(window)
|
||||||
|
layout.setContentsMargins(7, 7, 7, 7)
|
||||||
|
host = DiagnosisTableHost(tech_blue=tech_blue)
|
||||||
|
layout.addWidget(host, 1)
|
||||||
|
footer = QLabel("始终可达的分页")
|
||||||
|
footer.setFixedHeight(42)
|
||||||
|
layout.addWidget(footer)
|
||||||
|
host.set_rows([_row(i) for i in range(40)])
|
||||||
|
window.resize(1280, 640)
|
||||||
|
window.show()
|
||||||
|
_settle(application)
|
||||||
|
minimum = window.minimumSizeHint().height()
|
||||||
|
for width, height in ((780, 480), (1440, 780), (800, 540), (1280, 640)) * 3:
|
||||||
|
window.resize(width, height)
|
||||||
|
_settle(application)
|
||||||
|
# The previous implementation wrote the available height into the
|
||||||
|
# fixed child's minimum and enlarged every ancestor on each relayout.
|
||||||
|
assert window.size().height() == height
|
||||||
|
assert window.minimumSizeHint().height() == minimum
|
||||||
|
assert host.fixed.minimumHeight() == 0
|
||||||
|
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||||
|
assert host.main.viewport().mapTo(host, QPoint()).y() == host.fixed.viewport().mapTo(host, QPoint()).y()
|
||||||
|
main_scroll, fixed_scroll = host.main.verticalScrollBar(), host.fixed.verticalScrollBar()
|
||||||
|
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||||
|
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||||
|
_settle(application)
|
||||||
|
assert main_scroll.value() == fixed_scroll.value()
|
||||||
|
fixed_scroll.setValue(fixed_scroll.maximum())
|
||||||
|
_settle(application)
|
||||||
|
assert main_scroll.value() == fixed_scroll.value()
|
||||||
|
frozen_x = host.fixed.x()
|
||||||
|
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||||
|
_settle(application)
|
||||||
|
assert host.fixed.x() == frozen_x
|
||||||
|
assert host.fixed.geometry().right() <= host.rect().right()
|
||||||
|
assert footer.mapTo(window, QPoint()).y() + footer.height() <= window.height()
|
||||||
|
window.close()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkbox_toggle_and_select_all_do_not_change_current_patient(application: QApplication) -> None:
|
||||||
|
host = DiagnosisTableHost(tech_blue=True)
|
||||||
|
host.resize(1300, 420)
|
||||||
|
host.set_rows([_row(501), _row(502)])
|
||||||
|
host.show()
|
||||||
|
_settle(application)
|
||||||
|
host.main.selectRow(1)
|
||||||
|
box = host.main.visualRect(host.model.index(1, 0)).center()
|
||||||
|
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||||
|
assert [row["id"] for row in host.selected_records()] == [502]
|
||||||
|
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||||
|
assert host.selected_records() == []
|
||||||
|
assert host.main.currentRow() == 1
|
||||||
|
header = host.main.horizontalHeader()
|
||||||
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||||
|
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||||
|
assert host.main.currentRow() == 1
|
||||||
|
host.main.selectRow(0)
|
||||||
|
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||||
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||||
|
assert host.selected_records() == []
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_appointment_cancel_hit_targets_and_video_gates(application: QApplication) -> None:
|
||||||
|
host = DiagnosisTableHost(tech_blue=True, action_policy={
|
||||||
|
"view": True, "edit": True, "appointment_cancel": True, "video_call": True,
|
||||||
|
})
|
||||||
|
row = _row()
|
||||||
|
# Make the second entry cancellable and the first doctor wrap: hit regions
|
||||||
|
# must follow measured content, not a legacy fixed 42-pixel multiplier.
|
||||||
|
row["appointments"][0]["doctor_name"] = "医生与门诊信息完整保留" * 3
|
||||||
|
row["appointments"][1]["status"] = 4
|
||||||
|
host.set_rows([row])
|
||||||
|
host.resize(1300, 500)
|
||||||
|
host.show()
|
||||||
|
_settle(application)
|
||||||
|
received = []
|
||||||
|
host.appointment_cancel_requested.connect(lambda record, identifier: received.append((record["id"], identifier)))
|
||||||
|
overlay = host.main.indexWidget(host.model.index(0, 4))
|
||||||
|
buttons = overlay.findChildren(QToolButton)
|
||||||
|
assert len(buttons) == 2
|
||||||
|
entries, total = _blue_appointment_layout(row, overlay.width())
|
||||||
|
for position, button in enumerate(buttons):
|
||||||
|
assert button.y() == max(9, (overlay.height() - total) // 2) + entries[position][0]
|
||||||
|
assert button.geometry().bottom() < overlay.height()
|
||||||
|
button.click()
|
||||||
|
assert received == [(501, 2501), (501, 2401)]
|
||||||
|
more = host.fixed.indexWidget(host.model.index(0, 11)).findChild(QToolButton, "DiagnosisRowMore")
|
||||||
|
assert "取消挂号" not in [action.text() for action in more.menu().actions()]
|
||||||
|
assert not host.fixed.indexWidget(host.model.index(0, 10)).findChildren(QToolButton)
|
||||||
|
row["video_call_hint"] = {"state": "live"}
|
||||||
|
host.set_rows([row])
|
||||||
|
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||||
|
assert video is not None and video.isEnabled()
|
||||||
|
row["patient_id"] = 0
|
||||||
|
host.set_rows([row])
|
||||||
|
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||||
|
assert video is not None and not video.isEnabled()
|
||||||
|
host.action_policy["appointment_cancel"] = False
|
||||||
|
host.set_rows([row])
|
||||||
|
assert host.main.indexWidget(host.model.index(0, 4)) is None
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_shell_navigation_has_no_fixed_height_stack_overflow(tmp_path: Path) -> None:
|
||||||
|
script = tmp_path / "shell_navigation.py"
|
||||||
|
script.write_text('''
|
||||||
|
import socket
|
||||||
|
from unittest.mock import patch
|
||||||
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||||
|
app = QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
def settle():
|
||||||
|
for _ in range(12):
|
||||||
|
QThreadPool.globalInstance().waitForDone(1000)
|
||||||
|
app.processEvents()
|
||||||
|
with patch.object(socket.socket, "connect", side_effect=RuntimeError("offline test")):
|
||||||
|
repo = DemoDoctorRepository()
|
||||||
|
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||||
|
shell = ShellWindow(repo, {"session": session, "demo_mode": True}, permissions=session.permissions)
|
||||||
|
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||||
|
shell.resize(1536, 960)
|
||||||
|
shell.show()
|
||||||
|
settle()
|
||||||
|
for route in ("consultations", "appointments", "consultations"):
|
||||||
|
shell.navigate(route)
|
||||||
|
settle()
|
||||||
|
host = shell.pages["consultations"].table_host
|
||||||
|
for width, height in ((1024, 640), (1536, 960), (1366, 768)):
|
||||||
|
shell.resize(width, height)
|
||||||
|
settle()
|
||||||
|
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||||
|
assert host.fixed.minimumHeight() == 0
|
||||||
|
assert shell.height() == height
|
||||||
|
shell.close()
|
||||||
|
settle()
|
||||||
|
print("native navigation geometry stable")
|
||||||
|
''', encoding="utf-8")
|
||||||
|
env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "DOCTOR_SMOKE_TEST": "1",
|
||||||
|
"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
|
||||||
|
result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace", env=env, timeout=90)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
assert "native navigation geometry stable" in result.stdout
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint
|
||||||
|
from PySide6.QtGui import QPalette
|
||||||
|
from PySide6.QtWidgets import QApplication, QDialog, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui import apply_theme
|
||||||
|
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, _SlotCard
|
||||||
|
from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS, DiagnosisTabWidget
|
||||||
|
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
|
||||||
|
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||||
|
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(application: QApplication) -> None:
|
||||||
|
for _ in range(10):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width", [600, 760, 860])
|
||||||
|
def test_prescription_form_keeps_date_gender_and_controls_readable(
|
||||||
|
application: QApplication, width: int
|
||||||
|
) -> None:
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(width, 640)
|
||||||
|
host.show()
|
||||||
|
editor = PrescriptionEditorDialog(
|
||||||
|
SimpleNamespace(list_medicines=lambda **kwargs: {"lists": [], "count": 0}),
|
||||||
|
{"diagnosis_id": 501, "patient_name": "林晓岚", "prescription_type": "浓缩水丸"},
|
||||||
|
parent=host,
|
||||||
|
)
|
||||||
|
editor.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
display = editor.date_edit._display
|
||||||
|
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||||
|
for button in (editor.gender_male, editor.gender_female):
|
||||||
|
assert button.width() >= button.sizeHint().width()
|
||||||
|
controls = [editor.patient_name, editor.age, editor.date_edit, editor.prescription_type, editor.times_per_day]
|
||||||
|
assert len({control.height() for control in controls}) == 1
|
||||||
|
assert editor.body_content.width() == editor.body_scroll.viewport().width()
|
||||||
|
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
|
||||||
|
footer_position = editor.footer.mapToGlobal(QPoint())
|
||||||
|
editor.body_scroll.verticalScrollBar().setValue(editor.body_scroll.verticalScrollBar().maximum())
|
||||||
|
settle(application)
|
||||||
|
assert editor.footer.mapToGlobal(QPoint()) == footer_position
|
||||||
|
assert editor.save_button.visibleRegion().boundingRect() == editor.save_button.rect()
|
||||||
|
# Changing prescription type keeps its conditional fields reachable after reflow.
|
||||||
|
editor.prescription_type.setCurrentText("饮片")
|
||||||
|
settle(application)
|
||||||
|
assert not editor._main_decoction_field.isHidden()
|
||||||
|
assert editor._main_bag_field.isHidden()
|
||||||
|
if width == 860:
|
||||||
|
for resized_width in (600, 860):
|
||||||
|
editor.resize(resized_width, 640)
|
||||||
|
settle(application)
|
||||||
|
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||||
|
assert editor._main_decoction_field.width() >= editor.need_decoction.minimumSizeHint().width() + 104
|
||||||
|
finally:
|
||||||
|
editor.close()
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_overflow_rail_leaves_selected_tab_underline_visible(application: QApplication) -> None:
|
||||||
|
tabs = DiagnosisTabWidget()
|
||||||
|
tabs.setObjectName("DiagnosisDrawerTabs")
|
||||||
|
tabs.setStyleSheet(DIAGNOSIS_QSS)
|
||||||
|
for title in ("病历", "医生备注", "日常记录", "处方", "业务订单", "视频录制回放", "聊天", "指派医助记录", "挂号记录"):
|
||||||
|
tabs.addTab(QWidget(), title)
|
||||||
|
tabs.resize(614, 480)
|
||||||
|
tabs.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
selected = tabs.tabBar().tabRect(tabs.currentIndex())
|
||||||
|
selected.moveTopLeft(tabs.tabBar().pos() + selected.topLeft())
|
||||||
|
assert tabs.tab_scrollbar.isVisible()
|
||||||
|
assert not selected.intersects(tabs.tab_scrollbar.geometry())
|
||||||
|
tabs.tab_scrollbar.setValue(tabs.count() - 1)
|
||||||
|
assert tabs.currentIndex() == tabs.count() - 1
|
||||||
|
finally:
|
||||||
|
tabs.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_appointment_slot_labels_follow_checked_and_disabled_states(application: QApplication) -> None:
|
||||||
|
dialog = QDialog()
|
||||||
|
dialog.setObjectName("AppointmentDrawerOverlay")
|
||||||
|
dialog.setStyleSheet(APPOINTMENT_DRAWER_QSS)
|
||||||
|
layout = QVBoxLayout(dialog)
|
||||||
|
card = _SlotCard("09:00-09:30", "可约")
|
||||||
|
card.setProperty("appointmentSlot", True)
|
||||||
|
card.setProperty("availability", "available")
|
||||||
|
card.setCheckable(True)
|
||||||
|
layout.addWidget(card)
|
||||||
|
dialog.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
for checked, expected in ((True, "#ffffff"), (False, "#1a1c1f")):
|
||||||
|
card.setChecked(checked)
|
||||||
|
settle(application)
|
||||||
|
assert card.time_label.palette().color(QPalette.ColorRole.WindowText).name() == expected
|
||||||
|
card.setChecked(True)
|
||||||
|
card.setEnabled(False)
|
||||||
|
settle(application)
|
||||||
|
assert card.time_label.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText).name() == "#8e8f90"
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_report_columns_keep_headings_at_the_same_text_baseline(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
def immediate(function, **kwargs):
|
||||||
|
kwargs["on_success"](function())
|
||||||
|
if kwargs.get("on_finished"):
|
||||||
|
kwargs["on_finished"]()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_module, "run_async", immediate)
|
||||||
|
report = {"summary": "复核参考", "possible_symptoms": ["乏力", "纳差", "睡眠不安"], "main_indications": "脾气不足", "efficacy": ["健脾", "益气", "养阴"], "suitable_people": ["需由医师辨证确认"]}
|
||||||
|
repository = SimpleNamespace(list_prescription_template_ai_reports=lambda template_id: {"prescription_id": template_id, "reports": [{"report_id": 1, "model_key": "qwen", "report": report}]})
|
||||||
|
dialog = PrescriptionAiReportDialog(repository, PermissionSet(["*"]))
|
||||||
|
dialog.open_for({"id": 1, "prescription_name": "测试方", "herbs": []})
|
||||||
|
dialog.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
labels = {label.text(): label for label in dialog.findChildren(QLabel) if label.objectName() == "PrescriptionAiSectionTitle"}
|
||||||
|
for left, right in (("可能症状与证候", "主治方向"), ("主要功效", "可能适用人群")):
|
||||||
|
# QLabel vertically centers text: compare the centers, not just widget origins.
|
||||||
|
centers = [labels[text].mapTo(dialog, labels[text].rect().center()).y() for text in (left, right)]
|
||||||
|
assert abs(centers[0] - centers[1]) <= 1
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.dialogs import ai_consult_picker, diagnosis, prescription
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def run(function: Any, *, on_success: Any, on_error: Any, on_finished: Any = None) -> None:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
on_success(result)
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
for module in (ai_consult_picker, diagnosis, prescription):
|
||||||
|
monkeypatch.setattr(module, "run_async", run)
|
||||||
|
|
||||||
|
|
||||||
|
class ListRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
self.fail_page = 0
|
||||||
|
|
||||||
|
def _list(self, **filters: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(filters)
|
||||||
|
page, size = filters["page_no"], filters["page_size"]
|
||||||
|
if page == self.fail_page:
|
||||||
|
raise RuntimeError("暂时无法加载")
|
||||||
|
start = (page - 1) * size + 1
|
||||||
|
return {
|
||||||
|
"lists": [
|
||||||
|
{
|
||||||
|
"id": number,
|
||||||
|
"diagnosis_id": number,
|
||||||
|
"patient_id": 1000 + number,
|
||||||
|
"patient_name": f"患者{number}",
|
||||||
|
"prescription_id": 77,
|
||||||
|
"prescription_name": f"处方{number}",
|
||||||
|
"herbs": [{"name": "白术", "dosage": 10}],
|
||||||
|
"order_no": f"ORDER{number}",
|
||||||
|
}
|
||||||
|
for number in range(start, min(start + size, 46))
|
||||||
|
],
|
||||||
|
"count": 45,
|
||||||
|
}
|
||||||
|
|
||||||
|
list_prescription_templates = _list
|
||||||
|
list_prescription_orders = _list
|
||||||
|
list_ai_patient_options = _list
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_to_bottom(table: Any) -> None:
|
||||||
|
scrollbar = table.verticalScrollBar()
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
QTest.qWait(70)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["template", "order", "ai"])
|
||||||
|
def test_scroll_appends_and_retains_selected_business_identity(
|
||||||
|
application: QApplication, immediate_async: None, kind: str
|
||||||
|
) -> None:
|
||||||
|
repository = ListRepository()
|
||||||
|
if kind == "template":
|
||||||
|
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||||
|
controller = dialog.infinite_list
|
||||||
|
elif kind == "order":
|
||||||
|
dialog = prescription.PrescriptionOrderListDialog(repository, prescription_id=77)
|
||||||
|
controller = dialog.infinite_list
|
||||||
|
else:
|
||||||
|
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||||
|
controller = dialog.pager
|
||||||
|
dialog.resize(980, 520)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
page_size = controller.page_size
|
||||||
|
assert dialog.table.rowCount() == page_size
|
||||||
|
assert not any(
|
||||||
|
button.text() in {"上一页", "下一页"} for button in dialog.findChildren(QPushButton)
|
||||||
|
)
|
||||||
|
dialog.table.selectRow(2)
|
||||||
|
scroll_to_bottom(dialog.table)
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert dialog.table.rowCount() == page_size * 2
|
||||||
|
assert dialog.table.currentRow() == 2
|
||||||
|
assert all(
|
||||||
|
dialog.table.item(row, column).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||||
|
for row in range(dialog.table.rowCount())
|
||||||
|
for column in range(dialog.table.columnCount())
|
||||||
|
)
|
||||||
|
repository.fail_page = 3
|
||||||
|
scroll_to_bottom(dialog.table)
|
||||||
|
assert controller.retry_button.isVisible()
|
||||||
|
assert dialog.table.rowCount() == page_size * 2
|
||||||
|
repository.fail_page = 0
|
||||||
|
controller.retry_button.click()
|
||||||
|
assert dialog.table.rowCount() == 45
|
||||||
|
assert not controller.has_more
|
||||||
|
assert dialog.table.currentRow() == 2
|
||||||
|
if kind == "template":
|
||||||
|
dialog.accept()
|
||||||
|
assert dialog.selected_template()["id"] == 3
|
||||||
|
elif kind == "ai":
|
||||||
|
dialog.accept()
|
||||||
|
assert dialog.selected_target().diagnosis_id == 3
|
||||||
|
assert dialog.selected_target().patient_id == 1003
|
||||||
|
else:
|
||||||
|
assert dialog.table.item(2, 0).data(Qt.ItemDataRole.UserRole)["id"] == 3
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_query_supersedes_pending_load_and_keeps_creator_scope(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
pending: list[tuple[Any, dict[str, Any]]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
prescription, "run_async", lambda function, **callbacks: pending.append((function, callbacks))
|
||||||
|
)
|
||||||
|
repository = ListRepository()
|
||||||
|
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
dialog.name_edit.setText("白术")
|
||||||
|
dialog.formula_combo.setCurrentIndex(1)
|
||||||
|
dialog.search()
|
||||||
|
assert len(pending) == 2
|
||||||
|
latest_function, latest_callbacks = pending[1]
|
||||||
|
latest_callbacks["on_success"](latest_function())
|
||||||
|
old_function, old_callbacks = pending[0]
|
||||||
|
old_callbacks["on_success"](old_function())
|
||||||
|
old_callbacks["on_error"](RuntimeError("过期错误"))
|
||||||
|
assert dialog.table.rowCount() == 15
|
||||||
|
assert not dialog.banner.isVisible()
|
||||||
|
assert repository.calls[0] == {
|
||||||
|
"page_no": 1,
|
||||||
|
"page_size": 15,
|
||||||
|
"prescription_name": "白术",
|
||||||
|
"formula_type": "主方",
|
||||||
|
"prescribing_creator_id": 42,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_picker_continues_when_first_page_has_no_valid_diagnosis_targets(
|
||||||
|
application: QApplication, immediate_async: None
|
||||||
|
) -> None:
|
||||||
|
class Repository(ListRepository):
|
||||||
|
def list_ai_patient_options(self, **filters: Any) -> dict[str, Any]:
|
||||||
|
result = self._list(**filters)
|
||||||
|
if filters["page_no"] == 1:
|
||||||
|
for row in result["lists"]:
|
||||||
|
row["id"] = -row["id"]
|
||||||
|
row["diagnosis_id"] = row["id"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
repository = Repository()
|
||||||
|
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
QTest.qWait(100)
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert dialog.table.rowCount() == 20
|
||||||
|
assert dialog.table.item(0, 3).text() == "21"
|
||||||
|
assert dialog.table.isVisible()
|
||||||
|
assert not dialog.empty_state.isVisible()
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_readonly_orders_scroll_and_switching_patient_resets_scope(
|
||||||
|
application: QApplication, immediate_async: None
|
||||||
|
) -> None:
|
||||||
|
repository = ListRepository()
|
||||||
|
dialog = diagnosis.DiagnosisDialog(
|
||||||
|
repository, permissions=PermissionSet(["tcm.diagnosis/patientOrders"])
|
||||||
|
)
|
||||||
|
dialog.open_for(
|
||||||
|
501, authoritative_detail={"id": 501, "patient_id": 301, "patient_name": "患者一"}
|
||||||
|
)
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
table = dialog._table_registry["orders"][0]
|
||||||
|
dialog.readonly_scroll.ensureWidgetVisible(table)
|
||||||
|
assert table.rowCount() == 10
|
||||||
|
assert dialog.orders_list.parentWidget() is dialog._readonly_sections["orders"]
|
||||||
|
table.selectRow(2)
|
||||||
|
scroll_to_bottom(table)
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
assert table.currentRow() == 2
|
||||||
|
assert table.item(2, 0).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||||
|
assert repository.calls[-1] == {
|
||||||
|
"page_no": 2,
|
||||||
|
"page_size": 10,
|
||||||
|
"patient_id": 301,
|
||||||
|
"context_diagnosis_id": 501,
|
||||||
|
"scene": "diagnosis_edit",
|
||||||
|
}
|
||||||
|
dialog.open_for(
|
||||||
|
502, authoritative_detail={"id": 502, "patient_id": 302, "patient_name": "患者二"}
|
||||||
|
)
|
||||||
|
assert table.rowCount() == 10
|
||||||
|
assert repository.calls[-1]["page_no"] == 1
|
||||||
|
assert repository.calls[-1]["patient_id"] == 302
|
||||||
|
assert repository.calls[-1]["context_diagnosis_id"] == 502
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Search disclosure preserves queries, permissions, focus and usable list space."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.filter_disclosure import FilterDisclosure
|
||||||
|
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(app):
|
||||||
|
for _ in range(3):
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(35)
|
||||||
|
|
||||||
|
|
||||||
|
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
value = function()
|
||||||
|
if on_success:
|
||||||
|
on_success(value)
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_fold_preserves_hidden_permission_controls_and_focus(application):
|
||||||
|
host = QWidget()
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
panel = QWidget(host)
|
||||||
|
form = QVBoxLayout(panel)
|
||||||
|
field = QLineEdit("保留查询", panel)
|
||||||
|
forbidden = QPushButton("无权限操作", panel)
|
||||||
|
forbidden.hide()
|
||||||
|
form.addWidget(field)
|
||||||
|
form.addWidget(forbidden)
|
||||||
|
disclosure = FilterDisclosure(host, [panel])
|
||||||
|
layout.addWidget(disclosure.button)
|
||||||
|
layout.addWidget(panel)
|
||||||
|
host.show()
|
||||||
|
settle(application)
|
||||||
|
assert not panel.isVisible()
|
||||||
|
assert disclosure.button.height() == 32
|
||||||
|
disclosure.button.setFocus()
|
||||||
|
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||||
|
settle(application)
|
||||||
|
assert panel.isVisible() and not forbidden.isVisible()
|
||||||
|
field.setFocus()
|
||||||
|
disclosure.set_expanded(False)
|
||||||
|
assert QApplication.focusWidget() is disclosure.button
|
||||||
|
disclosure.set_expanded(True)
|
||||||
|
assert field.text() == "保留查询" and forbidden.isHidden()
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||||
|
@pytest.mark.parametrize("size", [(1328, 860), (1158, 690), (816, 620)])
|
||||||
|
def test_compact_default_expands_for_query_and_retains_values(application, monkeypatch, kind, size):
|
||||||
|
module = prescriptions if kind == "issued" else prescription_library
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
|
page_type = module.PrescriptionsPage if kind == "issued" else module.PrescriptionLibraryPage
|
||||||
|
page = page_type(repository, {"*"}, session.user)
|
||||||
|
page.resize(*size)
|
||||||
|
page.show()
|
||||||
|
settle(application)
|
||||||
|
disclosure = page.filter_disclosure
|
||||||
|
assert not disclosure.expanded and page.filter_card.isHidden()
|
||||||
|
assert page.header.height() == 44
|
||||||
|
assert page.header.breadcrumb_label.isHidden() and page.header.subtitle_label.isHidden()
|
||||||
|
assert disclosure.button.isVisible()
|
||||||
|
assert disclosure.button.height() == 32
|
||||||
|
assert page.header.rect().contains(disclosure.button.mapTo(page.header, QPoint(0, 0)))
|
||||||
|
compact_height = page.table.height()
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
assert disclosure.expanded and page.filter_card.isVisible()
|
||||||
|
assert compact_height - page.table.height() >= 100
|
||||||
|
field = page.sn_filter if kind == "issued" else page.name_filter
|
||||||
|
assert field.isVisible() and page.query_button.isVisible() and page.reset_button.isVisible()
|
||||||
|
field.setText("不存在的查询")
|
||||||
|
# Folding must not issue a request or clear an unfinished query.
|
||||||
|
generation = page._generation
|
||||||
|
disclosure.set_expanded(False)
|
||||||
|
settle(application)
|
||||||
|
assert page._generation == generation
|
||||||
|
assert field.text() == "不存在的查询"
|
||||||
|
disclosure.set_expanded(True)
|
||||||
|
QTest.keyClick(field, Qt.Key.Key_Return)
|
||||||
|
settle(application)
|
||||||
|
assert page._generation > generation
|
||||||
|
assert disclosure.expanded
|
||||||
|
disclosure.set_expanded(False)
|
||||||
|
page.refresh()
|
||||||
|
page.resize(size[0] + 10, size[1])
|
||||||
|
settle(application)
|
||||||
|
assert not disclosure.expanded and not page.filter_card.isVisible()
|
||||||
|
disclosure.set_expanded(True)
|
||||||
|
page.reset_button.click()
|
||||||
|
settle(application)
|
||||||
|
assert field.text() == "" and page.table.rowCount() == 2
|
||||||
|
for timer in page.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Contract for the shared icon system.
|
||||||
|
|
||||||
|
Before ``ui/icons.py`` existed the product drew its glyphs from ten independent
|
||||||
|
painters that disagreed on stroke weight, design grid and palette. These tests
|
||||||
|
lock in the properties that keep the set reading as one family, so a new glyph
|
||||||
|
cannot quietly reintroduce a one-off weight or a clipped mark.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtGui import QColor, QImage
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.ui import icons
|
||||||
|
from doctor_workstation.ui.theme import COLORS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _rendered(kind: str, size: int) -> QImage:
|
||||||
|
return icons.pixmap(kind, "strong", size).toImage()
|
||||||
|
|
||||||
|
|
||||||
|
def _ink_bounds(image: QImage) -> tuple[int, int, int, int, int]:
|
||||||
|
"""Return ``(left, top, right, bottom, count)`` of visibly painted pixels."""
|
||||||
|
|
||||||
|
left, top = image.width(), image.height()
|
||||||
|
right = bottom = -1
|
||||||
|
count = 0
|
||||||
|
for y in range(image.height()):
|
||||||
|
for x in range(image.width()):
|
||||||
|
if image.pixelColor(x, y).alpha() > 24:
|
||||||
|
count += 1
|
||||||
|
left, top = min(left, x), min(top, y)
|
||||||
|
right, bottom = max(right, x), max(bottom, y)
|
||||||
|
return left, top, right, bottom, count
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_glyph_paints_inside_its_box_at_every_shipped_size(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""No glyph may touch the edge of its pixmap.
|
||||||
|
|
||||||
|
Clipping is what the old painters did whenever a call site asked for a size
|
||||||
|
other than the one the geometry was authored against - the shell's ``ai``
|
||||||
|
star lost its companion dot, and the prescription icons were drawn with
|
||||||
|
16 px geometry inside 14 px and 15 px boxes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for kind in icons.available_kinds():
|
||||||
|
for size in (14, 16, 18, 20, 24):
|
||||||
|
image = _rendered(kind, size)
|
||||||
|
device = image.width() # honours the device pixel ratio
|
||||||
|
left, top, right, bottom, count = _ink_bounds(image)
|
||||||
|
|
||||||
|
assert count > 0, f"{kind}@{size} painted nothing"
|
||||||
|
assert left > 0 and top > 0, f"{kind}@{size} is clipped at the top/left"
|
||||||
|
assert right < device - 1 and bottom < device - 1, (
|
||||||
|
f"{kind}@{size} is clipped at the bottom/right"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphs_fill_a_consistent_share_of_the_optical_box(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""Every mark lives in the same safe area, so none reads over- or undersized.
|
||||||
|
|
||||||
|
The set this replaces mixed glyphs that spanned the full 18 px box with a
|
||||||
|
``close`` cross that spanned only 7 px, which is why the title bar controls
|
||||||
|
never looked like siblings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for kind in icons.available_kinds():
|
||||||
|
image = _rendered(kind, 24)
|
||||||
|
device = image.width()
|
||||||
|
left, top, right, bottom, _count = _ink_bounds(image)
|
||||||
|
extent = max(right - left, bottom - top) / device
|
||||||
|
|
||||||
|
assert 0.5 <= extent <= 0.95, f"{kind} fills {extent:.2f} of its box"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stroke_weight_is_one_formula_across_the_shipped_size_range() -> None:
|
||||||
|
assert icons.stroke_px(24) == pytest.approx(2.0)
|
||||||
|
assert icons.stroke_px(18) == pytest.approx(1.5)
|
||||||
|
assert icons.stroke_px(16) == pytest.approx(4.0 / 3.0)
|
||||||
|
# Clamped so a small icon stays visible and a large one does not turn slab.
|
||||||
|
assert icons.stroke_px(8) == pytest.approx(1.25)
|
||||||
|
assert icons.stroke_px(64) == pytest.approx(2.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_icons_are_cached_so_list_rows_do_not_repaint_them(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""A list page builds one icon per action button per row on every refresh.
|
||||||
|
|
||||||
|
Without the cache that is a fresh ``QPainter`` run per button; the pages in
|
||||||
|
this product ask for the same handful of (kind, colour, size) triples over
|
||||||
|
and over, so the cache turns a per-row cost into a per-process one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
icons.clear_cache()
|
||||||
|
for _ in range(50):
|
||||||
|
icons.icon("eye", "accent", 15)
|
||||||
|
icons.icon("trash", "danger", 15)
|
||||||
|
|
||||||
|
# Two glyphs, each painted once in its requested colour and once disabled.
|
||||||
|
assert icons._cached_pixmap.cache_info().misses == 4
|
||||||
|
# The remaining 98 calls are answered from the icon cache without painting.
|
||||||
|
assert icons._cached_icon.cache_info().hits == 98
|
||||||
|
|
||||||
|
|
||||||
|
def test_colour_roles_resolve_to_the_palette_not_to_per_call_site_hexes() -> None:
|
||||||
|
assert icons.resolve_color("accent") == COLORS["indigo"]
|
||||||
|
assert icons.resolve_color("danger") == COLORS["danger"]
|
||||||
|
assert icons.resolve_color("muted") == COLORS["muted"]
|
||||||
|
# A literal colour still passes through for the few bespoke tints that remain.
|
||||||
|
assert icons.resolve_color("#8268E8") == "#8268E8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_icon_carries_its_own_selected_and_disabled_pixmaps(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""Navigation rows invert on selection, so the icon has to invert with them."""
|
||||||
|
|
||||||
|
icon = icons.state_icon("patients", size=18, normal="muted", checked="inverse")
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
|
||||||
|
off = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.Off).toImage()
|
||||||
|
on = icon.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On).toImage()
|
||||||
|
|
||||||
|
assert off != on
|
||||||
|
assert _dominant_ink(on) == QColor("#FFFFFF")
|
||||||
|
|
||||||
|
|
||||||
|
def _dominant_ink(image: QImage) -> QColor:
|
||||||
|
for y in range(image.height()):
|
||||||
|
for x in range(image.width()):
|
||||||
|
colour = image.pixelColor(x, y)
|
||||||
|
if colour.alpha() > 240:
|
||||||
|
colour.setAlpha(255)
|
||||||
|
return colour
|
||||||
|
raise AssertionError("no opaque pixel found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_glyphs_are_visually_distinct(application: QApplication) -> None:
|
||||||
|
"""Six sidebar entries need six silhouettes.
|
||||||
|
|
||||||
|
The reception detail tabs previously shared three near-identical
|
||||||
|
"document with lines" marks, which made them unreadable at 16 px.
|
||||||
|
"""
|
||||||
|
|
||||||
|
renders = {
|
||||||
|
kind: _rendered(kind, 24).constBits().tobytes()
|
||||||
|
for kind in (
|
||||||
|
"reception",
|
||||||
|
"appointments",
|
||||||
|
"prescription_library",
|
||||||
|
"prescriptions",
|
||||||
|
"patients",
|
||||||
|
"consultations",
|
||||||
|
"report",
|
||||||
|
"meds",
|
||||||
|
"daily",
|
||||||
|
"followup",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(set(renders.values())) == len(renders)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Incremental requests retain business rows and survive query changes/errors."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||||
|
from doctor_workstation.ui.widgets import get_value, page_items
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
class Requests:
|
||||||
|
def __init__(self):
|
||||||
|
self.pending = []
|
||||||
|
|
||||||
|
def __call__(self, function, **callbacks):
|
||||||
|
self.pending.append((function, callbacks))
|
||||||
|
|
||||||
|
def finish(self, index=0, error=None):
|
||||||
|
fn, cb = self.pending.pop(index)
|
||||||
|
if error:
|
||||||
|
cb["on_error"](error)
|
||||||
|
else:
|
||||||
|
cb["on_success"](fn())
|
||||||
|
cb["on_finished"]()
|
||||||
|
|
||||||
|
|
||||||
|
def setup(app, size=2):
|
||||||
|
host = QWidget()
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
table = QTableWidget(0, 2)
|
||||||
|
footer = InfiniteList(size)
|
||||||
|
footer.bind(table)
|
||||||
|
layout.addWidget(table)
|
||||||
|
layout.addWidget(footer)
|
||||||
|
host.resize(420, 220)
|
||||||
|
seen = []
|
||||||
|
errors = []
|
||||||
|
jobs = Requests()
|
||||||
|
|
||||||
|
def apply(result):
|
||||||
|
rows = page_items(result)
|
||||||
|
seen.append(result)
|
||||||
|
table.setRowCount(len(rows))
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
for col in range(2):
|
||||||
|
item = QTableWidgetItem(str(row["id"]))
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||||
|
if col == 1:
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(Qt.CheckState.Unchecked)
|
||||||
|
table.setItem(i, col, item)
|
||||||
|
|
||||||
|
return host, table, footer, seen, errors, jobs, apply
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_deduplicates_and_preserves_selection_checks_metadata(app):
|
||||||
|
host, table, f, seen, errors, jobs, apply = setup(app)
|
||||||
|
pages = {
|
||||||
|
1: {"items": [{"id": 1}, {"id": 2}], "total": 4, "extend": {"scope": "mine"}},
|
||||||
|
2: {"items": [{"id": 2}, {"id": 3}], "total": 4},
|
||||||
|
3: {"items": [{"id": 4}], "total": 4},
|
||||||
|
}
|
||||||
|
f.reload(lambda p: pages[p], apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
table.selectRow(1)
|
||||||
|
table.item(1, 1).setCheckState(Qt.CheckState.Checked)
|
||||||
|
f.load_more()
|
||||||
|
f.load_more()
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
jobs.finish()
|
||||||
|
assert [r["id"] for r in f.rows] == [1, 2, 3]
|
||||||
|
assert table.currentRow() == 1 and table.item(1, 1).checkState() == Qt.CheckState.Checked
|
||||||
|
assert get_value(seen[-1], "extend.scope") == "mine"
|
||||||
|
f.load_more()
|
||||||
|
jobs.finish()
|
||||||
|
assert not f.has_more
|
||||||
|
assert table.rowCount() == 4 and not errors
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_query_discards_old_success_and_error(app):
|
||||||
|
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||||
|
f.reload(
|
||||||
|
lambda p: {"items": [{"id": 1}], "total": 1},
|
||||||
|
apply,
|
||||||
|
errors.append,
|
||||||
|
runner=jobs,
|
||||||
|
query_key="a",
|
||||||
|
)
|
||||||
|
f.reload(
|
||||||
|
lambda p: {"items": [{"id": 2}], "total": 1},
|
||||||
|
apply,
|
||||||
|
errors.append,
|
||||||
|
runner=jobs,
|
||||||
|
query_key="b",
|
||||||
|
)
|
||||||
|
jobs.finish(1)
|
||||||
|
jobs.finish(0, error=RuntimeError("stale"))
|
||||||
|
assert f.rows == [{"id": 2}] and not errors and not f.loading
|
||||||
|
f.invalidate()
|
||||||
|
assert not f.has_more
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_failure_retry_and_empty_end_are_bounded(app):
|
||||||
|
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fetch(p):
|
||||||
|
calls.append(p)
|
||||||
|
return {"items": [{"id": 1}, {"id": 2}] if p == 1 else [], "total": 8}
|
||||||
|
|
||||||
|
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
f.load_more()
|
||||||
|
jobs.finish(error=RuntimeError("offline"))
|
||||||
|
assert t.rowCount() == 2 and f.page == 1 and not f.retry_button.isHidden()
|
||||||
|
f.load_more()
|
||||||
|
assert not jobs.pending
|
||||||
|
f.retry()
|
||||||
|
jobs.finish()
|
||||||
|
assert f.page == 2 and not f.has_more
|
||||||
|
assert calls == [1, 2] and len(errors) == 1
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_retains_loaded_prefix_until_complete(app):
|
||||||
|
host, t, f, seen, errors, jobs, apply = setup(app)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fetch(p):
|
||||||
|
calls.append(p)
|
||||||
|
return {"items": [{"id": p * 2 - 1}, {"id": p * 2}], "total": 6}
|
||||||
|
|
||||||
|
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
f.load_more()
|
||||||
|
jobs.finish()
|
||||||
|
assert t.rowCount() == 4
|
||||||
|
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
assert t.rowCount() == 4 and f.loading
|
||||||
|
jobs.finish()
|
||||||
|
assert t.rowCount() == 4 and f.page == 2
|
||||||
|
assert calls == [1, 2, 1, 2]
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_autofill_then_load_at_bottom(app):
|
||||||
|
host, t, f, seen, errors, jobs, apply = setup(app, size=12)
|
||||||
|
|
||||||
|
def fetch(p):
|
||||||
|
return {"items": [{"id": i} for i in range((p - 1) * 12, p * 12)], "total": 36}
|
||||||
|
|
||||||
|
host.show()
|
||||||
|
app.processEvents()
|
||||||
|
f.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish()
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(60)
|
||||||
|
assert not jobs.pending
|
||||||
|
t.verticalScrollBar().setValue(t.verticalScrollBar().maximum())
|
||||||
|
QTest.qWait(60)
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
old = t.verticalScrollBar().value()
|
||||||
|
jobs.finish()
|
||||||
|
assert t.verticalScrollBar().value() == old and t.rowCount() == 24
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_during_slow_request_keeps_work_and_updates_consumer_callbacks(app):
|
||||||
|
host, table, footer, seen, errors, jobs, apply = setup(app)
|
||||||
|
completed = []
|
||||||
|
|
||||||
|
def fetch(page):
|
||||||
|
return {"items": [{"id": page * 2 - 1}, {"id": page * 2}], "total": 6}
|
||||||
|
|
||||||
|
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
footer.reload(
|
||||||
|
fetch,
|
||||||
|
apply,
|
||||||
|
errors.append,
|
||||||
|
runner=jobs,
|
||||||
|
query_key="a",
|
||||||
|
on_finished=lambda: completed.append("latest"),
|
||||||
|
)
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
jobs.finish()
|
||||||
|
assert completed == ["latest"] and table.rowCount() == 2
|
||||||
|
footer.load_more()
|
||||||
|
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
jobs.finish()
|
||||||
|
assert table.rowCount() == 4 and footer.page == 2
|
||||||
|
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
jobs.finish() # Prefix page 2 now pending.
|
||||||
|
footer.reload(fetch, apply, errors.append, runner=jobs, query_key="a")
|
||||||
|
assert len(jobs.pending) == 1
|
||||||
|
jobs.finish()
|
||||||
|
assert footer.page == 2 and not footer.loading and table.rowCount() == 4
|
||||||
|
host.close()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, QTimer
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui import apply_theme
|
||||||
|
from doctor_workstation.ui.pages import (
|
||||||
|
appointments,
|
||||||
|
consultations,
|
||||||
|
patients,
|
||||||
|
prescription_library,
|
||||||
|
prescriptions,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.widgets import PageHeader
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def immediate(function: Any, *args: Any, on_success=None, on_error=None,
|
||||||
|
on_finished=None, **kwargs: Any) -> object:
|
||||||
|
try:
|
||||||
|
result = function(*args, **kwargs)
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
return object()
|
||||||
|
|
||||||
|
modules = (appointments, consultations, patients, prescription_library, prescriptions)
|
||||||
|
for module in modules:
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(kind: str, width: int = 1040):
|
||||||
|
repo = DemoDoctorRepository()
|
||||||
|
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||||
|
classes = {
|
||||||
|
"appointments": appointments.AppointmentsPage,
|
||||||
|
"patients": patients.PatientsPage,
|
||||||
|
"consultations": consultations.ConsultationsPage,
|
||||||
|
"prescriptions": prescriptions.PrescriptionsPage,
|
||||||
|
"prescription_library": prescription_library.PrescriptionLibraryPage,
|
||||||
|
}
|
||||||
|
page = classes[kind](repo, permissions=session.permissions, current_user=session.user)
|
||||||
|
opened.append(page)
|
||||||
|
page.resize(width, 700)
|
||||||
|
page.show()
|
||||||
|
page.refresh()
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
return page
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for page in opened:
|
||||||
|
for timer in page.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["appointments", "patients", "consultations", "prescriptions", "prescription_library"])
|
||||||
|
def test_list_header_fits_the_real_bundled_font(page_factory, kind: str) -> None:
|
||||||
|
page = page_factory(kind)
|
||||||
|
header = page.findChild(PageHeader)
|
||||||
|
assert header is not None
|
||||||
|
for label in header.findChildren(QLabel):
|
||||||
|
if label.isVisible() and label.text() and label.property("role"):
|
||||||
|
assert label.height() >= label.fontMetrics().height(), (
|
||||||
|
kind, label.text(), label.height(), label.fontMetrics().height()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["patients", "prescription_library"])
|
||||||
|
@pytest.mark.parametrize("width", [1040, 1200])
|
||||||
|
def test_row_actions_are_fully_reachable_at_each_width(page_factory, kind, width):
|
||||||
|
page = page_factory(kind, width)
|
||||||
|
table = page.patient_workspace.table if kind == "patients" else page.table
|
||||||
|
assert table.rowCount() > 0
|
||||||
|
assert table.horizontalHeader().visualIndex(9) == 9
|
||||||
|
actions = table.cellWidget(0, 9)
|
||||||
|
assert actions is not None
|
||||||
|
assert table.horizontalScrollBar().value() == 0
|
||||||
|
# Both approved layouts put actions after the nine data columns. Preserve
|
||||||
|
# full-size controls and the existing reachability assertion after scrolling.
|
||||||
|
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||||
|
QApplication.processEvents()
|
||||||
|
assert actions.geometry().left() >= 0
|
||||||
|
assert actions.geometry().right() < table.viewport().width()
|
||||||
|
assert actions.height() >= actions.minimumSizeHint().height()
|
||||||
|
for child in actions.findChildren(QPushButton):
|
||||||
|
assert actions.rect().contains(child.mapTo(actions, QPoint()))
|
||||||
|
assert actions.rect().contains(child.mapTo(actions, child.rect().bottomRight()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_appointment_detail_lines_and_cancel_fit_without_overlapping(page_factory) -> None:
|
||||||
|
page = page_factory("appointments")
|
||||||
|
table = page.table
|
||||||
|
assert table.rowCount() > 0
|
||||||
|
host = table.cellWidget(0, 4)
|
||||||
|
assert host.width() >= host.minimumSizeHint().width()
|
||||||
|
assert host.height() >= host.minimumSizeHint().height()
|
||||||
|
for label in host.findChildren(QLabel):
|
||||||
|
assert label.height() >= label.fontMetrics().height()
|
||||||
|
assert host.rect().contains(label.geometry())
|
||||||
|
cancel = next(button for button in host.findChildren(QPushButton) if button.text() == "取消")
|
||||||
|
for label in host.findChildren(QLabel):
|
||||||
|
assert not label.geometry().intersects(cancel.geometry())
|
||||||
|
# Underlying text is still available to sorting while a delegate paints only
|
||||||
|
# the selection surface beneath the actual controls.
|
||||||
|
assert table.item(0, 4).text()
|
||||||
|
assert isinstance(table.itemDelegateForColumn(4), appointments._AppointmentInfoDelegate)
|
||||||
|
assert table.horizontalScrollBar().maximum() == 0
|
||||||
|
search = page.findChild(QPushButton, "AppointmentSearchButton")
|
||||||
|
assert search.height() == page.patient_input.height()
|
||||||
|
assert all(button.height() == search.height() for button in page.date_buttons.values() if button.isVisible())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["patients", "prescriptions", "prescription_library", "consultations"])
|
||||||
|
def test_filter_actions_align_with_their_inputs(page_factory, kind: str) -> None:
|
||||||
|
page = page_factory(kind)
|
||||||
|
if kind == "patients":
|
||||||
|
controls = page.patient_workspace
|
||||||
|
widgets = (controls.keyword_edit, controls.search_button, controls.reset_button)
|
||||||
|
statuses = tuple(controls.status_buttons.values())
|
||||||
|
assert len({widget.height() for widget in statuses}) == 1
|
||||||
|
assert len({widget.mapTo(controls.status_host, QPoint()).y() for widget in statuses}) == 1
|
||||||
|
for widget in statuses:
|
||||||
|
assert controls.status_host.rect().contains(widget.geometry())
|
||||||
|
assert widget.height() >= widget.fontMetrics().height()
|
||||||
|
elif kind == "prescriptions":
|
||||||
|
widgets = (page.sn_filter, page.query_button, page.reset_button, page.doctor_filter.button)
|
||||||
|
elif kind == "prescription_library":
|
||||||
|
widgets = (page.name_filter, page.query_button, page.reset_button)
|
||||||
|
else:
|
||||||
|
widgets = (page.search_button, page.reset_button, page.custom_date_edit)
|
||||||
|
assert len({widget.height() for widget in widgets}) == 1
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Contract for the shared motion system.
|
||||||
|
|
||||||
|
The product previously had no `QPropertyAnimation` at all, so these tests exist
|
||||||
|
to keep the two things that make added motion a liability from creeping back:
|
||||||
|
an animation that outlives or destroys the object it is animating, and a
|
||||||
|
graphics effect left attached after a fade, which would quietly move a whole
|
||||||
|
subtree onto Qt's offscreen composite path for the rest of the session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
# The module disables itself under the offscreen platform so widget grabs in the
|
||||||
|
# other suites capture settled frames; these tests are about the animation, so
|
||||||
|
# they opt back in.
|
||||||
|
os.environ["DOCTOR_MOTION"] = "on"
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QEvent, QPoint, QPointF, Qt
|
||||||
|
from PySide6.QtGui import QWheelEvent
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QLabel,
|
||||||
|
QScrollArea,
|
||||||
|
QStackedWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from doctor_workstation.ui import motion
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _stack(application: QApplication) -> tuple[QWidget, QStackedWidget, QLabel]:
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(320, 120)
|
||||||
|
layout = QVBoxLayout(host)
|
||||||
|
stack = QStackedWidget()
|
||||||
|
first, second = QLabel("A"), QLabel("B")
|
||||||
|
stack.addWidget(first)
|
||||||
|
stack.addWidget(second)
|
||||||
|
layout.addWidget(stack)
|
||||||
|
host.show()
|
||||||
|
application.processEvents()
|
||||||
|
return host, stack, second
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_transition_fades_and_settles_upward(application: QApplication) -> None:
|
||||||
|
host, stack, incoming = _stack(application)
|
||||||
|
origin = incoming.pos()
|
||||||
|
|
||||||
|
motion.switch_stack(stack, 1)
|
||||||
|
|
||||||
|
animations = incoming._doctor_motion
|
||||||
|
rise, fade = animations["enter"], animations["fade"]
|
||||||
|
assert rise.duration() == fade.duration() == motion.BASE
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
for at in (0, motion.BASE // 2, motion.BASE - 1):
|
||||||
|
rise.setCurrentTime(at)
|
||||||
|
fade.setCurrentTime(at)
|
||||||
|
samples.append((incoming.pos().y() - origin.y(), incoming.graphicsEffect().opacity()))
|
||||||
|
|
||||||
|
offsets = [offset for offset, _ in samples]
|
||||||
|
opacities = [opacity for _, opacity in samples]
|
||||||
|
assert offsets == sorted(offsets, reverse=True), "the page must settle downward-to-up"
|
||||||
|
assert opacities == sorted(opacities), "opacity must rise monotonically"
|
||||||
|
assert offsets[0] == motion.RISE and opacities[0] == pytest.approx(0.0)
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fade_detaches_its_graphics_effect_when_it_finishes(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""A left-behind opacity effect is a permanent frame-rate tax, not a leak."""
|
||||||
|
|
||||||
|
host, stack, incoming = _stack(application)
|
||||||
|
motion.switch_stack(stack, 1)
|
||||||
|
assert incoming.graphicsEffect() is not None
|
||||||
|
|
||||||
|
fade = incoming._doctor_motion["fade"]
|
||||||
|
fade.setCurrentTime(fade.duration())
|
||||||
|
# The detach is deferred by one event-loop turn on purpose, so that the
|
||||||
|
# effect is not destroyed from inside the signal it is emitting.
|
||||||
|
application.processEvents()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert incoming.graphicsEffect() is None
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_motion_can_be_turned_off_without_leaving_widgets_mid_state(
|
||||||
|
application: QApplication,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("DOCTOR_MOTION", "off")
|
||||||
|
assert motion.reduced_motion()
|
||||||
|
|
||||||
|
host, stack, incoming = _stack(application)
|
||||||
|
origin = incoming.pos()
|
||||||
|
motion.switch_stack(stack, 1)
|
||||||
|
|
||||||
|
assert stack.currentIndex() == 1
|
||||||
|
assert incoming.pos() == origin
|
||||||
|
assert incoming.graphicsEffect() is None
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _scroll_area(application: QApplication) -> QScrollArea:
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidgetResizable(True)
|
||||||
|
area.setWidget(QLabel("\n".join(f"line {index}" for index in range(300))))
|
||||||
|
area.resize(300, 200)
|
||||||
|
area.show()
|
||||||
|
application.processEvents()
|
||||||
|
return area
|
||||||
|
|
||||||
|
|
||||||
|
def _wheel(delta: int) -> QWheelEvent:
|
||||||
|
return QWheelEvent(
|
||||||
|
QPointF(50, 50),
|
||||||
|
QPointF(50, 50),
|
||||||
|
QPoint(0, 0),
|
||||||
|
QPoint(0, delta),
|
||||||
|
Qt.MouseButton.NoButton,
|
||||||
|
Qt.KeyboardModifier.NoModifier,
|
||||||
|
Qt.ScrollPhase.NoScrollPhase,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_scrolling_is_eased_rather_than_jumped(application: QApplication) -> None:
|
||||||
|
area = _scroll_area(application)
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
scroller = area._doctor_smooth_scroll
|
||||||
|
bar = area.verticalScrollBar()
|
||||||
|
|
||||||
|
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||||
|
animation = scroller._animation
|
||||||
|
assert animation.endValue() == motion.SCROLL_STEP
|
||||||
|
|
||||||
|
values = []
|
||||||
|
for at in (0, 60, 120, animation.duration() - 1):
|
||||||
|
animation.setCurrentTime(at)
|
||||||
|
values.append(bar.value())
|
||||||
|
|
||||||
|
assert values[0] == 0
|
||||||
|
assert values == sorted(values)
|
||||||
|
assert values[-1] < motion.SCROLL_STEP, "an eased curve never reaches its end early"
|
||||||
|
animation.stop()
|
||||||
|
area.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_wheel_at_either_end_is_handed_back_to_the_enclosing_area(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""Swallowing the wheel at the extremes is what makes nested panes feel stuck."""
|
||||||
|
|
||||||
|
area = _scroll_area(application)
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
scroller = area._doctor_smooth_scroll
|
||||||
|
bar = area.verticalScrollBar()
|
||||||
|
|
||||||
|
bar.setValue(bar.minimum())
|
||||||
|
application.processEvents()
|
||||||
|
assert not scroller.eventFilter(area.viewport(), _wheel(120))
|
||||||
|
assert scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||||
|
|
||||||
|
scroller._animation.stop()
|
||||||
|
bar.setValue(bar.maximum())
|
||||||
|
application.processEvents()
|
||||||
|
assert not scroller.eventFilter(area.viewport(), _wheel(-120))
|
||||||
|
area.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_gestures_are_left_alone(application: QApplication) -> None:
|
||||||
|
area = _scroll_area(application)
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
scroller = area._doctor_smooth_scroll
|
||||||
|
|
||||||
|
ctrl_wheel = QWheelEvent(
|
||||||
|
QPointF(50, 50),
|
||||||
|
QPointF(50, 50),
|
||||||
|
QPoint(0, 0),
|
||||||
|
QPoint(0, -120),
|
||||||
|
Qt.MouseButton.NoButton,
|
||||||
|
Qt.KeyboardModifier.ControlModifier,
|
||||||
|
Qt.ScrollPhase.NoScrollPhase,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not scroller.eventFilter(area.viewport(), ctrl_wheel)
|
||||||
|
area.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_is_idempotent(application: QApplication) -> None:
|
||||||
|
area = _scroll_area(application)
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
first = area._doctor_smooth_scroll
|
||||||
|
motion.install_smooth_scroll(area)
|
||||||
|
|
||||||
|
assert area._doctor_smooth_scroll is first
|
||||||
|
area.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reading_surfaces_get_smooth_scrolling_from_the_theme(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
"""A QScrollArea should not have to opt in page by page."""
|
||||||
|
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
apply_theme(application)
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidget(QLabel("content"))
|
||||||
|
area.show()
|
||||||
|
area.ensurePolished()
|
||||||
|
application.sendEvent(area, QEvent(QEvent.Type.Polish))
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert getattr(area, "_doctor_smooth_scroll", None) is not None
|
||||||
|
area.close()
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
"""Order contracts at risk when filters, summary and table receive the blue layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
|
from doctor_workstation.ui.pages.patients import PatientOrdersWorkspace, PatientsPage
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[dict[str, Any]] = []
|
||||||
|
self.rows = [
|
||||||
|
{
|
||||||
|
"id": 901 + index,
|
||||||
|
"order_no": f"PO2026081000{index + 1}",
|
||||||
|
"patient_name": name,
|
||||||
|
"patient_phone_masked": f"186****482{index}",
|
||||||
|
"recipient_phone": f"1860000482{index}",
|
||||||
|
"prescription_id": 801 + index,
|
||||||
|
"diagnosis_id": 501 + index,
|
||||||
|
"amount": 368 + index,
|
||||||
|
"effective_amount": 368 + index,
|
||||||
|
"prescription_audit_status": 1,
|
||||||
|
"payment_slip_audit_status": 1,
|
||||||
|
"fulfillment_status": 2,
|
||||||
|
"assistant_name": "周医助",
|
||||||
|
"doctor_name": "陈医生(演示)",
|
||||||
|
"creator_name": "周医助",
|
||||||
|
# Source-only values must not fill display fields that are absent.
|
||||||
|
"create_time": "2026-09-05 10:15:00",
|
||||||
|
"pay_orders": [{"id": 2001 + index, "pay_amount": 368 + index}],
|
||||||
|
}
|
||||||
|
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||||
|
]
|
||||||
|
self.total = 47
|
||||||
|
self.summary = {
|
||||||
|
"orders": 47,
|
||||||
|
"amount": 12368.5,
|
||||||
|
"pending": 8,
|
||||||
|
"completed": 9,
|
||||||
|
"rejected": 2,
|
||||||
|
"rejection_rate": 4.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.queries.append(query)
|
||||||
|
return {
|
||||||
|
"lists": deepcopy(self.rows),
|
||||||
|
"count": self.total,
|
||||||
|
"extend": {
|
||||||
|
"scope": {"label": "测试部门订单范围"},
|
||||||
|
"summary": deepcopy(self.summary),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"lists": [{"id": 501, "diagnosis_id": 501, "patient_name": "阿青"}],
|
||||||
|
"count": 1,
|
||||||
|
"extend": {"scope": {"label": "测试患者范围"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def patient_progress(self, **_query: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"lists": [],
|
||||||
|
"count": 0,
|
||||||
|
"extend": {"scope": {"label": "测试面诊范围"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _click(widget, application: QApplication) -> None:
|
||||||
|
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def _rect_in(widget, parent) -> QRect:
|
||||||
|
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||||
|
|
||||||
|
|
||||||
|
def _banner_text(workspace: PatientOrdersWorkspace) -> str:
|
||||||
|
return " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args: Any, **_kwargs: Any):
|
||||||
|
pytest.fail("Order visual tests must use only local fixture data")
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(*, permissions=("*",), width=1270, height=680, page=False):
|
||||||
|
repository = _Repository()
|
||||||
|
widget = (
|
||||||
|
PatientsPage(repository, permissions=PermissionSet(list(permissions)))
|
||||||
|
if page
|
||||||
|
else PatientOrdersWorkspace(repository, PermissionSet(list(permissions)))
|
||||||
|
)
|
||||||
|
opened.append(widget)
|
||||||
|
widget.resize(width, height)
|
||||||
|
widget.show()
|
||||||
|
if not page:
|
||||||
|
widget.refresh()
|
||||||
|
_settle(application)
|
||||||
|
return widget, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for widget in opened:
|
||||||
|
for timer in widget.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_defaults_and_reset_keep_edited_dates_but_remove_query_limit(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
default_query = {
|
||||||
|
"keyword": "",
|
||||||
|
"prescription_audit_status": None,
|
||||||
|
"payment_slip_audit_status": None,
|
||||||
|
"fulfillment_status": None,
|
||||||
|
"start_date": "",
|
||||||
|
"end_date": "",
|
||||||
|
"page_no": 1,
|
||||||
|
"page_size": 15,
|
||||||
|
}
|
||||||
|
assert repository.queries[0] == default_query
|
||||||
|
assert all(query == {**default_query, "page_no": index + 1}
|
||||||
|
for index, query in enumerate(repository.queries))
|
||||||
|
assert not workspace.use_dates.isChecked()
|
||||||
|
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||||
|
assert workspace.start_date.date() == QDate.currentDate().addDays(-30)
|
||||||
|
assert workspace.end_date.date() == QDate.currentDate()
|
||||||
|
assert all(
|
||||||
|
combo.currentData() is None
|
||||||
|
for combo in (workspace.rx_audit, workspace.pay_audit, workspace.fulfillment)
|
||||||
|
)
|
||||||
|
|
||||||
|
before_filters = len(repository.queries)
|
||||||
|
workspace.keyword_edit.setText(" 林医生 ")
|
||||||
|
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||||
|
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||||
|
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||||
|
assert len(repository.queries) == before_filters # Changing status does not auto-submit.
|
||||||
|
_click(workspace.use_dates, application)
|
||||||
|
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||||
|
start, end = QDate(2026, 7, 3), QDate(2026, 8, 8)
|
||||||
|
workspace.start_date.setDate(start)
|
||||||
|
workspace.end_date.setDate(end)
|
||||||
|
_click(workspace.search_button, application)
|
||||||
|
assert repository.queries[before_filters] == {
|
||||||
|
**default_query,
|
||||||
|
"keyword": "林医生",
|
||||||
|
"prescription_audit_status": 2,
|
||||||
|
"payment_slip_audit_status": 0,
|
||||||
|
"fulfillment_status": 9,
|
||||||
|
"start_date": "2026-07-03",
|
||||||
|
"end_date": "2026-08-08",
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace.pager.load_more()
|
||||||
|
assert repository.queries[-1]["page_no"] == 2
|
||||||
|
assert repository.queries[-1]["page_size"] == 15
|
||||||
|
workspace.keyword_edit.setText("新检索")
|
||||||
|
QTest.keyClick(workspace.keyword_edit, Qt.Key.Key_Return)
|
||||||
|
_settle(application)
|
||||||
|
assert next(query for query in repository.queries if query["keyword"] == "新检索")["page_no"] == 1
|
||||||
|
workspace.start_date.setDate(end.addDays(1))
|
||||||
|
before = len(repository.queries)
|
||||||
|
_click(workspace.search_button, application)
|
||||||
|
assert len(repository.queries) == before
|
||||||
|
assert workspace.banner.isVisible()
|
||||||
|
assert "开始日期不能晚于结束日期" in _banner_text(workspace)
|
||||||
|
|
||||||
|
_click(workspace.reset_button, application)
|
||||||
|
assert repository.queries[-1] == {**default_query, "page_no": repository.queries[-1]["page_no"]}
|
||||||
|
assert workspace.keyword_edit.text() == ""
|
||||||
|
assert not workspace.use_dates.isChecked()
|
||||||
|
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||||
|
assert workspace.start_date.date() == end.addDays(1)
|
||||||
|
assert workspace.end_date.date() == end
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_twelve_columns_keep_source_text_distinct_ids_and_missing_display_fields(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
row = repository.rows[0]
|
||||||
|
row["order_no"] = "PO-非常长的订单号-20260905-ABCDEFGHIJ"
|
||||||
|
row["patient_name"] = "用于检查省略与提示的较长患者姓名"
|
||||||
|
row["doctor_name"] = "用于检查分行显示与完整提示信息的开方医生(演示)"
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
table = workspace.table
|
||||||
|
assert [table.horizontalHeaderItem(column).text() for column in range(table.columnCount())] == [
|
||||||
|
"订单",
|
||||||
|
"患者",
|
||||||
|
"处方 / 诊单",
|
||||||
|
"有效金额",
|
||||||
|
"处方审核",
|
||||||
|
"支付审核",
|
||||||
|
"履约",
|
||||||
|
"支付单",
|
||||||
|
"归属助理",
|
||||||
|
"开方人",
|
||||||
|
"创建人",
|
||||||
|
"创建时间",
|
||||||
|
]
|
||||||
|
index = next(
|
||||||
|
index
|
||||||
|
for index in range(table.rowCount())
|
||||||
|
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == 901
|
||||||
|
)
|
||||||
|
assert table.item(index, 0).text() == f"{row['order_no']} · #901"
|
||||||
|
assert table.item(index, 1).text() == f"{row['patient_name']} · 186****4820"
|
||||||
|
assert table.item(index, 2).text() == "#801 / #501"
|
||||||
|
assert table.item(index, 9).text() == row["doctor_name"]
|
||||||
|
assert table.item(index, 0).toolTip() == table.item(index, 0).text()
|
||||||
|
assert table.item(index, 1).toolTip() == table.item(index, 1).text()
|
||||||
|
assert table.item(index, 9).toolTip() == row["doctor_name"]
|
||||||
|
assert table.item(index, 7).text() == "—"
|
||||||
|
assert table.item(index, 11).text() == "—"
|
||||||
|
for column in range(12):
|
||||||
|
assert not table.isColumnHidden(column)
|
||||||
|
assert table.item(index, column).data(Qt.ItemDataRole.UserRole) == row
|
||||||
|
assert row["recipient_phone"] not in table.item(index, column).text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_selection_buttons_and_menu_emit_current_order_resource_ids(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, _repository = workspace_factory()
|
||||||
|
table = workspace.table
|
||||||
|
emitted = []
|
||||||
|
workspace.diagnosis_requested.connect(lambda row: emitted.append(("diagnosis", row)))
|
||||||
|
workspace.detail_requested.connect(lambda row: emitted.append(("detail", row)))
|
||||||
|
workspace.action_requested.connect(lambda key, row: emitted.append((key, row)))
|
||||||
|
table.sortItems(0, Qt.SortOrder.AscendingOrder)
|
||||||
|
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||||
|
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id
|
||||||
|
for index in (0, 2, 1):
|
||||||
|
table.selectRow(index)
|
||||||
|
_settle(application)
|
||||||
|
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
workspace.diagnosis_button.click()
|
||||||
|
workspace.detail_button.click()
|
||||||
|
next(
|
||||||
|
action for action in workspace.action_menu.actions() if action.text() == "确认发货"
|
||||||
|
).trigger()
|
||||||
|
assert [kind for kind, _row in emitted[-3:]] == ["diagnosis", "detail", "ship"]
|
||||||
|
for _kind, row in emitted[-3:]:
|
||||||
|
assert (row["id"], row["prescription_id"], row["diagnosis_id"]) == (
|
||||||
|
expected["id"],
|
||||||
|
expected["prescription_id"],
|
||||||
|
expected["diagnosis_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("permissions", "menu", "detail"),
|
||||||
|
[
|
||||||
|
(("*",), ["撤回支付审核", "修改快递单号", "确认发货", "上传药房"], True),
|
||||||
|
(("tcm.prescriptionOrder/detail",), [], True),
|
||||||
|
(("tcm.prescriptionOrder/ship",), ["确认发货"], False),
|
||||||
|
(("tcm.prescriptionOrder.detail", "tcm.prescriptionOrder.ship"), [], False),
|
||||||
|
((), [], False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_selected_order_menu_requires_canonical_permissions(
|
||||||
|
application: QApplication, workspace_factory, permissions, menu, detail
|
||||||
|
) -> None:
|
||||||
|
workspace, _repository = workspace_factory(permissions=permissions)
|
||||||
|
assert [action.text() for action in workspace.action_menu.actions()] == menu
|
||||||
|
assert workspace.action_button.isVisible() is bool(menu)
|
||||||
|
assert workspace.detail_button.isVisible() is detail
|
||||||
|
assert workspace.diagnosis_button.isEnabled()
|
||||||
|
emitted = []
|
||||||
|
workspace.detail_requested.connect(lambda row: emitted.append(row["id"]))
|
||||||
|
workspace._request_detail() # The double-click path also checks permission.
|
||||||
|
assert bool(emitted) is detail
|
||||||
|
workspace.permissions = PermissionSet([])
|
||||||
|
workspace._selection_changed()
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.action_menu.actions() == []
|
||||||
|
assert not workspace.action_button.isVisible()
|
||||||
|
assert not workspace.detail_button.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("rx", "pay", "fulfillment", "rx_text", "pay_text", "state", "exclusion"),
|
||||||
|
[
|
||||||
|
(0, 1, 4, "待审核", "已通过", "已取消", "已取消不计入"),
|
||||||
|
(1, 2, 9, "已通过", "已驳回", "拒收", "拒收不计入"),
|
||||||
|
(2, 0, 10, "已驳回", "待审核", "退款", "退款不计入"),
|
||||||
|
(1, 1, 6, "已通过", "已通过", "已签收", ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_amount_exclusion_reasons_and_independent_status_text_survive_rendering(
|
||||||
|
application: QApplication,
|
||||||
|
workspace_factory,
|
||||||
|
rx,
|
||||||
|
pay,
|
||||||
|
fulfillment,
|
||||||
|
rx_text,
|
||||||
|
pay_text,
|
||||||
|
state,
|
||||||
|
exclusion,
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.rows = [
|
||||||
|
{
|
||||||
|
**repository.rows[0],
|
||||||
|
"prescription_audit_status": rx,
|
||||||
|
"payment_slip_audit_status": pay,
|
||||||
|
"fulfillment_status": fulfillment,
|
||||||
|
"amount_included": not bool(exclusion),
|
||||||
|
"amount_exclusion_text": exclusion,
|
||||||
|
"effective_amount": 12368.5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.table.item(0, 3).text() == (exclusion or "¥12,368.50")
|
||||||
|
assert [workspace.table.item(0, column).text() for column in (4, 5, 6)] == [
|
||||||
|
rx_text,
|
||||||
|
pay_text,
|
||||||
|
state,
|
||||||
|
]
|
||||||
|
if fulfillment == 6:
|
||||||
|
refund = next(
|
||||||
|
action for action in workspace.action_menu.actions() if action.text() == "退款"
|
||||||
|
)
|
||||||
|
assert refund.property("danger") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_uses_response_scope_and_legacy_aliases_without_recomputing_rows(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||||
|
"orders": "47",
|
||||||
|
"amount": "¥12,368.50",
|
||||||
|
"pending": "8",
|
||||||
|
"completed": "9",
|
||||||
|
"rejected": "2",
|
||||||
|
"rejection_rate": "4.3%",
|
||||||
|
}
|
||||||
|
assert workspace.scope_label.text() == "测试部门订单范围"
|
||||||
|
assert workspace.pager.total == 47
|
||||||
|
assert workspace.pager.page_size == 15
|
||||||
|
assert workspace.pager.height() == 24
|
||||||
|
# Preserve the existing ratio compatibility, including its known ambiguity.
|
||||||
|
repository.summary = {
|
||||||
|
"order_count": 20,
|
||||||
|
"effective_amount": 700.25,
|
||||||
|
"pending_audit": 3,
|
||||||
|
"completed": 4,
|
||||||
|
"rejected": 1,
|
||||||
|
"rejection_rate": 0.05,
|
||||||
|
}
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
assert {key: label.text() for key, label in workspace.metrics.items()} == {
|
||||||
|
"orders": "20",
|
||||||
|
"amount": "¥700.25",
|
||||||
|
"pending": "3",
|
||||||
|
"completed": "4",
|
||||||
|
"rejected": "1",
|
||||||
|
"rejection_rate": "5.0%",
|
||||||
|
}
|
||||||
|
before = len(repository.queries)
|
||||||
|
for metric in workspace.metrics.values():
|
||||||
|
_click(metric, application)
|
||||||
|
assert len(repository.queries) == before # The six metrics are read-only.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("silent", [False, True])
|
||||||
|
def test_loading_freezes_queries_resets_rows_and_ignores_stale_success_and_failure(
|
||||||
|
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
table = workspace.table
|
||||||
|
queued = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
patients_module,
|
||||||
|
"run_async",
|
||||||
|
lambda function, **callbacks: queued.append((function, callbacks)),
|
||||||
|
)
|
||||||
|
workspace.keyword_edit.setText("旧请求")
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
workspace.keyword_edit.setText("最新请求")
|
||||||
|
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||||
|
workspace.pay_audit.setCurrentIndex(workspace.pay_audit.findData(0))
|
||||||
|
workspace.fulfillment.setCurrentIndex(workspace.fulfillment.findData(9))
|
||||||
|
workspace.use_dates.setChecked(True)
|
||||||
|
workspace.start_date.setDate(QDate(2026, 7, 1))
|
||||||
|
workspace.end_date.setDate(QDate(2026, 8, 1))
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
_settle(application)
|
||||||
|
assert table.rowCount() == 0
|
||||||
|
assert workspace.content_stack.currentIndex() == 1
|
||||||
|
assert workspace.pager.isVisible()
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
workspace.keyword_edit.setText("尚未提交")
|
||||||
|
workspace.rx_audit.setCurrentIndex(0)
|
||||||
|
workspace.use_dates.setChecked(False)
|
||||||
|
newer = queued[1][0]()
|
||||||
|
assert repository.queries[-1] == {
|
||||||
|
"keyword": "最新请求",
|
||||||
|
"prescription_audit_status": 2,
|
||||||
|
"payment_slip_audit_status": 0,
|
||||||
|
"fulfillment_status": 9,
|
||||||
|
"start_date": "2026-07-01",
|
||||||
|
"end_date": "2026-08-01",
|
||||||
|
"page_no": 1,
|
||||||
|
"page_size": 15,
|
||||||
|
}
|
||||||
|
newer["extend"]["scope"]["label"] = "最新范围"
|
||||||
|
queued[1][1]["on_success"](newer)
|
||||||
|
current_item = table.item(0, 0)
|
||||||
|
stale = queued[0][0]()
|
||||||
|
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||||
|
queued[0][1]["on_success"](stale)
|
||||||
|
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 0) is current_item
|
||||||
|
assert workspace.scope_label.text() == "最新范围"
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
workspace.keyword_edit.setText("最新请求")
|
||||||
|
workspace.rx_audit.setCurrentIndex(workspace.rx_audit.findData(2))
|
||||||
|
workspace.use_dates.setChecked(True)
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
queued[-1][1]["on_error"](RuntimeError("订单查询失败"))
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 0) is current_item
|
||||||
|
assert workspace.metrics["orders"].text() == "47"
|
||||||
|
assert workspace.banner.isVisible()
|
||||||
|
assert "订单查询失败" in _banner_text(workspace)
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
queued[-1][1]["on_success"]({"lists": [], "count": 0})
|
||||||
|
_settle(application)
|
||||||
|
assert table.rowCount() == 0
|
||||||
|
assert workspace.content_stack.currentIndex() == 1
|
||||||
|
assert "当前范围内暂无订单" in " ".join(
|
||||||
|
label.text() for label in workspace.content_stack.currentWidget().findChildren(QLabel)
|
||||||
|
)
|
||||||
|
assert not workspace.diagnosis_button.isEnabled()
|
||||||
|
assert not workspace.detail_button.isEnabled()
|
||||||
|
assert workspace.action_menu.actions() == []
|
||||||
|
assert workspace.pager.total == 0
|
||||||
|
assert workspace.metrics["orders"].text() == "0"
|
||||||
|
assert workspace.metrics["amount"].text() == "¥0.00"
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1270, 680), (1014, 490), (760, 380)])
|
||||||
|
def test_layout_keeps_filters_actions_and_load_status_reachable_at_narrow_viewports(
|
||||||
|
application: QApplication, workspace_factory, width: int, height: int
|
||||||
|
) -> None:
|
||||||
|
workspace, _repository = workspace_factory(width=width, height=height)
|
||||||
|
table = workspace.table
|
||||||
|
assert workspace.size().width() == width
|
||||||
|
assert workspace.size().height() == height
|
||||||
|
assert table.columnCount() == 12
|
||||||
|
assert table.horizontalHeader().height() == 46
|
||||||
|
assert all(table.rowHeight(index) == 68 for index in range(table.rowCount()))
|
||||||
|
assert table.font().pixelSize() == 14
|
||||||
|
assert workspace.scope_label.font().pixelSize() == 13
|
||||||
|
assert all(metric.font().pixelSize() == 18 for metric in workspace.metrics.values())
|
||||||
|
for widget in (
|
||||||
|
workspace.keyword_edit,
|
||||||
|
workspace.rx_audit,
|
||||||
|
workspace.pay_audit,
|
||||||
|
workspace.fulfillment,
|
||||||
|
workspace.search_button,
|
||||||
|
workspace.reset_button,
|
||||||
|
workspace.use_dates,
|
||||||
|
workspace.start_date,
|
||||||
|
workspace.end_date,
|
||||||
|
):
|
||||||
|
assert widget.isVisibleTo(workspace)
|
||||||
|
assert workspace.filter_card.rect().contains(_rect_in(widget, workspace.filter_card))
|
||||||
|
for metric in workspace.metrics.values():
|
||||||
|
assert workspace.summary_strip.rect().contains(_rect_in(metric, workspace.summary_strip))
|
||||||
|
if width == 1270:
|
||||||
|
assert workspace.filter_card.height() == 132
|
||||||
|
assert workspace.summary_strip.height() == 96
|
||||||
|
if width == 760:
|
||||||
|
assert workspace.filter_card.height() == 184
|
||||||
|
assert (
|
||||||
|
len(
|
||||||
|
{
|
||||||
|
metric.mapTo(workspace.summary_strip, QPoint()).y()
|
||||||
|
for metric in workspace.metrics.values()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
assert table.horizontalScrollBar().maximum() > 0
|
||||||
|
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||||
|
|
||||||
|
for host in (
|
||||||
|
workspace.filter_card,
|
||||||
|
workspace.summary_strip,
|
||||||
|
workspace.action_bar,
|
||||||
|
workspace.pager,
|
||||||
|
):
|
||||||
|
workspace.scroll.ensureWidgetVisible(host, 0, 0)
|
||||||
|
_settle(application)
|
||||||
|
assert (
|
||||||
|
workspace.scroll.viewport().rect().contains(_rect_in(host, workspace.scroll.viewport()))
|
||||||
|
), host.objectName()
|
||||||
|
for button in (workspace.diagnosis_button, workspace.detail_button, workspace.action_button):
|
||||||
|
assert workspace.action_bar.rect().contains(_rect_in(button, workspace.action_bar))
|
||||||
|
assert workspace.pager.height() == 24
|
||||||
|
for widget in (workspace.pager.summary_label,):
|
||||||
|
assert workspace.pager.rect().contains(_rect_in(widget, workspace.pager))
|
||||||
|
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||||
|
table.scrollToBottom()
|
||||||
|
_settle(application)
|
||||||
|
assert table.columnViewportPosition(11) >= 0
|
||||||
|
assert table.columnViewportPosition(11) + table.columnWidth(11) <= table.viewport().width()
|
||||||
|
assert table.rowViewportPosition(2) + table.rowHeight(2) <= table.viewport().height()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tab_switches_keep_patient_styles_scope_deduplication_and_progress_timer(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
page, _repository = workspace_factory(width=1328, height=884, page=True)
|
||||||
|
patient = page.patient_workspace
|
||||||
|
progress = page.progress_workspace
|
||||||
|
patient_style = patient.styleSheet()
|
||||||
|
assert patient.table.objectName() == "PatientTable"
|
||||||
|
assert patient.table.columnCount() == 10
|
||||||
|
assert patient.table.rowHeight(0) == 66
|
||||||
|
assert patient.table.horizontalHeader().height() == 42
|
||||||
|
assert patient.table.font().pixelSize() == 14
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert not patient.search_toolbar.isVisible()
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
assert patient.search_toolbar.isVisible()
|
||||||
|
assert not progress.timer.isActive()
|
||||||
|
page.tabs.setCurrentIndex(2)
|
||||||
|
_settle(application)
|
||||||
|
progress_style = progress.styleSheet()
|
||||||
|
progress_table_font = progress.schedule_table.font()
|
||||||
|
progress_metric_font = progress.overview["total"][0].font()
|
||||||
|
assert progress.timer.interval() == 15_000
|
||||||
|
assert progress.timer.isActive()
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert not progress.scope_label.isVisible()
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
assert progress.scope_label.isVisible()
|
||||||
|
assert progress_table_font.pixelSize() == 14
|
||||||
|
assert progress_metric_font.pixelSize() == 18
|
||||||
|
assert progress.schedule_table.rowCount() == 7
|
||||||
|
assert progress.schedule_table.columnCount() == 7
|
||||||
|
|
||||||
|
page.tabs.setCurrentIndex(1)
|
||||||
|
_settle(application)
|
||||||
|
assert not progress.timer.isActive()
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert not patient.search_toolbar.isVisible()
|
||||||
|
assert page.order_workspace.scope_label.isVisible()
|
||||||
|
assert page.order_workspace.scope_label.text() == "测试部门订单范围"
|
||||||
|
assert patient.styleSheet() == patient_style
|
||||||
|
assert progress.styleSheet() == progress_style
|
||||||
|
assert progress.schedule_table.font() == progress_table_font
|
||||||
|
assert progress.overview["total"][0].font() == progress_metric_font
|
||||||
|
assert patient.table.rowHeight(0) == 66
|
||||||
|
assert patient.table.horizontalHeader().height() == 42
|
||||||
|
|
||||||
|
page.tabs.setCurrentIndex(0)
|
||||||
|
_settle(application)
|
||||||
|
assert patient.search_toolbar.isVisible()
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert not progress.timer.isActive()
|
||||||
|
page.tabs.setCurrentIndex(2)
|
||||||
|
_settle(application)
|
||||||
|
assert not page.scope_badge.isVisible()
|
||||||
|
assert progress.scope_label.isVisible()
|
||||||
|
assert progress.timer.isActive()
|
||||||
|
page.hide()
|
||||||
|
_settle(application)
|
||||||
|
assert not progress.timer.isActive()
|
||||||
|
page.show()
|
||||||
|
_settle(application)
|
||||||
|
assert progress.timer.isActive()
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
"""Progress data/display contracts and native reachability after the blue redesign."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QAbstractItemView, QApplication, QLabel
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
|
from doctor_workstation.ui.pages.patients import PatientProgressWorkspace, PatientsPage
|
||||||
|
from doctor_workstation.ui.shell import ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[dict[str, Any]] = []
|
||||||
|
self.result = {
|
||||||
|
"lists": [{"id": 101, "diagnosis_id": 501, "patient_id": 301,
|
||||||
|
"patient_name": "林晓岚", "doctor_name": "陈医生(演示)",
|
||||||
|
"appointment_time": "09:00-09:30"}],
|
||||||
|
"count": 37,
|
||||||
|
"extend": {"scope": {"label": "本部门及下级"}, "schedule_mode": "self",
|
||||||
|
"summary": {"waiting": 37, "completed": 2, "missed": 3}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.queries.append(deepcopy(query))
|
||||||
|
return deepcopy(self.result)
|
||||||
|
|
||||||
|
def list_patients(self, **_query: Any) -> dict[str, Any]:
|
||||||
|
return {"lists": [], "count": 0}
|
||||||
|
|
||||||
|
def patient_orders(self, **_query: Any) -> dict[str, Any]:
|
||||||
|
return {"lists": [], "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _texts(table) -> list[list[str]]:
|
||||||
|
return [[table.item(row, column).text() for column in range(table.columnCount())]
|
||||||
|
for row in range(table.rowCount())]
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(workspace) -> dict[str, tuple[str, str]]:
|
||||||
|
return {key: (value.text(), hint.text()) for key, (value, hint) in workspace.overview.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _rect_in(widget, parent) -> QRect:
|
||||||
|
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args, **_kwargs):
|
||||||
|
pytest.fail("Progress regression tests must use local fixtures or Demo only")
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||||
|
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(*, page=False, shell=False, permissions=("*",), width=1274, height=696,
|
||||||
|
repository=None, refresh=True):
|
||||||
|
repository = repository if repository is not None else _Repository()
|
||||||
|
permission_set = PermissionSet(list(permissions))
|
||||||
|
if shell:
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
|
# A real shell with only the tested navigation route, not unrelated workspaces.
|
||||||
|
widget = ShellWindow(repository, {
|
||||||
|
"user": session.user, "permissions": permission_set,
|
||||||
|
"menu": [{"perms": "firstvisit.myPatient/lists"}],
|
||||||
|
}, permissions=permission_set)
|
||||||
|
elif page:
|
||||||
|
widget = PatientsPage(repository, permissions=permission_set)
|
||||||
|
else:
|
||||||
|
widget = PatientProgressWorkspace(repository)
|
||||||
|
opened.append(widget)
|
||||||
|
widget.resize(width, height)
|
||||||
|
widget.show()
|
||||||
|
if shell:
|
||||||
|
widget.navigate("patients")
|
||||||
|
widget.pages["patients"].tabs.setCurrentIndex(2)
|
||||||
|
elif page:
|
||||||
|
widget.tabs.setCurrentIndex(2)
|
||||||
|
elif refresh:
|
||||||
|
widget.refresh()
|
||||||
|
_settle(application)
|
||||||
|
return widget, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for widget in opened:
|
||||||
|
for timer in widget.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("extend", "total", "expected_mode", "expected"),
|
||||||
|
[
|
||||||
|
({"schedule_mode": "self", "summary": {"waiting": 5, "completed": 2, "missed": 1}},
|
||||||
|
99, "按本人归属", {"total": ("8", "0 位接诊医生"), "waiting": ("5", "本人归属患者"), "completed": ("2", "已过号 1 人")}),
|
||||||
|
({"schedule_mode": "ownership", "summary": {"completed": 3, "missed": 2}},
|
||||||
|
7, "按本人归属", {"total": ("12", "0 位接诊医生"), "waiting": ("7", "本人归属患者"), "completed": ("3", "已过号 2 人")}),
|
||||||
|
({"schedule_mode": "self", "summary": {"waiting": 8, "completed": 9, "missed": 7},
|
||||||
|
"today_overview": {"total_visits": 0, "booked": 0, "completed": 0, "missed": 0, "doctor_count": 4}},
|
||||||
|
99, "按本人归属", {"total": ("0", "4 位接诊医生"), "waiting": ("0", "本人归属患者"), "completed": ("0", "已过号 0 人")}),
|
||||||
|
({"schedule_mode": "roster", "summary": {"waiting": 30, "completed": 40, "missed": 5},
|
||||||
|
"today_overview": {"total_visits": 18, "booked": 12, "completed": 4, "empty_slots": 6, "doctor_count": 3, "missed": 2}},
|
||||||
|
99, "与排班合并", {"total": ("18", "3 位接诊医生"), "waiting": ("12", "有效挂号"), "completed": ("6", "当前空号")}),
|
||||||
|
({"schedule_mode": "roster", "summary": {"waiting": 6, "completed": 3, "missed": 1}},
|
||||||
|
99, "与排班合并", {"total": ("10", "0 位接诊医生"), "waiting": ("6", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||||
|
({}, 4, "与排班合并", {"total": ("4", "0 位接诊医生"), "waiting": ("4", "有效挂号"), "completed": ("0", "当前空号")}),
|
||||||
|
],
|
||||||
|
ids=["self-summary", "ownership-total-fallback", "explicit-zero-wins", "roster-empty-slots", "roster-summary-fallback", "missing-extend"],
|
||||||
|
)
|
||||||
|
def test_overview_preserves_mode_fallbacks_and_fixed_completed_caption(
|
||||||
|
workspace_factory, extend, total, expected_mode, expected
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.result.update(count=total, extend=extend)
|
||||||
|
workspace.refresh()
|
||||||
|
assert _metrics(workspace) == expected
|
||||||
|
assert workspace.mode_label.text() == expected_mode
|
||||||
|
assert [label.text() for label in workspace.overview_card.findChildren(QLabel)
|
||||||
|
if label.text() in {"今日面诊总数", "待面诊", "已完成"}] == ["今日面诊总数", "待面诊", "已完成"]
|
||||||
|
assert workspace.queue_count.text() == f"共 {total} 人 · 每 15 秒刷新"
|
||||||
|
assert workspace.queue_table.rowCount() == 1 # Totals are server scope, not visible-row counts.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("week_schedule", [None, [], "invalid"], ids=["missing", "empty", "invalid-type"])
|
||||||
|
def test_schedule_fallback_is_seven_consecutive_days_with_blank_doctors(
|
||||||
|
workspace_factory, monkeypatch, week_schedule
|
||||||
|
) -> None:
|
||||||
|
# Sunday fixes the distinction between rolling seven days and a calendar week.
|
||||||
|
class Sunday:
|
||||||
|
@staticmethod
|
||||||
|
def currentDate():
|
||||||
|
return QDate(2026, 9, 6)
|
||||||
|
|
||||||
|
monkeypatch.setattr(patients_module, "QDate", Sunday)
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.result["extend"]["week_schedule"] = week_schedule
|
||||||
|
workspace.refresh()
|
||||||
|
rows = _texts(workspace.schedule_table)
|
||||||
|
assert [row[0] for row in rows] == ["09-06 周日", "09-07 周一", "09-08 周二", "09-09 周三", "09-10 周四", "09-11 周五", "09-12 周六"]
|
||||||
|
assert all(row[1:6] == ["0"] * 5 and row[6] == "" for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schedule_zero_priority_missing_fallback_and_doctor_names(workspace_factory) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.result["extend"]["week_schedule"] = [
|
||||||
|
{"date_text": "09-05", "weekday": "周六", "doctor_count": 2, "total_appointments": 0,
|
||||||
|
"waiting_appointments": 0, "completed_appointments": 0, "total_slots": 11,
|
||||||
|
"booked_slots": 7, "empty_slots": 4, "missed_appointments": 1, "doctors": []},
|
||||||
|
{"date_text": "09-06", "weekday": "周日", "doctor_count": 2, "total_appointments": None,
|
||||||
|
"waiting_appointments": "", "total_slots": 11, "booked_slots": 7, "empty_slots": 4,
|
||||||
|
"missed_appointments": 0, "doctors": [{"doctor_name": "甲医生"}, {"name": "乙医生"}]},
|
||||||
|
{"date_text": "09-07", "weekday": "周一", "doctors": "invalid"},
|
||||||
|
]
|
||||||
|
workspace.refresh()
|
||||||
|
rows = {row[0]: row for row in _texts(workspace.schedule_table)}
|
||||||
|
assert rows["09-05 周六"] == ["09-05 周六", "2", "0", "0", "0", "1", ""]
|
||||||
|
assert rows["09-06 周日"] == ["09-06 周日", "2", "11", "7", "4", "0", "甲医生、乙医生"]
|
||||||
|
assert rows["09-07 周一"][6] == "—"
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_row_keeps_missing_queue_fields_and_empty_cells_visually_blank(
|
||||||
|
application, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
|
workspace, _ = workspace_factory(repository=repository)
|
||||||
|
assert _texts(workspace.queue_table) == [["—", "林晓岚", "陈医生(演示)", "09:00", "0 位", "—"]]
|
||||||
|
assert _metrics(workspace) == {"total": ("1", "0 位接诊医生"), "waiting": ("1", "本人归属患者"), "completed": ("0", "已过号 0 人")}
|
||||||
|
assert workspace.scope == "演示医生本人患者"
|
||||||
|
assert workspace.pager.summary_label.text() == "共 1 条 · 已全部加载"
|
||||||
|
assert not workspace.pager.has_more
|
||||||
|
table = workspace.schedule_table
|
||||||
|
table.scrollToItem(table.item(0, 6))
|
||||||
|
_settle(application)
|
||||||
|
rect = table.visualItemRect(table.item(0, 6)).adjusted(3, 3, -3, -3)
|
||||||
|
image = table.viewport().grab(rect).toImage()
|
||||||
|
assert not image.isNull()
|
||||||
|
# Read the actual delegate output, not just the model's DisplayRole: blank is not “—”.
|
||||||
|
assert all(image.pixelColor(x, y).lightness() > 160
|
||||||
|
for x in range(image.width()) for y in range(image.height()))
|
||||||
|
dash_table = workspace.queue_table
|
||||||
|
dash_image = dash_table.viewport().grab(dash_table.visualItemRect(dash_table.item(0, 0))).toImage()
|
||||||
|
assert any(dash_image.pixelColor(x, y).lightness() < 100
|
||||||
|
for x in range(dash_image.width()) for y in range(dash_image.height()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_waiting_statuses_keep_source_counts_and_no_status_desc_fallback(workspace_factory) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
examples = [
|
||||||
|
({"queue_status": "consulting", "queue_status_text": "就诊中", "ahead_count": 12}, "0(进行中)", "就诊中"),
|
||||||
|
({"queue_status": "next", "queue_status_text": "待确认", "ahead_count": 12}, "0(待接诊)", "待确认"),
|
||||||
|
({"queue_status": "waiting", "queue_status_text": "等待中", "ahead_count": "3"}, "3 位 · 约 45 分钟", "等待中"),
|
||||||
|
({"queue_status": "waiting", "ahead_count": 2, "estimated_wait_minutes": 7}, "2 位 · 约 7 分钟", "—"),
|
||||||
|
({"queue_status": "missed", "queue_status_text": "已过号", "ahead_count": 2, "estimated_wait_minutes": -5}, "2 位 · 约 0 分钟", "已过号"),
|
||||||
|
({"queue_status": "completed", "queue_status_text": "已完成", "ahead_count": -8}, "0 位", "已完成"),
|
||||||
|
({"status_desc": "待接诊", "diagnosis_confirmed": 1, "ahead_count": "bad"}, "0 位", "—"),
|
||||||
|
]
|
||||||
|
base = repository.result["lists"][0]
|
||||||
|
repository.result["lists"] = [dict(base, **fields, id=100 + index, queue_no=40 + index)
|
||||||
|
for index, (fields, _, _) in enumerate(examples)]
|
||||||
|
workspace.refresh()
|
||||||
|
table = workspace.queue_table
|
||||||
|
for row_index in range(table.rowCount()):
|
||||||
|
row = table.item(row_index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
fields, expected_wait, expected_status = examples[row["id"] - 100]
|
||||||
|
assert table.item(row_index, 0).text() == str(row["queue_no"])
|
||||||
|
assert table.item(row_index, 4).text() == expected_wait
|
||||||
|
assert table.item(row_index, 5).text() == expected_status
|
||||||
|
assert all(table.item(row_index, col).data(Qt.ItemDataRole.UserRole) == row for col in range(6))
|
||||||
|
assert table.selectionMode() == QAbstractItemView.SelectionMode.SingleSelection
|
||||||
|
assert table.editTriggers() == QAbstractItemView.EditTrigger.NoEditTriggers
|
||||||
|
assert table.isSortingEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("permissions", "expected"),
|
||||||
|
[(('tcm.diagnosis/edit', 'tcm.diagnosis/readonlyDetail'), "edit"),
|
||||||
|
(("tcm.diagnosis/readonlyDetail",), "read"), ((), "denied"),
|
||||||
|
(("tcm.diagnosis.edit", "tcm.diagnosis.readonlyDetail"), "denied")],
|
||||||
|
ids=["edit-preferred", "readonly", "no-permission", "noncanonical-permission"],
|
||||||
|
)
|
||||||
|
def test_sorted_double_click_uses_selected_diagnosis_and_canonical_permissions(
|
||||||
|
application, workspace_factory, monkeypatch, permissions, expected
|
||||||
|
) -> None:
|
||||||
|
page, repository = workspace_factory(page=True, width=1328, height=884, permissions=permissions)
|
||||||
|
workspace = page.progress_workspace
|
||||||
|
repository.result["lists"] = [dict(repository.result["lists"][0], id=101 + index,
|
||||||
|
diagnosis_id=501 + index, patient_id=301 + index,
|
||||||
|
patient_name=name, queue_no=index + 1)
|
||||||
|
for index, name in enumerate(("阿青", "林青", "赵青"))]
|
||||||
|
workspace.refresh()
|
||||||
|
calls, toasts = [], []
|
||||||
|
dialog = SimpleNamespace(
|
||||||
|
open_for=lambda resource, **kwargs: calls.append(("edit", resource, kwargs)),
|
||||||
|
open_view_only=lambda resource, **kwargs: calls.append(("read", resource, kwargs)),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: dialog)
|
||||||
|
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||||
|
table = workspace.queue_table
|
||||||
|
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||||
|
table.selectRow(0)
|
||||||
|
table.scrollToItem(table.item(0, 1))
|
||||||
|
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||||
|
_settle(application)
|
||||||
|
row = table.item(0, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
assert row["id"] == 103 # A sorted row must not resolve to the first source row (101).
|
||||||
|
point = table.visualItemRect(table.item(0, 1)).center()
|
||||||
|
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||||
|
QTest.mouseDClick(table.viewport(), Qt.MouseButton.LeftButton, pos=point)
|
||||||
|
_settle(application)
|
||||||
|
if expected == "denied":
|
||||||
|
assert calls == [] and toasts == ["当前账号没有诊单查看权限。"]
|
||||||
|
else:
|
||||||
|
assert len(calls) == 1 and calls[0][0:2] == (expected, row["diagnosis_id"])
|
||||||
|
assert calls[0][1] != row["patient_id"] and calls[0][2]["seed"] == row
|
||||||
|
assert not toasts
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_row_id_fallback_and_invalid_diagnosis_never_use_patient_id(
|
||||||
|
workspace_factory, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
page, repository = workspace_factory(page=True, permissions=("tcm.diagnosis/edit",))
|
||||||
|
workspace = page.progress_workspace
|
||||||
|
calls, toasts = [], []
|
||||||
|
monkeypatch.setattr(page, "_ensure_diagnosis_dialog", lambda: SimpleNamespace(
|
||||||
|
open_for=lambda resource, **kwargs: calls.append(resource)))
|
||||||
|
monkeypatch.setattr(patients_module, "show_toast", lambda _parent, text, *_args: toasts.append(text))
|
||||||
|
for row, expected in [({"id": 810, "patient_id": 910}, 810),
|
||||||
|
({"id": 811, "diagnosis_id": 0, "patient_id": 911}, None),
|
||||||
|
({"patient_id": 912}, None)]:
|
||||||
|
repository.result["lists"] = [row]
|
||||||
|
workspace.refresh()
|
||||||
|
workspace.queue_table.selectRow(0)
|
||||||
|
before = len(calls)
|
||||||
|
workspace.queue_table.itemDoubleClicked.emit(workspace.queue_table.item(0, 0))
|
||||||
|
assert calls[before:] == ([] if expected is None else [expected])
|
||||||
|
assert toasts == ["患者诊单信息不完整。", "患者诊单信息不完整。"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_captures_today_status_and_page_before_worker_runs(
|
||||||
|
application, workspace_factory, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
queued = []
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||||
|
today = QDate.currentDate().toString("yyyy-MM-dd")
|
||||||
|
workspace.refresh(silent=True)
|
||||||
|
queued.pop()[1]["on_success"](deepcopy(repository.result))
|
||||||
|
workspace.pager.load_more()
|
||||||
|
workspace.pager.load_more()
|
||||||
|
assert len(queued) == 1 # Repeated bottom events share the in-flight request.
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
# Later GUI changes must not reach the pending worker's immutable query.
|
||||||
|
workspace._page = 9
|
||||||
|
queued[0][0]()
|
||||||
|
assert repository.queries[-1:] == [
|
||||||
|
{"status": 1, "start_date": today, "end_date": today, "page_no": 2, "page_size": 15},
|
||||||
|
]
|
||||||
|
assert "page" not in repository.queries[-1]
|
||||||
|
assert workspace.pager.page_size == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_success_error_and_latest_error_preserve_the_last_good_data(
|
||||||
|
application, workspace_factory, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
pending = []
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", lambda _function, **callbacks: pending.append(callbacks))
|
||||||
|
workspace.refresh()
|
||||||
|
# A date rollover changes the query; repeated same-day polls reuse in-flight work.
|
||||||
|
tomorrow = patients_module.QDate.currentDate().addDays(1)
|
||||||
|
monkeypatch.setattr(patients_module, "QDate", SimpleNamespace(currentDate=lambda: tomorrow))
|
||||||
|
workspace.refresh()
|
||||||
|
newer = deepcopy(repository.result)
|
||||||
|
newer["lists"][0].update(id=202, patient_name="最新结果")
|
||||||
|
newer["count"] = 22
|
||||||
|
newer["extend"].update(scope={"label": "新范围"}, summary={"waiting": 22, "completed": 4, "missed": 2})
|
||||||
|
pending[1]["on_success"](newer)
|
||||||
|
_settle(application)
|
||||||
|
snapshot = (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||||
|
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||||
|
pending[0]["on_success"]({"lists": [], "count": 0})
|
||||||
|
pending[0]["on_error"](RuntimeError("obsolete failure"))
|
||||||
|
_settle(application)
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||||
|
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||||
|
workspace.refresh(silent=True)
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
pending[2]["on_error"](RuntimeError("连接失败,请稍后刷新重试。"))
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.banner.isVisible() and workspace.banner.property("kind") == "danger"
|
||||||
|
assert "连接失败" in workspace.banner.label.text()
|
||||||
|
assert snapshot == (_texts(workspace.queue_table), _texts(workspace.schedule_table), _metrics(workspace),
|
||||||
|
workspace.scope, workspace.queue_count.text(), workspace.pager.total, workspace.queue_stack.currentIndex())
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_keeps_total_week_and_compact_status_visible(
|
||||||
|
application, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
repository.result.update(lists=[], count=37)
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.queue_stack.currentIndex() == 1
|
||||||
|
assert not workspace.queue_table.isVisible() and workspace.pager.isVisible()
|
||||||
|
assert workspace.pager.height() == 24
|
||||||
|
assert workspace.schedule_table.rowCount() == 7
|
||||||
|
assert workspace.queue_count.text() == "共 37 人 · 每 15 秒刷新"
|
||||||
|
labels = [label.text() for label in workspace.queue_stack.currentWidget().findChildren(QLabel)]
|
||||||
|
assert "今日暂无候诊患者" in labels
|
||||||
|
assert "当前权限范围内没有有效挂号。" in labels
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 768)])
|
||||||
|
def test_actual_shell_keeps_all_columns_rows_and_load_status_reachable(
|
||||||
|
application, workspace_factory, width, height
|
||||||
|
) -> None:
|
||||||
|
shell, _ = workspace_factory(shell=True, width=width, height=height)
|
||||||
|
page = shell.pages["patients"]
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
workspace = page.progress_workspace
|
||||||
|
assert (shell.width(), shell.height()) == (width, height)
|
||||||
|
assert not page.scope_badge.isVisible() and workspace.scope_label.isVisible()
|
||||||
|
schedule, queue = workspace.schedule_table, workspace.queue_table
|
||||||
|
assert (schedule.rowCount(), schedule.columnCount(), queue.columnCount()) == (7, 7, 6)
|
||||||
|
if width == 1536:
|
||||||
|
assert schedule.verticalScrollBar().value() == 0
|
||||||
|
assert schedule.rowViewportPosition(6) + schedule.rowHeight(6) <= schedule.viewport().height()
|
||||||
|
assert workspace.scroll.viewport().rect().contains(_rect_in(schedule, workspace.scroll.viewport()))
|
||||||
|
for table in (schedule, queue):
|
||||||
|
workspace.scroll.ensureWidgetVisible(table, 0, 0)
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.scroll.viewport().rect().contains(_rect_in(table, workspace.scroll.viewport()))
|
||||||
|
for column in range(table.columnCount()):
|
||||||
|
item = table.item(table.rowCount() - 1, column)
|
||||||
|
table.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||||
|
_settle(application)
|
||||||
|
assert not table.isColumnHidden(column)
|
||||||
|
assert table.viewport().rect().contains(table.visualItemRect(item)), (width, column)
|
||||||
|
workspace.scroll.ensureWidgetVisible(workspace.pager, 0, 0)
|
||||||
|
_settle(application)
|
||||||
|
assert workspace.scroll.viewport().rect().contains(_rect_in(workspace.pager, workspace.scroll.viewport()))
|
||||||
|
assert workspace.pager.height() == 24
|
||||||
|
for child in (workspace.pager.summary_label,):
|
||||||
|
assert workspace.pager.rect().contains(_rect_in(child, workspace.pager))
|
||||||
|
if width == 1024:
|
||||||
|
assert schedule.horizontalScrollBar().maximum() > 0
|
||||||
|
assert queue.horizontalScrollBar().maximum() > 0
|
||||||
|
assert workspace.scroll.verticalScrollBar().maximum() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_splitter_mouse_drag_reallocates_space_but_cannot_collapse_panels(
|
||||||
|
application, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, _ = workspace_factory()
|
||||||
|
splitter = workspace.splitter
|
||||||
|
before = splitter.sizes()
|
||||||
|
handle = splitter.handle(1)
|
||||||
|
point = handle.rect().center()
|
||||||
|
QTest.mousePress(handle, Qt.MouseButton.LeftButton, pos=point)
|
||||||
|
QTest.mouseMove(handle, point + QPoint(0, -60))
|
||||||
|
QTest.mouseRelease(handle, Qt.MouseButton.LeftButton, pos=point + QPoint(0, -60))
|
||||||
|
_settle(application)
|
||||||
|
assert splitter.sizes()[0] < before[0]
|
||||||
|
assert not splitter.childrenCollapsible()
|
||||||
|
for sizes in ([0, 1000], [1000, 0]):
|
||||||
|
splitter.setSizes(sizes)
|
||||||
|
_settle(application)
|
||||||
|
assert splitter.sizes()[0] >= workspace.schedule_card.minimumHeight() > 0
|
||||||
|
assert splitter.sizes()[1] >= workspace.queue_card.minimumHeight() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_typography_and_styles_are_local_to_the_progress_workspace(
|
||||||
|
application, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
app_style = application.styleSheet()
|
||||||
|
unrelated = QLabel("外部标签")
|
||||||
|
unrelated.ensurePolished()
|
||||||
|
original_font = unrelated.font()
|
||||||
|
workspace, _ = workspace_factory()
|
||||||
|
assert workspace.schedule_table.font().pixelSize() == 14
|
||||||
|
assert workspace.queue_table.font().pixelSize() == 14
|
||||||
|
assert workspace.scope_label.font().pixelSize() == 13
|
||||||
|
assert workspace.mode_label.font().pixelSize() == 13
|
||||||
|
assert workspace.queue_count.font().pixelSize() == 13
|
||||||
|
for value, hint in workspace.overview.values():
|
||||||
|
assert value.font().pixelSize() == 18 and hint.font().pixelSize() == 13
|
||||||
|
for label in workspace.findChildren(QLabel):
|
||||||
|
if label.property("role") == "sectionTitle":
|
||||||
|
assert label.font().pixelSize() == 14
|
||||||
|
assert application.styleSheet() == app_style
|
||||||
|
assert "ProgressWorkspace" not in app_style
|
||||||
|
assert unrelated.font() == original_font and not unrelated.styleSheet()
|
||||||
|
unrelated.deleteLater()
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Patient workspaces keep resource identity while appending server pages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QCheckBox, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
|
from doctor_workstation.ui.pages.patients import (
|
||||||
|
PatientListWorkspace,
|
||||||
|
PatientOrdersWorkspace,
|
||||||
|
PatientProgressWorkspace,
|
||||||
|
PatientsPage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
self.failed_pages: set[int] = set()
|
||||||
|
self.version = 0
|
||||||
|
|
||||||
|
def _list(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(dict(query))
|
||||||
|
page = query["page_no"]
|
||||||
|
if page in self.failed_pages:
|
||||||
|
raise RuntimeError("连接失败,请重试。")
|
||||||
|
start = (page - 1) * query["page_size"]
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": index + 100,
|
||||||
|
"diagnosis_id": index + 1000,
|
||||||
|
"patient_id": index + 2000,
|
||||||
|
"appointment_id": index + 3000,
|
||||||
|
"order_id": index + 4000,
|
||||||
|
"patient_name": f"患者 {index:02d} · {query.get('keyword', '')}{self.version}",
|
||||||
|
"appointment_status": 1,
|
||||||
|
"prescription_audit_status": 0,
|
||||||
|
"payment_slip_audit_status": 0,
|
||||||
|
"fulfillment_status": 2,
|
||||||
|
"queue_no": index + 1,
|
||||||
|
"queue_status": "waiting",
|
||||||
|
"queue_status_text": "等待中",
|
||||||
|
}
|
||||||
|
for index in range(start, min(start + query["page_size"], 37))
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"lists": rows,
|
||||||
|
"count": 37,
|
||||||
|
"extend": {
|
||||||
|
"scope": {"label": "本人患者"},
|
||||||
|
"summary": {"orders": 37, "amount": 3700, "today": 37, "waiting": 37},
|
||||||
|
"schedule_mode": "ownership",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
list_patients = _list
|
||||||
|
patient_orders = _list
|
||||||
|
patient_progress = _list
|
||||||
|
|
||||||
|
|
||||||
|
def run_immediately(function: Any, **callbacks: Any) -> object:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
callbacks["on_error"](error)
|
||||||
|
else:
|
||||||
|
callbacks["on_success"](result)
|
||||||
|
finally:
|
||||||
|
if callbacks.get("on_finished"):
|
||||||
|
callbacks["on_finished"]()
|
||||||
|
return object()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=["patients", "orders", "progress"])
|
||||||
|
def workspace(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", run_immediately)
|
||||||
|
repository = Repository()
|
||||||
|
permissions = PermissionSet(["*"])
|
||||||
|
constructors = {
|
||||||
|
"patients": lambda: PatientListWorkspace(repository, permissions),
|
||||||
|
"orders": lambda: PatientOrdersWorkspace(repository, permissions),
|
||||||
|
"progress": lambda: PatientProgressWorkspace(repository),
|
||||||
|
}
|
||||||
|
widget = constructors[request.param]()
|
||||||
|
widget.resize(1280, 900)
|
||||||
|
widget.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield request.param, widget, repository
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def table_for(workspace: Any) -> Any:
|
||||||
|
return getattr(workspace, "table", None) or workspace.queue_table
|
||||||
|
|
||||||
|
|
||||||
|
def settle(application: QApplication) -> None:
|
||||||
|
application.processEvents()
|
||||||
|
QTest.qWait(60)
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_down(workspace: Any, application: QApplication) -> None:
|
||||||
|
table = table_for(workspace)
|
||||||
|
table.verticalScrollBar().setValue(table.verticalScrollBar().maximum())
|
||||||
|
settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrolling_appends_without_duplicates_or_selection_loss(workspace, application):
|
||||||
|
kind, widget, repository = workspace
|
||||||
|
widget.refresh()
|
||||||
|
settle(application)
|
||||||
|
table = table_for(widget)
|
||||||
|
assert table.rowCount() == 15
|
||||||
|
assert widget.pager.height() == 24
|
||||||
|
assert not hasattr(widget.pager, "page_changed")
|
||||||
|
assert not any(button.text().isdigit() for button in widget.pager.findChildren(QPushButton))
|
||||||
|
table.sortItems(0, Qt.SortOrder.DescendingOrder)
|
||||||
|
table.selectRow(5)
|
||||||
|
chosen = table.current_data()["id"]
|
||||||
|
if kind == "patients":
|
||||||
|
table.cellWidget(5, 0).findChild(QCheckBox).setChecked(True)
|
||||||
|
scroll_down(widget, application)
|
||||||
|
assert table.rowCount() == 30
|
||||||
|
assert table.current_data()["id"] == chosen
|
||||||
|
if kind == "patients":
|
||||||
|
selected_row = next(index for index in range(table.rowCount())
|
||||||
|
if table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"] == chosen)
|
||||||
|
assert table.cellWidget(selected_row, 0).findChild(QCheckBox).isChecked()
|
||||||
|
scroll_down(widget, application)
|
||||||
|
assert table.rowCount() == 37
|
||||||
|
assert len({table.item(index, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||||
|
for index in range(table.rowCount())}) == 37
|
||||||
|
assert [query["page_no"] for query in repository.calls] == [1, 2, 3]
|
||||||
|
assert all(query["page_size"] == 15 and "page" not in query for query in repository.calls)
|
||||||
|
assert widget.pager.page == widget._page == 3
|
||||||
|
assert widget.pager.total == 37
|
||||||
|
assert not widget.pager.has_more
|
||||||
|
assert widget.scope == "本人患者"
|
||||||
|
scroll_down(widget, application)
|
||||||
|
assert len(repository.calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_retains_loaded_prefix_scroll_and_distinct_action_ids(workspace, application):
|
||||||
|
kind, widget, repository = workspace
|
||||||
|
widget.refresh()
|
||||||
|
settle(application)
|
||||||
|
scroll_down(widget, application)
|
||||||
|
table = table_for(widget)
|
||||||
|
table.selectRow(20)
|
||||||
|
chosen = dict(table.current_data())
|
||||||
|
scroll = table.verticalScrollBar().value()
|
||||||
|
repository.version = 1
|
||||||
|
widget.refresh(silent=True)
|
||||||
|
assert [query["page_no"] for query in repository.calls] == [1, 2, 1, 2]
|
||||||
|
assert table.rowCount() == 30
|
||||||
|
assert table.current_data()["id"] == chosen["id"]
|
||||||
|
assert table.current_data()["patient_name"].endswith("1")
|
||||||
|
assert table.verticalScrollBar().value() == scroll
|
||||||
|
selected = []
|
||||||
|
if kind == "patients":
|
||||||
|
widget.diagnosis_requested.connect(lambda row, _edit: selected.append(row))
|
||||||
|
widget._open_selected_diagnosis()
|
||||||
|
elif kind == "orders":
|
||||||
|
widget.detail_requested.connect(selected.append)
|
||||||
|
widget._request_detail()
|
||||||
|
else:
|
||||||
|
widget.diagnosis_requested.connect(selected.append)
|
||||||
|
widget._open_selected()
|
||||||
|
assert selected[0]["id"] == chosen["id"]
|
||||||
|
assert selected[0]["diagnosis_id"] == chosen["diagnosis_id"]
|
||||||
|
assert selected[0]["diagnosis_id"] != selected[0]["patient_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_append_keeps_rows_and_retry_continues_same_page(workspace, application):
|
||||||
|
_kind, widget, repository = workspace
|
||||||
|
repository.failed_pages.add(2)
|
||||||
|
widget.refresh()
|
||||||
|
settle(application)
|
||||||
|
scroll_down(widget, application)
|
||||||
|
table = table_for(widget)
|
||||||
|
assert table.rowCount() == 15
|
||||||
|
assert widget.pager.page == 1
|
||||||
|
assert widget.pager.retry_button.isVisible()
|
||||||
|
settle(application)
|
||||||
|
assert [query["page_no"] for query in repository.calls] == [1, 2]
|
||||||
|
repository.failed_pages.clear()
|
||||||
|
widget.pager.retry_button.click()
|
||||||
|
assert table.rowCount() == 30
|
||||||
|
assert [query["page_no"] for query in repository.calls] == [1, 2, 2]
|
||||||
|
assert not widget.pager.retry_button.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_preserves_horizontal_scroll_at_narrow_width(workspace, application):
|
||||||
|
_kind, widget, _repository = workspace
|
||||||
|
widget.resize(760, 600)
|
||||||
|
widget.refresh()
|
||||||
|
settle(application)
|
||||||
|
table = table_for(widget)
|
||||||
|
horizontal = table.horizontalScrollBar()
|
||||||
|
assert horizontal.maximum() > 0
|
||||||
|
horizontal.setValue(horizontal.maximum())
|
||||||
|
offset = horizontal.value()
|
||||||
|
widget.refresh(silent=True)
|
||||||
|
assert horizontal.value() == offset
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_page_failure_keeps_compact_retry_accessible(workspace, application):
|
||||||
|
_kind, widget, repository = workspace
|
||||||
|
repository.failed_pages.add(1)
|
||||||
|
widget.refresh()
|
||||||
|
settle(application)
|
||||||
|
widget.scroll.ensureWidgetVisible(widget.pager)
|
||||||
|
settle(application)
|
||||||
|
assert table_for(widget).rowCount() == 0
|
||||||
|
assert widget.pager.isVisible()
|
||||||
|
assert widget.pager.retry_button.isVisible()
|
||||||
|
assert widget.pager.retry_button.height() <= widget.pager.height()
|
||||||
|
repository.failed_pages.clear()
|
||||||
|
widget.pager.retry_button.click()
|
||||||
|
assert table_for(widget).rowCount() == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_change_restarts_and_ignores_old_append(workspace, application, monkeypatch):
|
||||||
|
kind, widget, repository = workspace
|
||||||
|
widget.refresh()
|
||||||
|
settle(application)
|
||||||
|
pending = []
|
||||||
|
monkeypatch.setattr(patients_module, "run_async",
|
||||||
|
lambda function, **callbacks: pending.append((function, callbacks)))
|
||||||
|
# Capture the new runner for the next scroll request without changing query.
|
||||||
|
widget.refresh(silent=True)
|
||||||
|
pending.pop()[1]["on_success"](repository._list(page_no=1, page_size=15))
|
||||||
|
scroll_down(widget, application)
|
||||||
|
assert len(pending) == 1
|
||||||
|
if kind == "progress":
|
||||||
|
class Tomorrow:
|
||||||
|
@staticmethod
|
||||||
|
def currentDate():
|
||||||
|
return QDate.currentDate().addDays(1)
|
||||||
|
|
||||||
|
monkeypatch.setattr(patients_module, "QDate", Tomorrow)
|
||||||
|
else:
|
||||||
|
widget.keyword_edit.setText("新条件")
|
||||||
|
widget.refresh()
|
||||||
|
assert len(pending) == 2
|
||||||
|
stale_function, stale_callbacks = pending[0]
|
||||||
|
current_function, current_callbacks = pending[1]
|
||||||
|
current_callbacks["on_success"](current_function())
|
||||||
|
stale_callbacks["on_success"](stale_function())
|
||||||
|
stale_callbacks["on_error"](RuntimeError("旧请求错误"))
|
||||||
|
assert table_for(widget).rowCount() == 15
|
||||||
|
assert widget.pager.page == 1
|
||||||
|
assert not widget.banner.isVisible()
|
||||||
|
latest = repository.calls[-2]
|
||||||
|
assert latest["page_no"] == 1
|
||||||
|
if kind == "progress":
|
||||||
|
assert latest["start_date"] == QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
|
||||||
|
else:
|
||||||
|
assert latest["keyword"] == "新条件"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patient_route_uses_eight_pixel_bottom_margin(application, monkeypatch):
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", run_immediately)
|
||||||
|
page = PatientsPage(Repository(), PermissionSet(["*"]))
|
||||||
|
assert page.layout().contentsMargins().bottom() == 8
|
||||||
|
assert page.order_workspace.action_bar.height() == 54
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtCore import QDate, QPoint
|
from PySide6.QtCore import QDate, QPoint, QRect
|
||||||
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
|
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
|
||||||
|
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet
|
||||||
@@ -175,6 +175,8 @@ def test_patient_refresh_generation_ignores_late_results(
|
|||||||
monkeypatch.setattr(patients_module, "run_async", queue_async)
|
monkeypatch.setattr(patients_module, "run_async", queue_async)
|
||||||
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
|
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
|
||||||
workspace.refresh()
|
workspace.refresh()
|
||||||
|
# Identical in-flight queries coalesce; a changed query starts a new generation.
|
||||||
|
workspace.keyword_edit.setText("新结果")
|
||||||
workspace.refresh()
|
workspace.refresh()
|
||||||
newer = {
|
newer = {
|
||||||
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
|
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
|
||||||
@@ -621,62 +623,72 @@ def test_patient_list_reference_geometry_and_row_actions(
|
|||||||
repository = DemoDoctorRepository()
|
repository = DemoDoctorRepository()
|
||||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
||||||
# 1366x768 shell minus its 170 px patient rail, 26 px outer gutter,
|
page.filter_disclosure.set_expanded(True)
|
||||||
# and 62 px top bar leaves a 1170x680 page viewport.
|
# Patient pages use the approved 208 px rail and 76 px top bar.
|
||||||
page.resize(1170, 680)
|
page.resize(1536 - 208, 960 - 76)
|
||||||
page.show()
|
page.show()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
page.patient_workspace.refresh()
|
page.patient_workspace.refresh()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
workspace = page.patient_workspace
|
workspace = page.patient_workspace
|
||||||
assert page.header.height() == 62
|
|
||||||
assert workspace.filter_card.height() <= 92
|
|
||||||
assert all(
|
|
||||||
button.minimumHeight() == 44 and button.maximumHeight() == 44
|
|
||||||
for button in workspace.summary_buttons.values()
|
|
||||||
)
|
|
||||||
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
|
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
|
||||||
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
|
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
|
||||||
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
|
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
|
||||||
assert workspace.date_host.objectName() == "PatientDateRangeHost"
|
assert workspace.date_host.objectName() == "PatientDateRangeHost"
|
||||||
assert (
|
|
||||||
workspace.keyword_edit.maximumWidth(),
|
|
||||||
workspace.status_host.maximumWidth(),
|
|
||||||
workspace.quick_host.maximumWidth(),
|
|
||||||
workspace.date_host.maximumWidth(),
|
|
||||||
) == (620, 440, 620, 420)
|
|
||||||
assert workspace.search_button.objectName() == "PatientSearchButton"
|
assert workspace.search_button.objectName() == "PatientSearchButton"
|
||||||
assert workspace.reset_button.objectName() == "PatientResetButton"
|
assert workspace.reset_button.objectName() == "PatientResetButton"
|
||||||
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
|
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
|
||||||
for width in (1170, 1290, 1514):
|
assert workspace.table.rowCount() == 4
|
||||||
page.resize(width, 680)
|
assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
|
||||||
application.processEvents()
|
assert workspace.table.horizontalScrollBar().maximum() == 0
|
||||||
for widget in (
|
assert workspace.pager.summary_label.text() == "共 4 条 · 已全部加载"
|
||||||
workspace.keyword_edit,
|
assert workspace.pager.height() == 24
|
||||||
workspace.status_host,
|
assert workspace.pager.page_size == 15
|
||||||
workspace.search_button,
|
assert workspace.pager.page == 1
|
||||||
workspace.reset_button,
|
assert not workspace.pager.has_more
|
||||||
workspace.quick_host,
|
for width, height in ((1536, 960), (1366, 768), (1024, 640)):
|
||||||
workspace.date_host,
|
page.resize(width - 208, height - 76)
|
||||||
workspace.custom_date_button,
|
for _ in range(3):
|
||||||
|
application.processEvents()
|
||||||
|
assert page.header.height() >= page.header.minimumSizeHint().height()
|
||||||
|
for host, widgets in (
|
||||||
|
(workspace.search_toolbar, (workspace.keyword_edit, workspace.search_button, workspace.reset_button)),
|
||||||
|
(workspace.filter_card, (workspace.status_host, workspace.quick_host, workspace.date_host, workspace.custom_date_button)),
|
||||||
|
(workspace.status_host, tuple(workspace.status_buttons.values())),
|
||||||
|
(workspace.quick_host, tuple(workspace.quick_buttons.values())),
|
||||||
|
(workspace.date_host, (workspace.start_date, workspace.end_date)),
|
||||||
|
(workspace.summary_strip, tuple(workspace.summary_buttons.values())),
|
||||||
):
|
):
|
||||||
top_left = widget.mapTo(workspace.filter_card, QPoint(0, 0))
|
for widget in widgets:
|
||||||
assert top_left.x() >= 0
|
assert widget.isVisibleTo(page)
|
||||||
assert top_left.x() + widget.width() <= workspace.filter_card.width()
|
assert host.rect().contains(QRect(widget.mapTo(host, QPoint()), widget.size())), widget.objectName()
|
||||||
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values())
|
for host in (workspace.search_toolbar, workspace.filter_card, workspace.summary_strip, workspace.pager):
|
||||||
|
if workspace.content.isAncestorOf(host):
|
||||||
|
workspace.scroll.ensureWidgetVisible(host, 0, 0)
|
||||||
|
application.processEvents()
|
||||||
|
assert workspace.scroll.viewport().rect().contains(QRect(host.mapTo(workspace.scroll.viewport(), QPoint()), host.size())), host.objectName()
|
||||||
|
else:
|
||||||
|
assert page.rect().contains(QRect(host.mapTo(page, QPoint()), host.size())), host.objectName()
|
||||||
|
assert workspace.pager.isVisibleTo(page)
|
||||||
|
workspace.table.scrollToBottom()
|
||||||
|
application.processEvents()
|
||||||
|
assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
|
||||||
assert page.tabs.minimumHeight() == 0
|
assert page.tabs.minimumHeight() == 0
|
||||||
assert workspace.content_stack.minimumHeight() == 0
|
assert workspace.content_stack.minimumHeight() == 0
|
||||||
assert workspace.table.minimumHeight() == 0
|
assert workspace.table.minimumHeight() == 0
|
||||||
assert workspace.bottom_actions.isHidden()
|
assert workspace.bottom_actions.isHidden()
|
||||||
assert workspace.table.viewport().height() // 36 >= 6
|
|
||||||
assert workspace.pager.isVisibleTo(page)
|
|
||||||
assert workspace.table.objectName() == "PatientTable"
|
assert workspace.table.objectName() == "PatientTable"
|
||||||
assert workspace.table.columnCount() == 10
|
assert workspace.table.columnCount() == 10
|
||||||
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
|
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
|
||||||
if workspace.table.rowCount():
|
assert workspace.table.horizontalHeader().visualIndex(9) == 9
|
||||||
assert workspace.table.rowHeight(0) == 36
|
assert all(not workspace.table.isColumnHidden(column) for column in range(10))
|
||||||
assert workspace.table.cellWidget(0, 0) is not None
|
actions = workspace.table.cellWidget(0, 9)
|
||||||
assert workspace.table.cellWidget(0, 9) is not None
|
assert workspace.table.rowHeight(0) >= max(40, actions.minimumSizeHint().height() + 1)
|
||||||
|
for button in [*actions.buttons, actions.more_button]:
|
||||||
|
if button is not None:
|
||||||
|
assert actions.rect().contains(button.geometry())
|
||||||
|
assert workspace.table.cellWidget(0, 0) is not None
|
||||||
|
assert workspace.table.cellWidget(0, 9) is not None
|
||||||
page.close()
|
page.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Interaction contracts at risk when the patient list moves to the blue layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QCheckBox, QLabel
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
|
from doctor_workstation.ui.pages.patients import PatientListWorkspace
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[dict[str, Any]] = []
|
||||||
|
self.rows = [
|
||||||
|
{
|
||||||
|
"id": 601 + index,
|
||||||
|
"diagnosis_id": 601 + index,
|
||||||
|
"source_patient_id": 301 + index,
|
||||||
|
"appointment_id": 401 + index if index != 2 else 0,
|
||||||
|
"patient_name": name,
|
||||||
|
"gender": 2,
|
||||||
|
"age": 36,
|
||||||
|
"assistant_name": "测试医助",
|
||||||
|
"assistant_id": 81 if index != 2 else 0,
|
||||||
|
"appointment_doctor_name": "测试医生" if index != 2 else "",
|
||||||
|
"appointment_status": (1, 4, 0)[index],
|
||||||
|
"appointment_status_text": "未预约" if index == 2 else "待接诊",
|
||||||
|
"appointment_time_text": "2026-09-05 09:00" if index != 2 else "—",
|
||||||
|
"has_id_card": index != 1,
|
||||||
|
"revisit_count": index,
|
||||||
|
"confirmation_text": "待确认",
|
||||||
|
"diagnosis_date_text": "初诊",
|
||||||
|
"phone_masked": f"138****120{index}",
|
||||||
|
}
|
||||||
|
for index, name in enumerate(("阿青", "林青", "赵青"))
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.queries.append(query)
|
||||||
|
return {
|
||||||
|
"lists": deepcopy(self.rows),
|
||||||
|
"count": len(self.rows),
|
||||||
|
"extend": {
|
||||||
|
"scope": {"label": "测试部门可见患者"},
|
||||||
|
"summary": {"today": 2, "tomorrow": 1, "day_after": 0},
|
||||||
|
"dates": {"today": QDate.currentDate().toString("yyyy-MM-dd")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workspace_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def immediate(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args: Any, **_kwargs: Any):
|
||||||
|
pytest.fail("Patient visual tests must use only local fixture data")
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(*, permissions=("*",), width=1270, height=700):
|
||||||
|
repository = _Repository()
|
||||||
|
workspace = PatientListWorkspace(repository, PermissionSet(list(permissions)))
|
||||||
|
opened.append(workspace)
|
||||||
|
workspace.resize(width, height)
|
||||||
|
workspace.show()
|
||||||
|
workspace.refresh()
|
||||||
|
_settle(application)
|
||||||
|
return workspace, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for workspace in opened:
|
||||||
|
for timer in workspace.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
workspace.close()
|
||||||
|
workspace.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def _click(widget, application: QApplication) -> None:
|
||||||
|
QTest.mouseClick(widget, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def _date_selection(workspace: PatientListWorkspace) -> list[str]:
|
||||||
|
return [key for key, button in workspace.quick_buttons.items() if button.isChecked()] + (
|
||||||
|
["custom"] if workspace.custom_date_button.isChecked() else []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filters_preserve_independent_dimensions_and_reset_to_unlimited_dates(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
assert repository.queries == [{
|
||||||
|
"keyword": "", "status": "", "start_date": "", "end_date": "",
|
||||||
|
"page_no": 1, "page_size": 15,
|
||||||
|
}]
|
||||||
|
assert _date_selection(workspace) == ["all"]
|
||||||
|
assert not workspace.start_date.isEnabled()
|
||||||
|
assert not workspace.end_date.isEnabled()
|
||||||
|
assert workspace.start_date.date() == workspace.end_date.date() == QDate.currentDate()
|
||||||
|
assert workspace.scope_label.text() == "测试部门可见患者"
|
||||||
|
assert workspace.summary_buttons["today"].text() == f"今日预约 · {QDate.currentDate().toString('yyyy-MM-dd')}\n2 人"
|
||||||
|
assert workspace.summary_buttons["tomorrow"].text() == "明日预约\n1 人"
|
||||||
|
assert workspace.summary_buttons["day_after"].text() == "后天预约\n0 人"
|
||||||
|
|
||||||
|
workspace.keyword_edit.setText(" 林医生 ")
|
||||||
|
workspace.pager.load_more()
|
||||||
|
_click(workspace.status_buttons["pending_interview"], application)
|
||||||
|
assert repository.queries[-1]["page_no"] == 1
|
||||||
|
for mode, offset in (("today", 0), ("tomorrow", 1), ("day_after", 2)):
|
||||||
|
_click(workspace.summary_buttons[mode], application)
|
||||||
|
expected = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
|
||||||
|
assert repository.queries[-1] == {
|
||||||
|
"keyword": "林医生", "status": "pending_interview", "start_date": expected,
|
||||||
|
"end_date": expected, "page_no": 1, "page_size": 15,
|
||||||
|
}
|
||||||
|
assert _date_selection(workspace) == [mode]
|
||||||
|
assert workspace.status_buttons["pending_interview"].isChecked()
|
||||||
|
assert workspace.summary_buttons["today"].text().endswith("2 人")
|
||||||
|
|
||||||
|
_click(workspace.custom_date_button, application)
|
||||||
|
assert _date_selection(workspace) == ["custom"]
|
||||||
|
assert workspace.start_date.isEnabled() and workspace.end_date.isEnabled()
|
||||||
|
# Clicking the selected custom mode keeps a visible selection.
|
||||||
|
_click(workspace.custom_date_button, application)
|
||||||
|
assert _date_selection(workspace) == ["custom"]
|
||||||
|
workspace.start_date.setDate(QDate.currentDate().addDays(-4))
|
||||||
|
workspace.end_date.setDate(QDate.currentDate().addDays(-2))
|
||||||
|
workspace.end_date.editingFinished.emit()
|
||||||
|
assert repository.queries[-1]["start_date"] == QDate.currentDate().addDays(-4).toString("yyyy-MM-dd")
|
||||||
|
assert repository.queries[-1]["end_date"] == QDate.currentDate().addDays(-2).toString("yyyy-MM-dd")
|
||||||
|
assert _date_selection(workspace) == ["custom"]
|
||||||
|
|
||||||
|
workspace.start_date.setDate(QDate.currentDate().addDays(3))
|
||||||
|
before = len(repository.queries)
|
||||||
|
workspace.start_date.editingFinished.emit()
|
||||||
|
_click(workspace.search_button, application)
|
||||||
|
assert len(repository.queries) == before
|
||||||
|
assert workspace.banner.isVisible()
|
||||||
|
assert "开始日期不能晚于结束日期" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||||
|
|
||||||
|
_click(workspace.reset_button, application)
|
||||||
|
assert repository.queries[-1] == {
|
||||||
|
"keyword": "", "status": "", "start_date": "", "end_date": "",
|
||||||
|
"page_no": 1, "page_size": 15,
|
||||||
|
}
|
||||||
|
assert workspace.keyword_edit.text() == ""
|
||||||
|
assert [key for key, button in workspace.status_buttons.items() if button.isChecked()] == [""]
|
||||||
|
assert _date_selection(workspace) == ["all"]
|
||||||
|
assert not workspace.start_date.isEnabled() and not workspace.end_date.isEnabled()
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("permissions", "expected", "editable"), [
|
||||||
|
(("*",), ["诊单", "AI 分析"], True),
|
||||||
|
(("tcm.diagnosis/readonlyDetail",), ["查看"], False),
|
||||||
|
((), [], None),
|
||||||
|
])
|
||||||
|
def test_row_actions_obey_edit_readonly_and_no_permission(
|
||||||
|
application: QApplication, workspace_factory, permissions, expected, editable
|
||||||
|
) -> None:
|
||||||
|
workspace, _repository = workspace_factory(permissions=permissions)
|
||||||
|
opened = []
|
||||||
|
workspace.diagnosis_requested.connect(lambda row, can_edit: opened.append((row["diagnosis_id"], can_edit)))
|
||||||
|
for index in range(workspace.table.rowCount()):
|
||||||
|
row = workspace.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
actions = workspace.table.cellWidget(index, 9)
|
||||||
|
assert actions.visible_labels == expected
|
||||||
|
if permissions == ("*",):
|
||||||
|
overflow = ["预约", "重新指派" if row["assistant_id"] else "指派医助"]
|
||||||
|
if not row["has_id_card"]:
|
||||||
|
overflow.append("补全身份证")
|
||||||
|
overflow.append("关联订单")
|
||||||
|
if row["appointment_id"] and row["appointment_status"] in (1, 4):
|
||||||
|
overflow.append("取消")
|
||||||
|
assert actions.overflow_labels == overflow
|
||||||
|
assert all(entry.property("danger") is True for entry in actions.menu.actions() if entry.text() == "取消")
|
||||||
|
else:
|
||||||
|
assert actions.more_button is None
|
||||||
|
assert actions.overflow_labels == []
|
||||||
|
if editable is not None:
|
||||||
|
_click(actions.buttons[0], application)
|
||||||
|
assert opened[-1] == (row["diagnosis_id"], editable)
|
||||||
|
if editable is None:
|
||||||
|
workspace.table.selectRow(0)
|
||||||
|
workspace._open_selected_diagnosis()
|
||||||
|
assert opened == []
|
||||||
|
assert workspace.bottom_actions.isHidden()
|
||||||
|
|
||||||
|
|
||||||
|
def _selector(workspace: PatientListWorkspace, row: int) -> QCheckBox:
|
||||||
|
return workspace.table.cellWidget(row, 0).findChild(QCheckBox)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_single_selection(workspace: PatientListWorkspace) -> None:
|
||||||
|
checked = [row for row in range(workspace.table.rowCount()) if _selector(workspace, row).isChecked()]
|
||||||
|
assert len(checked) <= 1
|
||||||
|
if checked:
|
||||||
|
assert checked == [workspace.table.currentRow()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorted_selectors_and_row_actions_keep_distinct_resource_ids(
|
||||||
|
application: QApplication, workspace_factory
|
||||||
|
) -> None:
|
||||||
|
workspace, _repository = workspace_factory()
|
||||||
|
table = workspace.table
|
||||||
|
first_id = table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"]
|
||||||
|
table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 0).data(Qt.ItemDataRole.UserRole)["diagnosis_id"] != first_id
|
||||||
|
for index in (0, 1):
|
||||||
|
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
_click(_selector(workspace, index), application)
|
||||||
|
assert table.current_data()["diagnosis_id"] == expected["diagnosis_id"]
|
||||||
|
assert _selector(workspace, index).isChecked()
|
||||||
|
_assert_single_selection(workspace)
|
||||||
|
|
||||||
|
# Leave a different row current: every callback must use its own sorted row.
|
||||||
|
table.selectRow(2)
|
||||||
|
_settle(application)
|
||||||
|
_assert_single_selection(workspace)
|
||||||
|
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||||
|
emitted = []
|
||||||
|
workspace.diagnosis_requested.connect(lambda row, editable: emitted.append(("diagnosis", row, editable)))
|
||||||
|
workspace.ai_consult_requested.connect(lambda row: emitted.append(("ai", row, None)))
|
||||||
|
workspace.appointment_requested.connect(lambda row: emitted.append(("appointment", row, None)))
|
||||||
|
for index in range(table.rowCount()):
|
||||||
|
expected = table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
actions = table.cellWidget(index, 9)
|
||||||
|
actions.buttons[0].click()
|
||||||
|
actions.buttons[1].click()
|
||||||
|
next(action for action in actions.menu.actions() if action.text() == "预约").trigger()
|
||||||
|
for kind, row, editable in emitted[-3:]:
|
||||||
|
assert row["diagnosis_id"] == expected["diagnosis_id"]
|
||||||
|
assert row["source_patient_id"] == expected["source_patient_id"]
|
||||||
|
assert row["appointment_id"] == expected["appointment_id"]
|
||||||
|
if kind == "diagnosis":
|
||||||
|
assert editable is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("silent", [False, True])
|
||||||
|
def test_loading_resets_changed_query_and_ignores_stale_success_and_error(
|
||||||
|
application: QApplication, workspace_factory, monkeypatch: pytest.MonkeyPatch, silent: bool
|
||||||
|
) -> None:
|
||||||
|
workspace, repository = workspace_factory()
|
||||||
|
table = workspace.table
|
||||||
|
queued = []
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", lambda function, **callbacks: queued.append((function, callbacks)))
|
||||||
|
workspace.keyword_edit.setText("旧请求")
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
workspace.keyword_edit.setText("最新请求")
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
_settle(application)
|
||||||
|
assert table.rowCount() == 0
|
||||||
|
assert workspace.content_stack.currentIndex() == 1
|
||||||
|
assert workspace.pager.isVisible()
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
# Workers must keep the UI-thread snapshot even after text changes again.
|
||||||
|
workspace.keyword_edit.setText("尚未查询")
|
||||||
|
newer = queued[1][0]()
|
||||||
|
assert repository.queries[-1]["keyword"] == "最新请求"
|
||||||
|
newer["lists"][0]["patient_name"] = "最新结果"
|
||||||
|
newer["extend"]["scope"]["label"] = "最新范围"
|
||||||
|
queued[1][1]["on_success"](newer)
|
||||||
|
current_item = table.item(0, 1)
|
||||||
|
assert current_item.text().startswith("最新结果")
|
||||||
|
stale = queued[0][0]()
|
||||||
|
assert repository.queries[-1]["keyword"] == "旧请求"
|
||||||
|
queued[0][1]["on_success"](stale)
|
||||||
|
queued[0][1]["on_error"](RuntimeError("过期失败"))
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 1) is current_item
|
||||||
|
assert workspace.scope_label.text() == "最新范围"
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
|
|
||||||
|
workspace.keyword_edit.setText("最新请求")
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
queued[-1][1]["on_error"](RuntimeError("患者查询失败"))
|
||||||
|
_settle(application)
|
||||||
|
assert table.item(0, 1) is current_item
|
||||||
|
assert workspace.banner.isVisible()
|
||||||
|
assert "患者查询失败" in " ".join(label.text() for label in workspace.banner.findChildren(QLabel))
|
||||||
|
workspace.refresh(silent=silent)
|
||||||
|
queued[-1][1]["on_success"](queued[-1][0]())
|
||||||
|
_settle(application)
|
||||||
|
assert not workspace.banner.isVisible()
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Library display units and query/identity contracts after the approved redesign."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui.pages import prescription_library as library
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class QueryRepository:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.rows = [
|
||||||
|
{"id": 41, "prescription_name": "乙方", "formula_type": "aux", "is_public": False,
|
||||||
|
"creator_id": 7, "creator_name": "本页医生", "efficacy": "益气养阴",
|
||||||
|
"herbs": [{"name": "白芍", "dosage": "15g"}], "create_time": "2026-07-15 10:30"},
|
||||||
|
{"id": 42, "prescription_name": "甲方", "formula_type": "main", "is_public": True,
|
||||||
|
"creator_id": 8, "creator_name": "其他医生", "efficacy": "清热祛湿",
|
||||||
|
"herbs": [{"name": "茯苓", "dosage": 10}], "create_time": "2026-07-18 16:20"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_prescription_templates(self, **query):
|
||||||
|
self.calls.append(deepcopy(query))
|
||||||
|
# Server total deliberately differs from the current-page/effect-filter count.
|
||||||
|
return {"lists": deepcopy(self.rows), "count": 44}
|
||||||
|
|
||||||
|
|
||||||
|
def settle(app):
|
||||||
|
for _ in range(4):
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def record_id(record):
|
||||||
|
return record["id"] if isinstance(record, dict) else record.id
|
||||||
|
|
||||||
|
|
||||||
|
def row_record(table, index):
|
||||||
|
return table.item(index, 0).data(Qt.ItemDataRole.UserRole)
|
||||||
|
|
||||||
|
|
||||||
|
def row_buttons(table, index):
|
||||||
|
return {button.accessibleName(): button for button in table.cellWidget(index, 9).findChildren(QPushButton)}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def local_execution(monkeypatch):
|
||||||
|
def reject(*_args, **_kwargs):
|
||||||
|
pytest.fail("Library regression tests must not contact external services")
|
||||||
|
|
||||||
|
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject)
|
||||||
|
monkeypatch.setattr(library, "run_async", immediate)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page_factory(application):
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(repository=None, *, permissions=None, user=None):
|
||||||
|
repository = repository or QueryRepository()
|
||||||
|
page = library.PrescriptionLibraryPage(
|
||||||
|
repository, {"*"} if permissions is None else permissions,
|
||||||
|
user or SimpleNamespace(id=7, root=0, role_ids=[]),
|
||||||
|
)
|
||||||
|
opened.append(page)
|
||||||
|
page.resize(1328, 884)
|
||||||
|
page.show()
|
||||||
|
settle(application)
|
||||||
|
assert page.table.rowCount() == 2
|
||||||
|
return page, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for page in opened:
|
||||||
|
for timer in page.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("dose, expected", [(10, "10g"), ("10", "10g"), ("10g", "10g"), ("15G", "15G"), (0, "0g")])
|
||||||
|
def test_display_unit_is_added_once_without_mutating_dose(dose, expected):
|
||||||
|
row = {"herbs": [{"medicine_id": 11, "name": "白芍", "dosage": dose}]}
|
||||||
|
before = deepcopy(row)
|
||||||
|
assert library._herbs_detail(None, row) == f"白芍 {expected}"
|
||||||
|
assert row == before
|
||||||
|
assert library._herbs_detail(None, {"herbs": [{"medicine_name": "茯苓", "amount": dose}]}) == f"茯苓 {expected}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_raw_doses_and_default_unselected_state_are_preserved(page_factory, monkeypatch):
|
||||||
|
monkeypatch.setattr(library, "datetime", SimpleNamespace(now=lambda: datetime(2026, 9, 5)))
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
|
originals = {identifier: repository.get_prescription_template(identifier) for identifier in (701, 702)}
|
||||||
|
snapshots = {identifier: deepcopy((row.herbs, row.raw)) for identifier, row in originals.items()}
|
||||||
|
assert library._herbs_detail(None, originals[701]) == "柴胡 10g、白芍 15g、茯苓 15g"
|
||||||
|
assert library._herbs_detail(None, originals[702]) == "酸枣仁 20g、夜交藤 30g"
|
||||||
|
assert {identifier: (row.herbs, row.raw) for identifier, row in originals.items()} == snapshots
|
||||||
|
page, _ = page_factory(repository, permissions=session.permissions, user=session.user)
|
||||||
|
assert page.table.columnCount() == 10
|
||||||
|
assert [page.table.horizontalHeader().logicalIndex(index) for index in range(10)] == list(range(10))
|
||||||
|
assert page.table.currentRow() == -1
|
||||||
|
assert all(not button.isEnabled() for button in (page.view_button, page.ai_button, page.edit_button, page.delete_button))
|
||||||
|
assert [page.metric_cards[key].value_label.text() for key in ("total", "private", "public", "month")] == ["2", "1", "1", "0"]
|
||||||
|
for index in range(2):
|
||||||
|
assert all(button.isEnabled() for button in row_buttons(page.table, index).values())
|
||||||
|
assert len(row_buttons(page.table, index)) == 4
|
||||||
|
assert page.table.item(index, 5).text() == "—"
|
||||||
|
identifier = record_id(row_record(page.table, index))
|
||||||
|
assert page.table.item(index, 4).text() == library._herbs_detail(None, originals[identifier])
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_return_reset_favorite_tab_and_page_send_original_dto(page_factory, application):
|
||||||
|
page, repository = page_factory()
|
||||||
|
repository.calls.clear()
|
||||||
|
page.name_filter.setText(" 测试名称 ")
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
settle(application)
|
||||||
|
page.formula_filter.setCurrentIndex(page.formula_filter.findData("主方"))
|
||||||
|
page.visibility_filter.setCurrentIndex(page.visibility_filter.findData(1))
|
||||||
|
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
|
||||||
|
assert repository.calls == [] # Combo selection still requires query or Return.
|
||||||
|
QTest.keyClick(page.name_filter, Qt.Key.Key_Return)
|
||||||
|
settle(application)
|
||||||
|
filtered = {"prescription_name": "测试名称", "formula_type": "主方", "is_public": 1, "page_no": 1, "page_size": 15}
|
||||||
|
assert repository.calls == [filtered]
|
||||||
|
assert page.table.rowCount() == 1
|
||||||
|
assert record_id(row_record(page.table, 0)) == 42
|
||||||
|
assert page.metric_cards["total"].value_label.text() == "44"
|
||||||
|
assert page.metric_cards["private"].value_label.text() == "0"
|
||||||
|
assert page.metric_cards["public"].value_label.text() == "1"
|
||||||
|
page.pager.load_more()
|
||||||
|
assert repository.calls[-1] == dict(filtered, page_no=2)
|
||||||
|
assert page.pager.page == 2
|
||||||
|
page.favorite_tab.click()
|
||||||
|
assert repository.calls[-2:] == [filtered, dict(filtered, page_no=2)] # Same query refreshes its loaded prefix.
|
||||||
|
assert page.favorite_tab.isChecked() and not page.all_tab.isChecked()
|
||||||
|
page.reset_button.click()
|
||||||
|
assert repository.calls[-1] == {"prescription_name": "", "formula_type": "", "is_public": "", "page_no": 1, "page_size": 15}
|
||||||
|
assert page.effect_filter.currentData() == ""
|
||||||
|
assert page.favorite_tab.isChecked()
|
||||||
|
assert page.table.rowCount() == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("column, order", [(0, Qt.SortOrder.DescendingOrder), (2, Qt.SortOrder.DescendingOrder)])
|
||||||
|
def test_sort_then_refresh_keeps_selection_tags_and_each_action_bound_to_visible_id(
|
||||||
|
page_factory, application, monkeypatch, column, order
|
||||||
|
):
|
||||||
|
page, repository = page_factory()
|
||||||
|
original_rows = deepcopy(repository.rows)
|
||||||
|
table = page.table
|
||||||
|
visited = []
|
||||||
|
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(record_id(table.current_data())))
|
||||||
|
table.sortItems(column, order)
|
||||||
|
settle(application)
|
||||||
|
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
|
||||||
|
|
||||||
|
def verify_visible_actions():
|
||||||
|
for index in range(2):
|
||||||
|
record = row_record(table, index)
|
||||||
|
assert table.item(index, 2).text() == ("主方" if record["formula_type"] == "main" else "辅方")
|
||||||
|
assert table.item(index, 6).text() == ("所有人可见" if record["is_public"] else "仅自己可见")
|
||||||
|
buttons = row_buttons(table, index)
|
||||||
|
assert buttons["编辑处方模板"].isEnabled() == (record["creator_id"] == 7)
|
||||||
|
table.selectRow(1 - index)
|
||||||
|
button = buttons["查看处方模板"]
|
||||||
|
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||||
|
assert visited[-1] == record["id"]
|
||||||
|
assert record_id(table.current_data()) == record["id"]
|
||||||
|
|
||||||
|
verify_visible_actions()
|
||||||
|
table.selectRow(next(index for index in range(2) if record_id(row_record(table, index)) == 41))
|
||||||
|
page.refresh()
|
||||||
|
settle(application)
|
||||||
|
assert record_id(table.current_data()) == 41
|
||||||
|
assert [record_id(row_record(table, row)) for row in range(2)] == [42, 41]
|
||||||
|
verify_visible_actions()
|
||||||
|
assert visited == [42, 41, 42, 41]
|
||||||
|
assert repository.rows == original_rows
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"permissions, user, expected_actions, can_edit_foreign",
|
||||||
|
[({"wcf.prescription/read"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释"}, False),
|
||||||
|
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, False),
|
||||||
|
({"wcf.prescription/*"}, SimpleNamespace(id=7, root=0, role_ids=[3]), {"查看处方模板", "AI 解释", "编辑处方模板", "删除处方模板"}, True)],
|
||||||
|
)
|
||||||
|
def test_row_action_permissions_and_foreign_public_management_are_preserved(
|
||||||
|
page_factory, permissions, user, expected_actions, can_edit_foreign
|
||||||
|
):
|
||||||
|
page, _ = page_factory(permissions=permissions, user=user)
|
||||||
|
index = next(index for index in range(2) if record_id(row_record(page.table, index)) == 42)
|
||||||
|
buttons = row_buttons(page.table, index)
|
||||||
|
assert set(buttons) == expected_actions
|
||||||
|
assert buttons["查看处方模板"].isEnabled()
|
||||||
|
assert buttons["AI 解释"].isEnabled()
|
||||||
|
if "编辑处方模板" in buttons:
|
||||||
|
assert buttons["编辑处方模板"].isEnabled() == can_edit_foreign
|
||||||
|
assert buttons["删除处方模板"].isEnabled() == can_edit_foreign
|
||||||
|
else:
|
||||||
|
assert page.edit_button.isHidden() and page.delete_button.isHidden()
|
||||||
@@ -144,23 +144,18 @@ def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> in
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown(
|
def test_infinite_footer_is_shared_compact_and_has_no_page_controls(application):
|
||||||
application: QApplication,
|
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||||
) -> None:
|
footer = InfiniteList(15)
|
||||||
pager = BusinessPager(15)
|
footer.show()
|
||||||
pager.update_state(2, 44)
|
|
||||||
pager.show()
|
|
||||||
_settle(application)
|
_settle(application)
|
||||||
|
assert prescriptions_module.InfiniteList is InfiniteList
|
||||||
assert prescriptions_module.BusinessPager is BusinessPager
|
assert footer.height() == 24
|
||||||
assert 40 <= pager.height() <= 44
|
assert footer.findChildren(QComboBox) == []
|
||||||
assert pager.minimumHeight() == pager.maximumHeight() == 42
|
assert not hasattr(footer, "page_size_label")
|
||||||
assert pager.findChildren(QComboBox) == []
|
assert not hasattr(footer, "next")
|
||||||
assert pager.page_size_label.text() == "15 条/页"
|
assert footer.layout().contentsMargins().bottom() == 0
|
||||||
margins = pager.layout().contentsMargins()
|
footer.close()
|
||||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4)
|
|
||||||
assert pager.page_label is not None and pager.page_label.text() == "2"
|
|
||||||
pager.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||||
@@ -182,9 +177,19 @@ def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
|||||||
header = page.findChild(QWidget, "PageHeader")
|
header = page.findChild(QWidget, "PageHeader")
|
||||||
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
|
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
|
||||||
toolbar = page.findChild(QFrame, toolbar_name)
|
toolbar = page.findChild(QFrame, toolbar_name)
|
||||||
assert header is not None and 60 <= header.height() <= 64
|
assert header is not None and header.height() >= header.minimumSizeHint().height()
|
||||||
assert toolbar is not None and 44 <= toolbar.height() <= 48
|
assert toolbar is not None
|
||||||
assert 40 <= page.pager.height() <= 44
|
if kind == "issued":
|
||||||
|
assert toolbar.height() == 64
|
||||||
|
assert page.pager.height() == 24
|
||||||
|
# Approved two-line rows retain complete 14 px text and warning content.
|
||||||
|
minimum_visible_rows = 3 if size[1] == 768 else 5
|
||||||
|
else:
|
||||||
|
# The approved library design adds the four metric cards and readable
|
||||||
|
# multiline herb/date rows. Keep meaningful row and pager reachability.
|
||||||
|
assert 60 <= toolbar.height() <= 70
|
||||||
|
assert page.pager.height() == 24
|
||||||
|
minimum_visible_rows = 2 if size[1] == 768 else 4
|
||||||
assert page.pager.minimumHeight() == page.pager.maximumHeight()
|
assert page.pager.minimumHeight() == page.pager.maximumHeight()
|
||||||
assert page.table.minimumHeight() == 0
|
assert page.table.minimumHeight() == 0
|
||||||
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||||
@@ -196,18 +201,21 @@ def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
|||||||
assert pager_position.x() + page.pager.width() <= page.width()
|
assert pager_position.x() + page.pager.width() <= page.width()
|
||||||
assert pager_position.y() >= 0
|
assert pager_position.y() >= 0
|
||||||
assert pager_position.y() + page.pager.height() <= page.height()
|
assert pager_position.y() + page.pager.height() <= page.height()
|
||||||
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + (
|
page_size_right = page.pager.summary_label.mapTo(page, QPoint()).x() + (
|
||||||
page.pager.page_size_label.width()
|
page.pager.summary_label.width()
|
||||||
)
|
)
|
||||||
assert page_size_right <= page.width()
|
assert page_size_right <= page.width()
|
||||||
pager_margins = page.pager.layout().contentsMargins()
|
pager_margins = page.pager.layout().contentsMargins()
|
||||||
toolbar_margins = toolbar.layout().contentsMargins()
|
toolbar_margins = toolbar.layout().contentsMargins()
|
||||||
assert pager_margins.left() == toolbar_margins.left() == 16
|
expected_inset = 16 if kind == "issued" else 18
|
||||||
assert pager_margins.right() == toolbar_margins.right() == 16
|
assert pager_margins.left() == 12
|
||||||
|
assert toolbar_margins.left() == expected_inset
|
||||||
|
assert pager_margins.right() == 12
|
||||||
|
assert toolbar_margins.right() == expected_inset
|
||||||
|
|
||||||
if kind == "issued":
|
if kind == "issued":
|
||||||
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
||||||
assert filters is not None and 84 <= filters.height() <= 92
|
assert filters is not None and filters.height() == 144
|
||||||
actions_host = page.table.cellWidget(0, 2)
|
actions_host = page.table.cellWidget(0, 2)
|
||||||
assert actions_host is not None
|
assert actions_host is not None
|
||||||
row_edit = next(
|
row_edit = next(
|
||||||
@@ -225,6 +233,8 @@ def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
|||||||
else:
|
else:
|
||||||
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
|
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
|
||||||
assert filters is not None
|
assert filters is not None
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
assert page.name_filter.minimumWidth() < 500
|
assert page.name_filter.minimumWidth() < 500
|
||||||
filter_right = filters.contentsRect().right()
|
filter_right = filters.contentsRect().right()
|
||||||
for control in (
|
for control in (
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ def test_number_column_renders_sn_id_and_visible_warning_with_dynamic_height(
|
|||||||
assert "已有关联业务订单,当前处方存在重复药材:黄 芪" in duplicate_tip
|
assert "已有关联业务订单,当前处方存在重复药材:黄 芪" in duplicate_tip
|
||||||
assert blank_height > normal_height
|
assert blank_height > normal_height
|
||||||
assert duplicate_height > normal_height
|
assert duplicate_height > normal_height
|
||||||
assert page.table.columnWidth(1) >= 250
|
assert page.table.columnWidth(1) == 192
|
||||||
|
|
||||||
image = page.table.viewport().grab().toImage().convertToFormat(QImage.Format.Format_RGB32)
|
image = page.table.viewport().grab().toImage().convertToFormat(QImage.Format.Format_RGB32)
|
||||||
red_pixels = 0
|
red_pixels = 0
|
||||||
|
|||||||
@@ -288,122 +288,121 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_diagnosis_order_detail_lookup_is_queued_before_repository_call(
|
def test_prescription_diagnosis_detail_uses_shared_structured_readonly_ui(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
queued: list[tuple[Any, dict[str, Any]]] = []
|
|
||||||
requested: list[int] = []
|
|
||||||
shown: list[tuple[int, str]] = []
|
|
||||||
|
|
||||||
class Repository:
|
class Repository:
|
||||||
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
|
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
|
||||||
requested.append(order_id)
|
assert diagnosis_id == 745
|
||||||
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
|
return []
|
||||||
|
|
||||||
def queue_async(function: Any, **options: Any) -> object:
|
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||||
queued.append((function, options))
|
|
||||||
return object()
|
|
||||||
|
|
||||||
def present_order_detail(
|
|
||||||
_host: Any,
|
|
||||||
order: dict[str, Any],
|
|
||||||
*,
|
|
||||||
order_id: int,
|
|
||||||
permissions: Any,
|
|
||||||
exec_: bool,
|
|
||||||
) -> None:
|
|
||||||
del permissions, exec_
|
|
||||||
shown.append((order_id, order["order_no"]))
|
|
||||||
|
|
||||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
|
||||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
|
||||||
dialog = DiagnosisDetailDialog(
|
dialog = DiagnosisDetailDialog(
|
||||||
{"orders": [{"id": 17, "order_no": "ROW-17"}]},
|
{
|
||||||
|
"diagnosis": {
|
||||||
|
"id": 745,
|
||||||
|
"patient_id": 745,
|
||||||
|
"patient_name": "庄志芳",
|
||||||
|
"phone": "13823549442",
|
||||||
|
"id_card": "440305196701011234",
|
||||||
|
"gender": 0,
|
||||||
|
"age": 59,
|
||||||
|
"chief_complaint": "睡眠不好、出汗多",
|
||||||
|
"past_history": ["高血压", "高脂血症"],
|
||||||
|
},
|
||||||
|
"patient": {"id": 745},
|
||||||
|
},
|
||||||
repository=Repository(),
|
repository=Repository(),
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
|
||||||
)
|
)
|
||||||
table = dialog._order_detail_table
|
|
||||||
button = dialog._order_detail_button
|
|
||||||
assert table is not None
|
|
||||||
assert button is not None
|
|
||||||
table.setCurrentCell(0, 0)
|
|
||||||
|
|
||||||
button.click()
|
assert isinstance(dialog, DiagnosisDialog)
|
||||||
|
assert dialog.view_stack.currentWidget() is dialog.readonly_page
|
||||||
assert len(queued) == 1
|
assert dialog.edit_fields["chief_complaint"].toPlainText() == "睡眠不好、出汗多"
|
||||||
assert requested == []
|
assert dialog.summary_fields["phone"].text() == "138****9442"
|
||||||
assert shown == []
|
assert dialog.case_grid.isVisibleTo(dialog)
|
||||||
assert not table.isEnabled()
|
assert not dialog.findChildren(dialog_module.QTextBrowser)
|
||||||
assert not button.isEnabled()
|
|
||||||
|
|
||||||
function, options = queued[0]
|
|
||||||
options["on_success"](function())
|
|
||||||
options["on_finished"]()
|
|
||||||
assert requested == [17]
|
|
||||||
assert shown == [(17, "DETAIL-17")]
|
|
||||||
assert table.isEnabled()
|
|
||||||
assert button.isEnabled()
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback(
|
@pytest.mark.parametrize("owner_width", [1024, 1440, 1710])
|
||||||
|
def test_prescription_editor_embeds_scrollable_diagnosis_beside_editable_form(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
owner_width: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
queued: list[dict[str, Any]] = []
|
class Repository:
|
||||||
shown: list[tuple[int, str]] = []
|
def get_diagnosis_detail(
|
||||||
|
self, diagnosis_id: int, *, readonly: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
assert readonly
|
||||||
|
return {
|
||||||
|
"diagnosis": {
|
||||||
|
"id": diagnosis_id,
|
||||||
|
"patient_id": 745,
|
||||||
|
"patient_name": "庄志芳",
|
||||||
|
"chief_complaint": "睡眠不好、出汗多",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def queue_async(_function: Any, **options: Any) -> object:
|
def get_doctor_notes(self, _diagnosis_id: int) -> list[dict[str, Any]]:
|
||||||
queued.append(options)
|
return []
|
||||||
return object()
|
|
||||||
|
|
||||||
def present_order_detail(
|
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
|
||||||
_host: Any,
|
owner = QDialog()
|
||||||
order: dict[str, Any],
|
owner.resize(owner_width, 720)
|
||||||
*,
|
owner.show()
|
||||||
order_id: int,
|
repository = Repository()
|
||||||
permissions: Any,
|
editor = PrescriptionEditorDialog(
|
||||||
exec_: bool,
|
repository,
|
||||||
) -> None:
|
{"diagnosis_id": 745, "patient_name": "庄志芳"},
|
||||||
del permissions, exec_
|
mode="edit",
|
||||||
shown.append((order_id, order["order_no"]))
|
current_user=SimpleNamespace(id=9, name="周医生"),
|
||||||
|
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
|
||||||
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
|
parent=owner,
|
||||||
monkeypatch.setattr(dialog_module, "run_async", queue_async)
|
|
||||||
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
|
|
||||||
dialog = DiagnosisDetailDialog(
|
|
||||||
{
|
|
||||||
"orders": [
|
|
||||||
{"id": 21, "order_no": "ROW-21"},
|
|
||||||
{"id": 22, "order_no": "ROW-22"},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
repository=repository,
|
|
||||||
)
|
)
|
||||||
table = dialog._order_detail_table
|
editor.show()
|
||||||
button = dialog._order_detail_button
|
application.processEvents()
|
||||||
assert table is not None
|
assert QApplication.activeModalWidget() is editor
|
||||||
assert button is not None
|
assert editor.width() == editor.DRAWER_WIDTH
|
||||||
|
|
||||||
table.setCurrentCell(0, 0)
|
editor.diagnosis_button.click()
|
||||||
dialog._open_selected_order()
|
application.processEvents()
|
||||||
table.setCurrentCell(1, 0)
|
|
||||||
dialog._open_selected_order()
|
|
||||||
assert len(queued) == 2
|
|
||||||
|
|
||||||
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
|
detail = editor._diagnosis_view
|
||||||
queued[0]["on_finished"]()
|
assert detail is not None
|
||||||
assert shown == []
|
assert QApplication.activeModalWidget() is editor
|
||||||
assert not table.isEnabled()
|
assert not detail.isWindow()
|
||||||
assert not button.isEnabled()
|
assert detail.parentWidget() is editor.diagnosis_host
|
||||||
|
assert editor.diagnosis_host.isVisibleTo(editor)
|
||||||
|
assert editor.drawer_surface.isVisibleTo(editor)
|
||||||
|
assert editor.width() == owner.width()
|
||||||
|
assert editor.diagnosis_host.geometry().right() < editor.drawer_surface.geometry().left()
|
||||||
|
scroll_bar = detail.readonly_scroll.verticalScrollBar()
|
||||||
|
assert scroll_bar.maximum() > 0
|
||||||
|
scroll_bar.setValue(scroll_bar.maximum())
|
||||||
|
assert scroll_bar.value() == scroll_bar.maximum()
|
||||||
|
|
||||||
queued[1]["on_error"](RuntimeError("detail unavailable"))
|
editor.patient_name.setText("庄志芳(已核对)")
|
||||||
queued[1]["on_finished"]()
|
assert editor.patient_name.isEnabled()
|
||||||
assert shown == [(22, "ROW-22")]
|
assert editor.payload()["patient_name"] == "庄志芳(已核对)"
|
||||||
assert table.isEnabled()
|
|
||||||
assert button.isEnabled()
|
detail.readonly_close_button.click()
|
||||||
dialog.close()
|
application.processEvents()
|
||||||
|
assert editor.isVisible()
|
||||||
|
assert not editor.diagnosis_host.isVisible()
|
||||||
|
assert editor.width() == editor.DRAWER_WIDTH
|
||||||
|
assert editor.patient_name.text() == "庄志芳(已核对)"
|
||||||
|
editor.diagnosis_button.click()
|
||||||
|
application.processEvents()
|
||||||
|
assert editor.diagnosis_host.isVisibleTo(editor)
|
||||||
|
editor.reject()
|
||||||
|
application.processEvents()
|
||||||
|
assert not editor.isVisible()
|
||||||
|
assert not detail.isVisible()
|
||||||
|
owner.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
@@ -413,7 +412,7 @@ def _finish_queued(callback: dict[str, Any], result: Any) -> None:
|
|||||||
callback["on_finished"]()
|
callback["on_finished"]()
|
||||||
|
|
||||||
|
|
||||||
def test_prescription_lists_snapshot_queries_and_replay_pending_refresh(
|
def test_prescription_lists_snapshot_queries_and_supersede_stale_refresh(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -436,7 +435,7 @@ def test_prescription_lists_snapshot_queries_and_replay_pending_refresh(
|
|||||||
library.refresh()
|
library.refresh()
|
||||||
library.name_filter.setText("新条件")
|
library.name_filter.setText("新条件")
|
||||||
library.refresh()
|
library.refresh()
|
||||||
assert len(library_callbacks) == 1
|
assert len(library_callbacks) == 2
|
||||||
first_function, first_options = library_callbacks[0]
|
first_function, first_options = library_callbacks[0]
|
||||||
first_result = first_function()
|
first_result = first_function()
|
||||||
_finish_queued(first_options, first_result)
|
_finish_queued(first_options, first_result)
|
||||||
@@ -465,7 +464,7 @@ def test_prescription_lists_snapshot_queries_and_replay_pending_refresh(
|
|||||||
issued.refresh()
|
issued.refresh()
|
||||||
issued.patient_filter.setText("新患者")
|
issued.patient_filter.setText("新患者")
|
||||||
issued.refresh()
|
issued.refresh()
|
||||||
assert len(issued_callbacks) == 1
|
assert len(issued_callbacks) == 2
|
||||||
first_function, first_options = issued_callbacks[0]
|
first_function, first_options = issued_callbacks[0]
|
||||||
first_result = first_function()
|
first_result = first_function()
|
||||||
_finish_queued(first_options, first_result)
|
_finish_queued(first_options, first_result)
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
|
|||||||
"disable_edit": 1,
|
"disable_edit": 1,
|
||||||
"creator_id": 9,
|
"creator_id": 9,
|
||||||
"creator_name": "张医生",
|
"creator_name": "张医生",
|
||||||
"create_time": "2026-08-10 12:00:00",
|
"create_time": QDate.currentDate().toString("yyyy-MM-dd") + " 12:00:00",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"count": 1,
|
"count": 1,
|
||||||
@@ -237,8 +237,9 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
|
|||||||
"创建时间",
|
"创建时间",
|
||||||
"操作",
|
"操作",
|
||||||
]
|
]
|
||||||
assert page.table.cellWidget(0, 2) is not None
|
# 处方类型与公开范围改由 _RowDecorationDelegate 绘制,只有操作列仍是真实控件。
|
||||||
assert page.table.cellWidget(0, 6) is not None
|
assert page.table.item(0, 2).data(prescription_module._ROLE_TAG_KIND) == "accent"
|
||||||
|
assert page.table.item(0, 6).data(prescription_module._ROLE_LEAD_ICON) == "lock"
|
||||||
assert page.table.cellWidget(0, 9) is not None
|
assert page.table.cellWidget(0, 9) is not None
|
||||||
assert not page.view_button.isHidden() and page.view_button.isEnabled()
|
assert not page.view_button.isHidden() and page.view_button.isEnabled()
|
||||||
assert not page.ai_button.isHidden() and page.ai_button.isEnabled()
|
assert not page.ai_button.isHidden() and page.ai_button.isEnabled()
|
||||||
@@ -305,8 +306,10 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
|
|||||||
assert page.table.columnCount() == 11
|
assert page.table.columnCount() == 11
|
||||||
assert page.table.horizontalHeaderItem(2).text() == "操作"
|
assert page.table.horizontalHeaderItem(2).text() == "操作"
|
||||||
assert page.table.cellWidget(0, 2) is not None
|
assert page.table.cellWidget(0, 2) is not None
|
||||||
assert page.table.cellWidget(0, 3) is not None
|
# 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
|
||||||
assert page.table.cellWidget(0, 6) is not None
|
# 各挂一个 QWidget;这里改为断言驱动绘制的角色数据仍然写入。
|
||||||
|
assert page.table.item(0, 3).data(prescription_module._ROLE_TAG_KIND) == "accent"
|
||||||
|
assert page.table.item(0, 6).data(prescription_module._ROLE_TAG_KIND)
|
||||||
row_edit = next(
|
row_edit = next(
|
||||||
button
|
button
|
||||||
for button in page.table.cellWidget(0, 2).findChildren(QPushButton)
|
for button in page.table.cellWidget(0, 2).findChildren(QPushButton)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Real prescription pages append server pages without losing row actions."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.ui.pages import prescription_library, prescriptions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def inline(function, *, on_success, on_error, on_finished):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def get(self, **q):
|
||||||
|
self.calls.append(dict(q))
|
||||||
|
start = (q["page_no"] - 1) * q["page_size"]
|
||||||
|
return {
|
||||||
|
"lists": [
|
||||||
|
{
|
||||||
|
"id": i,
|
||||||
|
"prescription_name": f"Template {i}",
|
||||||
|
"sn": f"RX{i}",
|
||||||
|
"patient_name": f"Patient {i}",
|
||||||
|
"creator_id": 1,
|
||||||
|
"is_public": False,
|
||||||
|
"efficacy": "清热祛湿" if i > 15 else "益气养阴",
|
||||||
|
"herbs": [],
|
||||||
|
"create_time": "2026-09-01 10:00",
|
||||||
|
}
|
||||||
|
for i in range(start + 1, min(start + q["page_size"], 37) + 1)
|
||||||
|
],
|
||||||
|
"count": 37,
|
||||||
|
"extend": {"doctors": [{"id": 1, "name": "Doctor"}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
list_prescriptions = get
|
||||||
|
list_prescription_templates = get
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"module,cls",
|
||||||
|
[
|
||||||
|
(prescriptions, prescriptions.PrescriptionsPage),
|
||||||
|
(prescription_library, prescription_library.PrescriptionLibraryPage),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_scroll_append_refresh_and_filter_reset(app, monkeypatch, module, cls):
|
||||||
|
monkeypatch.setattr(module, "run_async", inline)
|
||||||
|
repo = Repository()
|
||||||
|
page = cls(repo, {"*"}, SimpleNamespace(id=1, root=1, role_ids=[]))
|
||||||
|
page.resize(1250, 760)
|
||||||
|
page.show()
|
||||||
|
page.refresh()
|
||||||
|
# Let deferred filter/table geometry settle before scrolling its real viewport.
|
||||||
|
for _ in range(3):
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(35)
|
||||||
|
assert page.table.rowCount() == 15
|
||||||
|
page.table.selectRow(4)
|
||||||
|
before_id = page.table.current_data()["id"]
|
||||||
|
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||||
|
for _ in range(50):
|
||||||
|
if page.table.rowCount() == 30:
|
||||||
|
break
|
||||||
|
QTest.qWait(20)
|
||||||
|
assert page.table.rowCount() == 30 and page.table.current_data()["id"] == before_id
|
||||||
|
assert (
|
||||||
|
len({page.table.item(r, 0).data(Qt.ItemDataRole.UserRole)["id"] for r in range(30)}) == 30
|
||||||
|
)
|
||||||
|
page.refresh()
|
||||||
|
assert page.table.rowCount() == 30
|
||||||
|
assert [q["page_no"] for q in repo.calls[-2:]] == [1, 2]
|
||||||
|
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||||
|
QTest.qWait(90)
|
||||||
|
assert page.table.rowCount() == 37 and not page.pager.has_more
|
||||||
|
edit = page.name_filter if module is prescription_library else page.patient_filter
|
||||||
|
edit.setText("changed")
|
||||||
|
page._search()
|
||||||
|
assert page.table.rowCount() == 15 and repo.calls[-1]["page_no"] == 1
|
||||||
|
assert page.pager.height() == 24 and page.layout().contentsMargins().bottom() == 8
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_effect_filter_continues_until_matching_rows_are_visible(app, monkeypatch):
|
||||||
|
monkeypatch.setattr(prescription_library, "run_async", inline)
|
||||||
|
page = prescription_library.PrescriptionLibraryPage(
|
||||||
|
Repository(), {"*"}, SimpleNamespace(id=1, root=1, role_ids=[])
|
||||||
|
)
|
||||||
|
page.resize(1250, 760)
|
||||||
|
page.show()
|
||||||
|
page.effect_filter.setCurrentIndex(page.effect_filter.findData("清热祛湿"))
|
||||||
|
page._search()
|
||||||
|
QTest.qWait(180)
|
||||||
|
assert page.table.rowCount() >= 15
|
||||||
|
assert page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] > 15
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_status_sort_keeps_actions_on_the_visible_prescription(app, monkeypatch):
|
||||||
|
from PySide6.QtWidgets import QPushButton
|
||||||
|
|
||||||
|
monkeypatch.setattr(prescriptions, "run_async", inline)
|
||||||
|
|
||||||
|
class AuditRepository(Repository):
|
||||||
|
def get(self, **query):
|
||||||
|
result = super().get(**query)
|
||||||
|
for row in result["lists"]:
|
||||||
|
identifier = row["id"]
|
||||||
|
row.update(
|
||||||
|
audit_status=identifier % 3,
|
||||||
|
audit_remark="rejected reason" if identifier % 3 == 2 else "",
|
||||||
|
business_prescription_audit_rejected=identifier % 2 == 0,
|
||||||
|
business_prescription_audit_remark="business reason"
|
||||||
|
if identifier % 2 == 0
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
list_prescriptions = get
|
||||||
|
|
||||||
|
page = prescriptions.PrescriptionsPage(AuditRepository(), {"*"}, SimpleNamespace(id=1))
|
||||||
|
page.refresh()
|
||||||
|
opened = []
|
||||||
|
page._view_selected = lambda: opened.append(page.table.current_data()["id"])
|
||||||
|
page.table.sortItems(6, Qt.SortOrder.DescendingOrder)
|
||||||
|
page.pager.load_more()
|
||||||
|
page.refresh()
|
||||||
|
assert page.table.rowCount() == 30
|
||||||
|
for row in range(page.table.rowCount()):
|
||||||
|
expected = page.table.item(row, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||||
|
buttons = page.table.cellWidget(row, 2).findChildren(QPushButton)
|
||||||
|
next(button for button in buttons if button.accessibleName() == "查看处方").click()
|
||||||
|
assert opened[-1] == expected
|
||||||
|
page.close()
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Native interactions and reachability unique to the issued-prescription blue UI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, QRect, Qt, QTimer
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QAbstractItemView, QApplication, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui import icons
|
||||||
|
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||||
|
from doctor_workstation.ui.shell import ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(5):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _rect_in(widget, parent) -> QRect:
|
||||||
|
return QRect(widget.mapTo(parent, QPoint()), widget.size())
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_visible_within(widget, viewport) -> None:
|
||||||
|
assert widget.isVisible()
|
||||||
|
rect = _rect_in(widget, viewport)
|
||||||
|
assert viewport.rect().contains(rect), (widget.objectName(), rect, viewport.rect())
|
||||||
|
|
||||||
|
|
||||||
|
def _record_id(table, row: int) -> int:
|
||||||
|
return int(table.item(row, 0).data(Qt.ItemDataRole.UserRole).id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def shell_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
network_attempts = []
|
||||||
|
|
||||||
|
def reject_network(*_args, **_kwargs):
|
||||||
|
network_attempts.append("socket")
|
||||||
|
pytest.fail("Prescription blue interaction tests must use local Demo data only")
|
||||||
|
|
||||||
|
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
monkeypatch.setattr(prescriptions_module, "run_async", immediate)
|
||||||
|
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(width=1536, height=960):
|
||||||
|
repository = DemoDoctorRepository()
|
||||||
|
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||||
|
permissions = PermissionSet(["*"])
|
||||||
|
shell = ShellWindow(repository, {
|
||||||
|
"user": session.user,
|
||||||
|
"permissions": permissions,
|
||||||
|
"demo_mode": True,
|
||||||
|
"menu": [{"perms": "tcm.prescription/lists"}],
|
||||||
|
}, permissions=permissions)
|
||||||
|
opened.append(shell)
|
||||||
|
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||||
|
shell.resize(width, height)
|
||||||
|
shell.show()
|
||||||
|
assert shell.navigate("prescriptions")
|
||||||
|
_settle(application)
|
||||||
|
page = shell.pages["prescriptions"]
|
||||||
|
assert {_record_id(page.table, row) for row in range(page.table.rowCount())} == {801, 802}
|
||||||
|
assert (shell.width(), shell.height()) == (width, height)
|
||||||
|
return shell, page
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for shell in opened:
|
||||||
|
for timer in shell.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
shell.close()
|
||||||
|
shell.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
assert network_attempts == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("width", "height", "filter_height"),
|
||||||
|
[(1536, 960, 144), (1366, 768, 144), (1024, 768, 200), (1024, 640, 200)],
|
||||||
|
ids=["full-desktop", "compact-desktop", "small-window", "minimum-window"],
|
||||||
|
)
|
||||||
|
def test_shell_preserves_all_columns_filters_and_reachable_pager(
|
||||||
|
shell_factory, application, width, height, filter_height
|
||||||
|
) -> None:
|
||||||
|
shell, page = shell_factory(width, height)
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
table = page.table
|
||||||
|
assert [column.key for column in table.columns] == [
|
||||||
|
"__selected__", "sn", "__actions__", "prescription_type", "is_system_auto",
|
||||||
|
"patient_name", "audit_status", "void_status", "doctor_name", "assistant_name", "create_time",
|
||||||
|
]
|
||||||
|
assert [table.horizontalHeader().logicalIndex(index) for index in range(11)] == [
|
||||||
|
0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 2,
|
||||||
|
]
|
||||||
|
assert table.horizontalHeaderItem(2).text() == "操作"
|
||||||
|
assert page.filter_card.height() == filter_height
|
||||||
|
for name in ("quick_date", "start_time", "end_time", "audit_filter", "source_filter",
|
||||||
|
"sn_filter", "patient_filter", "doctor_filter", "query_button", "reset_button"):
|
||||||
|
_assert_visible_within(getattr(page, name), page.filter_card)
|
||||||
|
|
||||||
|
outer_scroll = page.scroll.verticalScrollBar()
|
||||||
|
if width >= 1366:
|
||||||
|
assert outer_scroll.maximum() == 0
|
||||||
|
_assert_visible_within(page.filter_card, page.scroll.viewport())
|
||||||
|
_assert_visible_within(page.pager, page.scroll.viewport())
|
||||||
|
else:
|
||||||
|
# The compact footer allows 1024x768 to fit without outer scrolling.
|
||||||
|
if height <= 640:
|
||||||
|
assert outer_scroll.maximum() > 0
|
||||||
|
assert table.horizontalScrollBar().maximum() > 0
|
||||||
|
|
||||||
|
if width == 1536:
|
||||||
|
assert table.horizontalScrollBar().maximum() == 0
|
||||||
|
for row in range(table.rowCount()):
|
||||||
|
for column in range(11):
|
||||||
|
assert table.viewport().rect().contains(table.visualItemRect(table.item(row, column))), (row, column)
|
||||||
|
_assert_visible_within(page.pager, shell)
|
||||||
|
|
||||||
|
# Every logical column remains reachable in the native table at every width.
|
||||||
|
outer_scroll.setValue(outer_scroll.maximum())
|
||||||
|
for column in range(11):
|
||||||
|
table.scrollToItem(table.item(0, column), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||||
|
_settle(application)
|
||||||
|
assert table.viewport().rect().contains(table.visualItemRect(table.item(0, column))), column
|
||||||
|
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
|
||||||
|
_settle(application)
|
||||||
|
action_host = table.cellWidget(0, 2)
|
||||||
|
assert action_host is not None
|
||||||
|
_assert_visible_within(action_host, table.viewport())
|
||||||
|
_assert_visible_within(action_host, page.scroll.viewport())
|
||||||
|
_assert_visible_within(page.pager, page.scroll.viewport())
|
||||||
|
_assert_visible_within(page.pager.summary_label, shell)
|
||||||
|
assert page.pager.height() == 24
|
||||||
|
assert not hasattr(page.pager, "next")
|
||||||
|
for button in page.pager.findChildren(QPushButton):
|
||||||
|
if button.isVisible():
|
||||||
|
_assert_visible_within(button, page.scroll.viewport())
|
||||||
|
for button in action_host.findChildren(QPushButton):
|
||||||
|
_assert_visible_within(button, table.viewport())
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_checkbox_center_clicks_and_space_keep_row_binding(shell_factory, application) -> None:
|
||||||
|
_shell, page = shell_factory()
|
||||||
|
table = page.table
|
||||||
|
row = next(index for index in range(table.rowCount()) if _record_id(table, index) == 802)
|
||||||
|
selector = table.item(row, 0)
|
||||||
|
table.setCurrentItem(selector)
|
||||||
|
table.setFocus()
|
||||||
|
_settle(application)
|
||||||
|
center = table.visualItemRect(selector).center()
|
||||||
|
assert table.viewport().rect().contains(center)
|
||||||
|
assert selector.checkState() == Qt.CheckState.Unchecked
|
||||||
|
|
||||||
|
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||||
|
assert selector.checkState() == Qt.CheckState.Checked
|
||||||
|
QTest.mouseClick(table.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||||
|
assert selector.checkState() == Qt.CheckState.Unchecked
|
||||||
|
QTest.keyClick(table, Qt.Key.Key_Space)
|
||||||
|
assert selector.checkState() == Qt.CheckState.Checked
|
||||||
|
assert int(table.current_data().id) == 802
|
||||||
|
assert selector.data(Qt.ItemDataRole.UserRole).id == 802
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("order", [Qt.SortOrder.AscendingOrder, Qt.SortOrder.DescendingOrder])
|
||||||
|
def test_sorted_native_row_view_button_targets_visible_record(
|
||||||
|
shell_factory, application, monkeypatch, order
|
||||||
|
) -> None:
|
||||||
|
_shell, page = shell_factory()
|
||||||
|
table = page.table
|
||||||
|
visited = []
|
||||||
|
monkeypatch.setattr(page, "_view_selected", lambda: visited.append(int(table.current_data().id)))
|
||||||
|
table.sortItems(1, order)
|
||||||
|
_settle(application)
|
||||||
|
expected_order = [801, 802] if order == Qt.SortOrder.AscendingOrder else [802, 801]
|
||||||
|
assert [_record_id(table, index) for index in range(2)] == expected_order
|
||||||
|
for row in range(2):
|
||||||
|
# Start on the other record, so a stale action that only uses selection fails.
|
||||||
|
table.selectRow(1 - row)
|
||||||
|
host = table.cellWidget(row, 2)
|
||||||
|
button = next(button for button in host.findChildren(QPushButton)
|
||||||
|
if button.accessibleName() == "查看处方")
|
||||||
|
_assert_visible_within(button, table.viewport())
|
||||||
|
QTest.mouseClick(button, Qt.MouseButton.LeftButton, pos=button.rect().center())
|
||||||
|
assert visited[-1] == expected_order[row]
|
||||||
|
assert int(table.current_data().id) == expected_order[row]
|
||||||
|
assert visited == expected_order
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_icon_disabled_variant_does_not_modify_shared_icon_cache(application) -> None:
|
||||||
|
kind, color, size = "pencil", "#1769E8", 19
|
||||||
|
shared = icons.icon(kind, color, size)
|
||||||
|
original_key = shared.cacheKey()
|
||||||
|
original_normal = shared.pixmap(size, size, QIcon.Mode.Normal).toImage()
|
||||||
|
original_disabled = shared.pixmap(size, size, QIcon.Mode.Disabled).toImage()
|
||||||
|
|
||||||
|
blue = prescriptions_module._blue_prescription_icon(kind, color, size)
|
||||||
|
|
||||||
|
assert icons.icon(kind, color, size) is shared
|
||||||
|
assert shared.cacheKey() == original_key
|
||||||
|
assert shared.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||||
|
assert shared.pixmap(size, size, QIcon.Mode.Disabled).toImage() == original_disabled
|
||||||
|
assert blue.pixmap(size, size, QIcon.Mode.Normal).toImage() == original_normal
|
||||||
|
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() == icons.pixmap(kind, "#A4ADBA", size).toImage()
|
||||||
|
assert blue.pixmap(size, size, QIcon.Mode.Disabled).toImage() != original_disabled
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_doctor_button_paints_compactly_without_truncating_real_value(shell_factory, application) -> None:
|
||||||
|
_shell, page = shell_factory(1024, 768)
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
button = page.doctor_filter.button
|
||||||
|
full_text = "联合会诊专家门诊陈医生、疑难病联合门诊林医生 等3人"
|
||||||
|
button.setText(full_text)
|
||||||
|
_settle(application)
|
||||||
|
assert button.fontMetrics().horizontalAdvance(full_text) > button.width() - 48
|
||||||
|
assert button.text() == full_text
|
||||||
|
assert button.toolTip() == full_text
|
||||||
|
assert not button.grab().isNull() # Execute the actual compact paint path.
|
||||||
|
assert button.text() == full_text
|
||||||
|
assert page.doctor_filter.values() == []
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtGui import QPalette
|
||||||
|
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import reception as reception_module
|
||||||
|
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(app):
|
||||||
|
for _ in range(8):
|
||||||
|
app.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page(application, monkeypatch):
|
||||||
|
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||||
|
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||||
|
widget.resize(1340, 900)
|
||||||
|
widget.show()
|
||||||
|
widget.poll_timer.stop()
|
||||||
|
widget.detail_stack.setCurrentIndex(1)
|
||||||
|
widget.patient_name_label.setText("林晓岚")
|
||||||
|
widget.patient_meta_label.setText("女 · 46岁 · 138****1203 | 就诊号:101")
|
||||||
|
settle(application)
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_primary_action_and_reading_ink_use_separate_colors(page, application):
|
||||||
|
page.video_button.setEnabled(True)
|
||||||
|
settle(application)
|
||||||
|
assert page.video_button.palette().color(QPalette.ColorRole.Button).name() == "#1769e8"
|
||||||
|
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#ffffff"
|
||||||
|
assert page.case_labels["present"].palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||||
|
page.video_button.setEnabled(False)
|
||||||
|
settle(application)
|
||||||
|
assert page.video_button.palette().color(QPalette.ColorRole.ButtonText).name() == "#8e8f90"
|
||||||
|
|
||||||
|
|
||||||
|
def test_typography_preserves_regular_body_and_distinct_values(page):
|
||||||
|
body = page.case_labels["present"]
|
||||||
|
assert body.font().pixelSize() == 14
|
||||||
|
assert body.font().weight() == 400
|
||||||
|
assert page.patient_name_label.font().pixelSize() == 22
|
||||||
|
assert page.patient_name_label.font().weight() == 600
|
||||||
|
assert page.vital_labels["height"].font().pixelSize() == 18
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width", [1340, 1080, 816])
|
||||||
|
def test_identity_and_actions_stay_inside_hero_at_both_layouts(page, application, width):
|
||||||
|
page.resize(width, 900)
|
||||||
|
settle(application)
|
||||||
|
hero = page.findChild(QFrame, "ReceptionHero")
|
||||||
|
for button in (page.notify_button, page.history_button, page.video_button, page.more_button):
|
||||||
|
assert hero.rect().contains(button.geometry())
|
||||||
|
assert button.width() >= button.minimumSizeHint().width()
|
||||||
|
if width == 1340:
|
||||||
|
assert abs(page.video_button.geometry().center().y() - page.patient_avatar_label.geometry().center().y()) < 20
|
||||||
|
else:
|
||||||
|
assert page.video_button.geometry().top() >= page.patient_meta_label.geometry().bottom()
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_growth_stays_readable_and_does_not_cover_actions(page, application):
|
||||||
|
body = page.case_labels["present"]
|
||||||
|
body.setText("患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n" * 12)
|
||||||
|
settle(application)
|
||||||
|
assert body.height() >= body.heightForWidth(body.width())
|
||||||
|
assert page.detail_scroll.verticalScrollBar().maximum() > 0
|
||||||
|
assert body.textFormat() == Qt.TextFormat.PlainText
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["6.8 mmol/L", "1234.56 mg/g", "≤0.25 mg/L", "—"])
|
||||||
|
def test_lab_value_keeps_full_plain_text_and_wraps_long_units(page, application, value):
|
||||||
|
label = page.lab_labels["fasting"]
|
||||||
|
label.setText(value)
|
||||||
|
settle(application)
|
||||||
|
assert label.text() == value
|
||||||
|
assert label.textFormat() == Qt.TextFormat.PlainText
|
||||||
|
for width in (55, 120):
|
||||||
|
layout, size = label._value_layout(width)
|
||||||
|
assert sum(layout.lineAt(i).textLength() for i in range(layout.lineCount())) == len(value)
|
||||||
|
assert size.height() == label.heightForWidth(width)
|
||||||
|
for i in range(layout.lineCount()):
|
||||||
|
line = layout.lineAt(i)
|
||||||
|
assert line.naturalTextWidth() <= width + 1
|
||||||
|
assert line.y() + line.height() <= size.height()
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_summary_and_navigation_tabs_remain_available(page):
|
||||||
|
summary = page.findChild(QLabel, "ReceptionQueueSummary")
|
||||||
|
assert summary.isVisible()
|
||||||
|
assert [page.detail_tabs.tabText(i) for i in range(page.detail_tabs.count())] == [
|
||||||
|
"问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_risks_remain_in_full_report_while_preview_matches_approved_image(page, application):
|
||||||
|
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||||
|
settle(application)
|
||||||
|
page._render_ai_risk_chips([
|
||||||
|
{"label": "血糖波动风险", "level": "medium"},
|
||||||
|
{"label": "睡眠质量下降", "level": "low"},
|
||||||
|
])
|
||||||
|
settle(application)
|
||||||
|
assert not page.ai_risk_chip_host.isVisible()
|
||||||
|
dialog = reception_module._ReceptionAiAnalysisDialog({"qwen": {
|
||||||
|
"diagnosis_advice": "需要结合病史复核。",
|
||||||
|
"risk_assessment": [{"label": "血糖波动风险", "level": "medium"}],
|
||||||
|
"treatment_advice": "完整治疗建议仍可阅读。",
|
||||||
|
}})
|
||||||
|
dialog.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
risk = next(label for label in dialog.findChildren(QLabel) if label.text() == "血糖波动风险")
|
||||||
|
assert risk.isVisible()
|
||||||
|
assert risk.width() >= risk.fontMetrics().horizontalAdvance(risk.text())
|
||||||
|
assert any(label.text() == "完整治疗建议仍可阅读。" for label in dialog.findChildren(QLabel))
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_ai_keeps_full_treatment_data_without_pushing_controls_out_of_view(page, application):
|
||||||
|
page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page)
|
||||||
|
page.ai_summary_label.setText("需要结合完整病史与复诊记录核对。" * 25)
|
||||||
|
page.ai_treatment_label.setText("请结合患者实际情况复核完整分析。" * 25)
|
||||||
|
page._render_ai_risk_chips([{"label": "血糖波动风险", "level": "medium"}])
|
||||||
|
settle(application)
|
||||||
|
assert page.ai_treatment_label.text() == "请结合患者实际情况复核完整分析。" * 25
|
||||||
|
assert not page.ai_treatment_label.isVisible()
|
||||||
|
assert not page.ai_question_edit.isVisible()
|
||||||
|
assert not page.ai_analysis_history_button.isVisible()
|
||||||
|
actions = [action.text() for action in page.more_button.menu().actions()]
|
||||||
|
assert "向 AI 提问" in actions
|
||||||
|
assert "历史 AI 报告" in actions
|
||||||
|
assert "重新分析 AI 报告" in actions
|
||||||
|
assert page.ai_analysis_card.height() < 320
|
||||||
|
|
||||||
|
|
||||||
|
def test_patient_switch_clears_previous_condition_tooltip(page):
|
||||||
|
page.patient_meta_label.setToolTip("上一位患者的病情提示")
|
||||||
|
page._reset_detail_content({"id": 202, "patient_name": "新患者"})
|
||||||
|
assert page.patient_meta_label.toolTip() == ""
|
||||||
|
page.patient_meta_label.setToolTip("已失效的病情提示")
|
||||||
|
page._render_detail_load_failure("详情暂不可用")
|
||||||
|
assert page.patient_meta_label.toolTip() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_moved_ai_controls_keep_actions_and_enabled_state(application, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||||
|
monkeypatch.setattr(ReceptionPage, "_open_ai_analysis_dialog", lambda self: calls.append("history"))
|
||||||
|
monkeypatch.setattr(ReceptionPage, "_regenerate_patient_ai_reports", lambda self: calls.append("regenerate"))
|
||||||
|
monkeypatch.setattr(ReceptionPage, "_open_ai_assistant", lambda self, prompt: calls.append(("ask", prompt)))
|
||||||
|
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||||
|
try:
|
||||||
|
widget.poll_timer.stop()
|
||||||
|
assert not widget.ai_history_action.isEnabled()
|
||||||
|
assert not widget.ai_regenerate_action.isEnabled()
|
||||||
|
widget._ai_analysis_histories["qwen"] = [{"diagnosis_advice": "示例分析"}]
|
||||||
|
widget._ai_analysis_patient_flow = True
|
||||||
|
widget._ai_analysis_patient_id = 101
|
||||||
|
widget._sync_ai_analysis_view()
|
||||||
|
assert widget.ai_history_action.isEnabled()
|
||||||
|
assert widget.ai_regenerate_action.isEnabled()
|
||||||
|
widget.ai_history_action.trigger()
|
||||||
|
widget.ai_regenerate_action.trigger()
|
||||||
|
next(action for action in widget.more_button.menu().actions() if action.text() == "向 AI 提问").trigger()
|
||||||
|
widget.ai_assistant_card.findChild(QPushButton, "ReceptionAiTitle").click()
|
||||||
|
assert calls == ["history", "regenerate", ("ask", ""), ("ask", "")]
|
||||||
|
widget._ai_analysis_regenerating = True
|
||||||
|
widget._sync_ai_analysis_view()
|
||||||
|
assert not widget.ai_regenerate_action.isEnabled()
|
||||||
|
finally:
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
settle(application)
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, QSize
|
||||||
|
from PySide6.QtGui import QFont, QFontInfo, QIcon
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui import shell as shell_module
|
||||||
|
from doctor_workstation.ui.reception_style import (
|
||||||
|
TECH_BLUE,
|
||||||
|
body_family,
|
||||||
|
heading_family,
|
||||||
|
number_family,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Page(QWidget):
|
||||||
|
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.label = QLabel("原有页面正文", self)
|
||||||
|
layout.addWidget(self.label)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
navigation = [
|
||||||
|
NavigationItem(key, title, "", _Page, ("doctor.appointment/lists",))
|
||||||
|
for key, title in (
|
||||||
|
("appointments", "挂号列表"),
|
||||||
|
("consultations", "问诊列表"),
|
||||||
|
("reception", "接诊台"),
|
||||||
|
("patients", "我的患者"),
|
||||||
|
("prescriptions", "已开处方"),
|
||||||
|
("prescription_library", "我的处方库"),
|
||||||
|
("legacy_reference", "原版框架参照"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
shell_module,
|
||||||
|
"_resolve_navigation",
|
||||||
|
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||||
|
shell = ShellWindow(
|
||||||
|
object(),
|
||||||
|
{"user": {"name": "陈医生"}, "demo_mode": True},
|
||||||
|
permissions={"doctor.appointment/lists", "tcm.diagnosis/aiAssistant"},
|
||||||
|
)
|
||||||
|
shell.resize(1536, 960)
|
||||||
|
shell.show()
|
||||||
|
application.processEvents()
|
||||||
|
yield shell
|
||||||
|
shell.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _pixel(widget: QWidget, x: int, y: int) -> str:
|
||||||
|
image = widget.grab().toImage()
|
||||||
|
ratio = image.devicePixelRatio()
|
||||||
|
return image.pixelColor(round(x * ratio), round(y * ratio)).name().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def test_approved_chrome_restores_styles_fonts_icons_and_geometry(
|
||||||
|
application: QApplication, window: ShellWindow
|
||||||
|
) -> None:
|
||||||
|
assert window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
original_app_qss = application.styleSheet()
|
||||||
|
original_app_font = application.font().toString()
|
||||||
|
original_colors = dict(COLORS)
|
||||||
|
original_page_fonts = {key: page.label.font().toString() for key, page in window.pages.items()}
|
||||||
|
original_styles = dict(window._legacy_chrome_styles)
|
||||||
|
original_avatar = window.user_menu_button.icon().pixmap(34, 34).toImage()
|
||||||
|
original_ai = window.ai_top_button.icon().pixmap(18, 18).toImage()
|
||||||
|
original_nav = (
|
||||||
|
window.nav_buttons["reception"]
|
||||||
|
.icon()
|
||||||
|
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||||
|
.toImage()
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library", "consultations", "appointments"):
|
||||||
|
assert window.navigate(key)
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 208
|
||||||
|
assert _pixel(window, 6, 300) == "#F3F7FD"
|
||||||
|
assert _pixel(window.sidebar, 5, 300) == "#F3F7FD"
|
||||||
|
assert _pixel(window.workspace, 600, 10) == TECH_BLUE["surface"]
|
||||||
|
assert window.nav_buttons[key].font().family() == body_family()
|
||||||
|
assert window.nav_buttons[key].font().weight() == QFont.Weight.Normal
|
||||||
|
assert window.brand_name.font().family() == heading_family()
|
||||||
|
assert window.brand_name.font().weight() == QFont.Weight.DemiBold
|
||||||
|
assert window.assistant_glyph.property("receptionTechBlue") is True
|
||||||
|
assert window.assistant_status.property("receptionTechBlue") is True
|
||||||
|
assert original_avatar != window.user_menu_button.icon().pixmap(34, 34).toImage()
|
||||||
|
assert original_ai != window.ai_top_button.icon().pixmap(18, 18).toImage()
|
||||||
|
assert (
|
||||||
|
original_nav
|
||||||
|
!= window.nav_buttons["reception"]
|
||||||
|
.icon()
|
||||||
|
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||||
|
.toImage()
|
||||||
|
)
|
||||||
|
assert {
|
||||||
|
key: page.label.font().toString() for key, page in window.pages.items()
|
||||||
|
} == original_page_fonts
|
||||||
|
|
||||||
|
assert window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 190
|
||||||
|
assert _pixel(window, 6, 300) == COLORS["canvas"].upper()
|
||||||
|
assert _pixel(window, 18, 313) == COLORS["canvas"].upper()
|
||||||
|
assert {widget: widget.styleSheet() for widget in original_styles} == original_styles
|
||||||
|
assert window.user_menu_button.icon().pixmap(34, 34).toImage() == original_avatar
|
||||||
|
assert window.ai_top_button.icon().pixmap(18, 18).toImage() == original_ai
|
||||||
|
assert (
|
||||||
|
window.nav_buttons["reception"]
|
||||||
|
.icon()
|
||||||
|
.pixmap(18, 18, QIcon.Mode.Normal, QIcon.State.On)
|
||||||
|
.toImage()
|
||||||
|
== original_nav
|
||||||
|
)
|
||||||
|
assert window.assistant_glyph.property("receptionTechBlue") is False
|
||||||
|
assert window.assistant_status.property("receptionTechBlue") is False
|
||||||
|
|
||||||
|
assert application.styleSheet() == original_app_qss
|
||||||
|
assert application.font().toString() == original_app_font
|
||||||
|
assert original_colors == COLORS
|
||||||
|
assert {
|
||||||
|
key: page.label.font().toString() for key, page in window.pages.items()
|
||||||
|
} == original_page_fonts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||||
|
def test_selected_navigation_and_ai_tile_use_approved_blue(
|
||||||
|
application: QApplication, window: ShellWindow, key: str
|
||||||
|
) -> None:
|
||||||
|
window.navigate(key)
|
||||||
|
application.processEvents()
|
||||||
|
button = window.nav_buttons[key]
|
||||||
|
button.clearFocus()
|
||||||
|
application.processEvents()
|
||||||
|
assert _pixel(button, 1, button.height() // 2) == TECH_BLUE["accent"]
|
||||||
|
assert _pixel(button, button.width() - 12, button.height() // 2) == TECH_BLUE["selection"]
|
||||||
|
assert _pixel(window.assistant_glyph, 10, 25) == TECH_BLUE["accent"]
|
||||||
|
assert _pixel(window.assistant_status, 3, 9) == TECH_BLUE["accent"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||||
|
def test_approved_brand_mark_uses_blue_medical_cross_and_restores_original(
|
||||||
|
application: QApplication, window: ShellWindow, key: str
|
||||||
|
) -> None:
|
||||||
|
assert window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
mark = window.brand_mark
|
||||||
|
original = mark.grab().toImage()
|
||||||
|
assert window.navigate(key)
|
||||||
|
application.processEvents()
|
||||||
|
assert mark.property("receptionTechBlue") is True
|
||||||
|
assert mark.width() == mark.height() == 38
|
||||||
|
assert _pixel(mark, 5, 18) == TECH_BLUE["accent"]
|
||||||
|
assert _pixel(mark, 19, 19) == TECH_BLUE["accent"]
|
||||||
|
assert _pixel(mark, 18, 9) == TECH_BLUE["surface"]
|
||||||
|
assert original != mark.grab().toImage()
|
||||||
|
assert window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
assert mark.property("receptionTechBlue") is False
|
||||||
|
assert mark.grab().toImage() == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapsed_rail_keeps_state_across_approved_and_original_pages(
|
||||||
|
application: QApplication, window: ShellWindow
|
||||||
|
) -> None:
|
||||||
|
window.toggle_sidebar()
|
||||||
|
assert window.sidebar.width() == 68
|
||||||
|
window.navigate("reception")
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 68
|
||||||
|
assert window.nav_buttons["reception"].isChecked()
|
||||||
|
assert not window.assistant_card.isVisible()
|
||||||
|
assert window.nav_buttons["reception"].toolTip() == "接诊台"
|
||||||
|
window.toggle_sidebar()
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 208
|
||||||
|
assert window.assistant_card.isVisible()
|
||||||
|
window.toggle_sidebar()
|
||||||
|
window.navigate("appointments")
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 68
|
||||||
|
assert window.nav_buttons["appointments"].isChecked()
|
||||||
|
assert window.nav_buttons["appointments"].toolTip() == "挂号列表"
|
||||||
|
assert not window.assistant_card.isVisible()
|
||||||
|
window.navigate("consultations")
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 68
|
||||||
|
assert window.nav_buttons["consultations"].isChecked()
|
||||||
|
assert window.nav_buttons["consultations"].toolTip() == "问诊列表"
|
||||||
|
assert not window.assistant_card.isVisible()
|
||||||
|
window.navigate("patients")
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 68
|
||||||
|
assert window.nav_buttons["patients"].isChecked()
|
||||||
|
window.toggle_sidebar()
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 208
|
||||||
|
window.toggle_sidebar()
|
||||||
|
window.navigate("prescriptions")
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 68
|
||||||
|
assert window.nav_buttons["prescriptions"].isChecked()
|
||||||
|
assert window.nav_buttons["prescriptions"].toolTip() == "已开处方"
|
||||||
|
assert not window.assistant_card.isVisible()
|
||||||
|
window.toggle_sidebar()
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 208
|
||||||
|
window.toggle_sidebar()
|
||||||
|
window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 68
|
||||||
|
window.toggle_sidebar()
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 190
|
||||||
|
assert window.nav_buttons["legacy_reference"].text() == "原版框架参照"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||||
|
def test_approved_shell_geometry_and_function_entries_restore(
|
||||||
|
application: QApplication, window: ShellWindow, key: str
|
||||||
|
) -> None:
|
||||||
|
assert window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
original_geometry = {
|
||||||
|
widget: (widget.size(), widget.iconSize() if hasattr(widget, "iconSize") else None)
|
||||||
|
for widget in (
|
||||||
|
window.sidebar, window.topbar, window.search_host, window.brand_mark,
|
||||||
|
window.assistant_card, window.assistant_button, window.assistant_status,
|
||||||
|
window.upload_settings_button, window.user_menu_button,
|
||||||
|
window.refresh_button, window.notification_button, window.settings_button,
|
||||||
|
*window.nav_buttons.values(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert window.navigate(key)
|
||||||
|
application.processEvents()
|
||||||
|
assert window.workspace.pos() == QPoint(208, 0)
|
||||||
|
assert window.topbar.height() == 76
|
||||||
|
assert window.centralWidget().layout().contentsMargins().isNull()
|
||||||
|
assert window.search_host.mapTo(window, QPoint()) == QPoint(234, 15)
|
||||||
|
assert window.search_host.size() == QSize(384, 44)
|
||||||
|
assert window.assistant_card.mapTo(window, QPoint()) == QPoint(20, 695)
|
||||||
|
assert window.assistant_card.size() == QSize(168, 171)
|
||||||
|
assert window.assistant_button.height() == 46
|
||||||
|
assert window.brand_name.font().pixelSize() == 18
|
||||||
|
assert window.brand_subtitle.font().pixelSize() == 13
|
||||||
|
for index, button in enumerate(window.nav_buttons.values()):
|
||||||
|
assert button.mapTo(window, QPoint()).y() == 99 + 58 * index
|
||||||
|
assert button.height() == 52
|
||||||
|
assert button.font().pixelSize() == 15
|
||||||
|
assert button.iconSize() == QSize(20, 20)
|
||||||
|
assert not window.fold_button.isVisible()
|
||||||
|
assert not window.ai_top_button.isVisible()
|
||||||
|
assert window.menu_sidebar_action.isVisible()
|
||||||
|
assert window.menu_ai_action.isVisible()
|
||||||
|
window.menu_sidebar_action.trigger()
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 68
|
||||||
|
assert window.menu_sidebar_action.isVisible()
|
||||||
|
window.menu_sidebar_action.trigger()
|
||||||
|
application.processEvents()
|
||||||
|
assert window.sidebar.width() == 208
|
||||||
|
assert window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
assert window.workspace.pos() == QPoint(203, 13)
|
||||||
|
assert window.fold_button.isVisible()
|
||||||
|
assert window.ai_top_button.isVisible()
|
||||||
|
assert not window.menu_sidebar_action.isVisible()
|
||||||
|
assert not window.menu_ai_action.isVisible()
|
||||||
|
assert not window.user_separator.isVisible()
|
||||||
|
for widget, (size, icon_size) in original_geometry.items():
|
||||||
|
assert widget.size() == size
|
||||||
|
if icon_size is not None:
|
||||||
|
assert widget.iconSize() == icon_size
|
||||||
|
|
||||||
|
|
||||||
|
def test_reception_fonts_resolve_real_requested_weights(application: QApplication) -> None:
|
||||||
|
for family, weights in (
|
||||||
|
(body_family(), (QFont.Weight.Normal,)),
|
||||||
|
(heading_family(), (QFont.Weight.Medium, QFont.Weight.DemiBold)),
|
||||||
|
(number_family(), (QFont.Weight.Normal, QFont.Weight.DemiBold)),
|
||||||
|
):
|
||||||
|
for weight in weights:
|
||||||
|
font = QFont(family)
|
||||||
|
font.setPixelSize(15)
|
||||||
|
font.setWeight(weight)
|
||||||
|
resolved = QFontInfo(font)
|
||||||
|
assert resolved.family() == family
|
||||||
|
assert resolved.weight() == weight
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||||
|
def test_minimum_window_keeps_search_and_all_chrome_actions_separate(
|
||||||
|
application: QApplication, window: ShellWindow, key: str
|
||||||
|
) -> None:
|
||||||
|
window.navigate(key)
|
||||||
|
window.resize(1024, 640)
|
||||||
|
application.processEvents()
|
||||||
|
assert window.width() == 1024
|
||||||
|
assert 220 <= window.search_host.width() < 384
|
||||||
|
controls = (
|
||||||
|
window.search_host, window.refresh_button, window.notification_button,
|
||||||
|
window.settings_button, window.user_menu_button, window.minimize_button,
|
||||||
|
window.fullscreen_button, window.close_button,
|
||||||
|
)
|
||||||
|
for left, right in zip(controls, controls[1:], strict=False):
|
||||||
|
assert left.x() + left.width() <= right.x()
|
||||||
|
assert right.x() + right.width() <= window.topbar.width()
|
||||||
|
window.resize(1536, 960)
|
||||||
|
application.processEvents()
|
||||||
|
assert window.search_host.size() == QSize(384, 44)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_animation_keeps_running_and_restores_palette_on_page_switch(
|
||||||
|
application: QApplication, window: ShellWindow
|
||||||
|
) -> None:
|
||||||
|
window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
button = window.refresh_button
|
||||||
|
original = button.icon().pixmap(18, 18).toImage()
|
||||||
|
button.start_spin()
|
||||||
|
for key in ("appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"):
|
||||||
|
window.navigate(key)
|
||||||
|
application.processEvents()
|
||||||
|
assert button._timer.isActive()
|
||||||
|
assert button.property("receptionTechBlue") is True
|
||||||
|
window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
assert button._timer.isActive()
|
||||||
|
assert button.property("receptionTechBlue") is False
|
||||||
|
assert button._resting.pixmap(18, 18).toImage() == original
|
||||||
|
button._timer.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_between_approved_pages_keeps_existing_chrome_assets(
|
||||||
|
application: QApplication, window: ShellWindow
|
||||||
|
) -> None:
|
||||||
|
assert window.navigate("appointments")
|
||||||
|
application.processEvents()
|
||||||
|
styles = {widget: widget.styleSheet() for widget in window._legacy_chrome_styles}
|
||||||
|
icons = {
|
||||||
|
widget: widget.icon().cacheKey()
|
||||||
|
for widget in (
|
||||||
|
window.user_menu_button, window.refresh_button, window.ai_top_button,
|
||||||
|
*window.nav_buttons.values(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
window.global_search.setText("患者查询")
|
||||||
|
|
||||||
|
for key in ("consultations", "reception", "patients", "prescriptions", "prescription_library", "appointments", "consultations"):
|
||||||
|
assert window.navigate(key)
|
||||||
|
application.processEvents()
|
||||||
|
assert window.nav_buttons[key].isChecked()
|
||||||
|
assert window.global_search.text() == "患者查询"
|
||||||
|
assert window._reception_chrome_active
|
||||||
|
assert {widget: widget.styleSheet() for widget in styles} == styles
|
||||||
|
assert {widget: widget.icon().cacheKey() for widget in icons} == icons
|
||||||
@@ -59,7 +59,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
|
|||||||
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
|
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
|
||||||
clicked: list[bool] = []
|
clicked: list[bool] = []
|
||||||
button.clicked.connect(lambda: clicked.append(True))
|
button.clicked.connect(lambda: clicked.append(True))
|
||||||
assert surface_color(button) == "#fff7f8"
|
assert surface_color(button) == "#ffffff"
|
||||||
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||||
|
|
||||||
QTest.mouseMove(button, button.rect().center())
|
QTest.mouseMove(button, button.rect().center())
|
||||||
@@ -81,7 +81,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
|
|||||||
button.clearFocus()
|
button.clearFocus()
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
assert clicked == []
|
assert clicked == []
|
||||||
assert surface_color(button) == "#fff7f8"
|
assert surface_color(button) == "#ffffff"
|
||||||
assert button.geometry() == geometry
|
assert button.geometry() == geometry
|
||||||
assert neighbor.geometry() == neighbor_geometry
|
assert neighbor.geometry() == neighbor_geometry
|
||||||
|
|
||||||
@@ -93,14 +93,14 @@ def test_completion_disabled_hover_does_not_look_or_act_enabled(controls):
|
|||||||
button.setEnabled(False)
|
button.setEnabled(False)
|
||||||
QTest.mouseMove(button, button.rect().center())
|
QTest.mouseMove(button, button.rect().center())
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
assert surface_color(button) == "#f7f8fb"
|
assert surface_color(button) == "#f7fafe"
|
||||||
assert button.cursor().shape() == Qt.CursorShape.ArrowCursor
|
assert button.cursor().shape() == Qt.CursorShape.ArrowCursor
|
||||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
|
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
|
||||||
assert clicked == []
|
assert clicked == []
|
||||||
button.setEnabled(True)
|
button.setEnabled(True)
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||||
assert surface_color(button) != "#f7f8fb"
|
assert surface_color(button) != "#f7f7f7"
|
||||||
|
|
||||||
|
|
||||||
def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
||||||
@@ -112,11 +112,11 @@ def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
|||||||
button.setFocus(Qt.FocusReason.TabFocusReason)
|
button.setFocus(Qt.FocusReason.TabFocusReason)
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
assert button.hasFocus()
|
assert button.hasFocus()
|
||||||
assert surface_color(button, border=True) == "#cf4656"
|
assert surface_color(button, border=True) == "#be4b58"
|
||||||
assert surface_color(button, border=True) != border_before
|
assert surface_color(button, border=True) != border_before
|
||||||
assert button.geometry() == geometry
|
assert button.geometry() == geometry
|
||||||
assert neighbor.geometry() == neighbor_geometry
|
assert neighbor.geometry() == neighbor_geometry
|
||||||
QTest.mouseMove(button, button.rect().center())
|
QTest.mouseMove(button, button.rect().center())
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
assert surface_color(button) == "#fff0f2"
|
assert surface_color(button) == "#fff0f2"
|
||||||
assert surface_color(button, border=True) == "#cf4656"
|
assert surface_color(button, border=True) == "#be4b58"
|
||||||
|
|||||||
@@ -51,6 +51,52 @@ def application() -> QApplication:
|
|||||||
return QApplication.instance() or QApplication([])
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width", [1114, 1320, 1494])
|
||||||
|
def test_visible_history_updates_grow_clinical_card_without_clipping(
|
||||||
|
application: QApplication,
|
||||||
|
queued_async: list[dict[str, Any]],
|
||||||
|
width: int,
|
||||||
|
) -> None:
|
||||||
|
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||||
|
page.resize(width, 824)
|
||||||
|
page.show()
|
||||||
|
try:
|
||||||
|
application.processEvents()
|
||||||
|
page.detail_stack.setCurrentIndex(1)
|
||||||
|
for _ in range(8):
|
||||||
|
application.processEvents()
|
||||||
|
label = page.case_labels["present"]
|
||||||
|
clinical = page.clinical_info_group
|
||||||
|
initial_height = clinical.height()
|
||||||
|
initial_scroll_maximum = page.detail_scroll.verticalScrollBar().maximum()
|
||||||
|
history = (
|
||||||
|
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
|
||||||
|
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
|
||||||
|
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
|
||||||
|
)
|
||||||
|
label.setText(history)
|
||||||
|
application.processEvents()
|
||||||
|
assert label.text() == history
|
||||||
|
assert label.height() >= label.heightForWidth(label.width())
|
||||||
|
|
||||||
|
label.setText("\n".join([history] * 5))
|
||||||
|
application.processEvents()
|
||||||
|
assert label.height() >= label.heightForWidth(label.width())
|
||||||
|
assert clinical.height() > initial_height
|
||||||
|
assert page.detail_scroll.verticalScrollBar().maximum() > initial_scroll_maximum
|
||||||
|
assert label.maximumHeight() > label.height()
|
||||||
|
|
||||||
|
expanded_height = clinical.height()
|
||||||
|
label.setText("无特殊不适。")
|
||||||
|
application.processEvents()
|
||||||
|
assert label.height() >= label.heightForWidth(label.width())
|
||||||
|
assert clinical.height() < expanded_height
|
||||||
|
finally:
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
def run_immediately(
|
def run_immediately(
|
||||||
@@ -622,6 +668,7 @@ def test_queue_date_picker_filters_the_selected_day(
|
|||||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||||
page.show()
|
page.show()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
page.filter_disclosure.button.click()
|
||||||
page.queue_date_button.click()
|
page.queue_date_button.click()
|
||||||
calendar = page.queue_date_button.calendarWidget()
|
calendar = page.queue_date_button.calendarWidget()
|
||||||
assert calendar.isVisible()
|
assert calendar.isVisible()
|
||||||
@@ -1036,14 +1083,14 @@ def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summar
|
|||||||
caption = medication.findChild(QLabel, "ReceptionCaseFieldCaption")
|
caption = medication.findChild(QLabel, "ReceptionCaseFieldCaption")
|
||||||
value = medication.findChild(QLabel, "ReceptionCaseFieldValue")
|
value = medication.findChild(QLabel, "ReceptionCaseFieldValue")
|
||||||
assert title is not None and title.font().pixelSize() == 18
|
assert title is not None and title.font().pixelSize() == 18
|
||||||
assert caption is not None and caption.font().pixelSize() == 12
|
assert caption is not None and caption.font().pixelSize() == 13
|
||||||
assert value is not None and value.font().pixelSize() == 14
|
assert value is not None and value.font().pixelSize() == 14
|
||||||
assert title.font().weight() >= 700
|
assert title.font().weight() == 600
|
||||||
assert caption.font().weight() >= 600
|
assert caption.font().weight() == 400
|
||||||
assert value.font().weight() >= 700
|
assert value.font().weight() == 600
|
||||||
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#17264d"
|
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||||
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#617092"
|
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#5d6b80"
|
||||||
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#253a83"
|
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||||
assert value.wordWrap()
|
assert value.wordWrap()
|
||||||
assert value.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByMouse
|
assert value.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByMouse
|
||||||
|
|
||||||
@@ -1135,7 +1182,7 @@ def test_queue_load_more_accumulates_to_total_boundary(
|
|||||||
assert [call["page_no"] for call in calls] == [1, 2]
|
assert [call["page_no"] for call in calls] == [1, 2]
|
||||||
assert all(call["page_size"] == 15 for call in calls)
|
assert all(call["page_size"] == 15 for call in calls)
|
||||||
assert page.queue_list.count() == 22
|
assert page.queue_list.count() == 22
|
||||||
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
|
assert page.queue_summary.text() == "共 22 位患者"
|
||||||
assert not page._queue_has_more()
|
assert not page._queue_has_more()
|
||||||
page.close()
|
page.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
@@ -1466,11 +1513,11 @@ def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
|
|||||||
buttons = [
|
buttons = [
|
||||||
button
|
button
|
||||||
for button in (
|
for button in (
|
||||||
page.complete_button,
|
|
||||||
page.notify_button,
|
page.notify_button,
|
||||||
page.history_button,
|
page.history_button,
|
||||||
page.video_button,
|
page.video_button,
|
||||||
page.more_button,
|
page.more_button,
|
||||||
|
page.complete_button,
|
||||||
)
|
)
|
||||||
if button.isVisibleTo(page)
|
if button.isVisibleTo(page)
|
||||||
]
|
]
|
||||||
@@ -1688,8 +1735,8 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
|||||||
assert page.ai_analysis_card.maximumHeight() > 520
|
assert page.ai_analysis_card.maximumHeight() > 520
|
||||||
assert page.ai_analysis_card.findChildren(QScrollArea) == []
|
assert page.ai_analysis_card.findChildren(QScrollArea) == []
|
||||||
assert left.height() == right.height()
|
assert left.height() == right.height()
|
||||||
assert 0 <= right.left() - left.right() - 1 <= 2
|
assert right.left() - left.right() - 1 == 10
|
||||||
assert abs(left.width() * 5 - right.width() * 4) <= 10
|
assert abs(left.width() / (left.width() + right.width()) - 0.425) < 0.01
|
||||||
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
|
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
|
||||||
assert page.ai_summary_label.minimumSizeHint().width() <= 48
|
assert page.ai_summary_label.minimumSizeHint().width() <= 48
|
||||||
|
|
||||||
|
|||||||
@@ -400,9 +400,11 @@ def test_refresh_button_visible_and_f5_uses_same_debounce(harness, application)
|
|||||||
page.poll_timer.stop()
|
page.poll_timer.stop()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
assert page.refresh_button.isVisible()
|
assert page.refresh_button.isVisible()
|
||||||
assert page.refresh_button.text() == "刷新"
|
assert not page.refresh_button.icon().isNull()
|
||||||
assert page.refresh_button.width() >= 50
|
assert page.refresh_button.accessibleName() == "刷新接诊台"
|
||||||
assert page.refresh_button.geometry().right() < page.queue_date_button.geometry().left()
|
assert page.refresh_button.width() >= 34
|
||||||
|
assert page.refresh_button.geometry().right() < page.filter_disclosure.button.geometry().left()
|
||||||
|
assert not page.queue_date_button.isVisible()
|
||||||
page.note_edit.setFocus()
|
page.note_edit.setFocus()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
|
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidge
|
|||||||
|
|
||||||
from doctor_workstation.ui import shell as shell_module
|
from doctor_workstation.ui import shell as shell_module
|
||||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||||
from doctor_workstation.ui.theme import apply_theme
|
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||||
|
|
||||||
|
|
||||||
def _logical_pixel(image: Any, x: int, y: int):
|
def _logical_pixel(image: Any, x: int, y: int):
|
||||||
@@ -135,6 +135,7 @@ def shell_window(
|
|||||||
("prescriptions", "已开处方", "笺", "tcm.prescription/lists"),
|
("prescriptions", "已开处方", "笺", "tcm.prescription/lists"),
|
||||||
("patients", "我的患者", "患", "firstvisit.myPatient/lists"),
|
("patients", "我的患者", "患", "firstvisit.myPatient/lists"),
|
||||||
("consultations", "问诊列表", "询", "tcm.diagnosis/lists"),
|
("consultations", "问诊列表", "询", "tcm.diagnosis/lists"),
|
||||||
|
("legacy_reference", "原版框架参照", "旧", "legacy.reference/lists"),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -160,38 +161,40 @@ def shell_window(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
|
def test_legacy_shell_matches_reference_geometry_at_both_acceptance_sizes(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
shell_window: ShellWindow,
|
shell_window: ShellWindow,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
assert shell_window.navigate("legacy_reference")
|
||||||
for width, height in ((1024, 640), (1440, 900)):
|
for width, height in ((1024, 640), (1440, 900)):
|
||||||
shell_window.resize(width, height)
|
shell_window.resize(width, height)
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
assert shell_window.sidebar.width() == 179
|
# 独立legacy参照路由继续验证原有导轨和外圈留白。
|
||||||
|
assert shell_window.sidebar.width() == 190
|
||||||
assert shell_window.topbar.height() == 62
|
assert shell_window.topbar.height() == 62
|
||||||
assert shell_window.tabs_host.height() == 0
|
assert shell_window.tabs_host.height() == 0
|
||||||
assert shell_window.workspace.width() == width - 26 - 179
|
assert shell_window.workspace.width() == width - 26 - 190
|
||||||
assert shell_window.stack.width() == width - 26 - 179
|
assert shell_window.stack.width() == width - 26 - 190
|
||||||
assert shell_window.stack.height() == height - 26 - 62
|
assert shell_window.stack.height() == height - 26 - 62
|
||||||
assert shell_window.stack.geometry().right() < shell_window.workspace.width()
|
assert shell_window.stack.geometry().right() < shell_window.workspace.width()
|
||||||
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
|
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
|
||||||
|
|
||||||
image = shell_window.grab().toImage()
|
image = shell_window.grab().toImage()
|
||||||
assert _logical_pixel(image, 20, 300).name().lower() in {
|
# 侧边栏不再是画布上的一块面板:它就是画布本身,所以导轨内任意一点
|
||||||
"#f2f5fd",
|
# 都必须与外圈留白同色。原先它是 #F4F7FE→#EEF3FD 的斜向渐变,
|
||||||
"#f3f6fd",
|
# 沿整条左边缘都对不上画布,形成一道常驻接缝。
|
||||||
"#f2f6fe",
|
assert _logical_pixel(image, 20, 300).name().lower() == COLORS["canvas"].lower()
|
||||||
"#f3f6fe",
|
assert _logical_pixel(image, 6, 300).name().lower() == COLORS["canvas"].lower()
|
||||||
}
|
|
||||||
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
|
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
|
||||||
assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe"
|
assert _logical_pixel(image, 220, 90).name().lower() == COLORS["canvas_mid"].lower()
|
||||||
|
|
||||||
|
|
||||||
def test_topbar_search_actions_and_navigation_controls_stay_aligned(
|
def test_legacy_topbar_search_actions_and_navigation_controls_stay_aligned(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
shell_window: ShellWindow,
|
shell_window: ShellWindow,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
assert shell_window.navigate("legacy_reference")
|
||||||
for width, height in ((1024, 640), (1366, 768)):
|
for width, height in ((1024, 640), (1366, 768)):
|
||||||
shell_window.resize(width, height)
|
shell_window.resize(width, height)
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
@@ -243,6 +246,7 @@ def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -
|
|||||||
def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
|
def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
|
||||||
shell_window: ShellWindow,
|
shell_window: ShellWindow,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
assert shell_window.navigate("legacy_reference")
|
||||||
assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint
|
assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint
|
||||||
assert (
|
assert (
|
||||||
shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号"
|
shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号"
|
||||||
@@ -405,15 +409,17 @@ def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
|
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||||
|
def test_shell_ai_menu_on_approved_pages_opens_global_patient_picker(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
shell_window: ShellWindow,
|
shell_window: ShellWindow,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
key: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
reception = shell_window.pages["reception"]
|
page = shell_window.pages[key]
|
||||||
assert isinstance(reception, _ShellPageDouble)
|
assert isinstance(page, _ShellPageDouble)
|
||||||
assert shell_window.navigate("reception")
|
assert shell_window.navigate(key)
|
||||||
reception.ai_context_available = True
|
page.ai_context_available = True
|
||||||
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
shell_module,
|
shell_module,
|
||||||
@@ -421,25 +427,34 @@ def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
|
|||||||
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
|
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
|
||||||
)
|
)
|
||||||
|
|
||||||
shell_window.ai_top_button.click()
|
assert shell_window.menu_ai_action.isVisible()
|
||||||
|
shell_window.menu_ai_action.trigger()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
assert reception.ai_open_count == 0
|
assert page.ai_open_count == 0
|
||||||
assert len(opened) == 1
|
assert len(opened) == 1
|
||||||
assert shell_window.stack.currentWidget() is reception
|
assert shell_window.stack.currentWidget() is page
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("key", "permission"),
|
||||||
|
[("appointments", "doctor.appointment/lists"), ("consultations", "tcm.diagnosis/lists"),
|
||||||
|
("patients", "firstvisit.myPatient/lists"), ("prescriptions", "tcm.prescription/lists"),
|
||||||
|
("prescription_library", "tcm.prescriptionLibrary/lists")],
|
||||||
|
)
|
||||||
def test_shell_hides_global_ai_entries_without_ai_permission(
|
def test_shell_hides_global_ai_entries_without_ai_permission(
|
||||||
application: QApplication,
|
application: QApplication,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
key: str,
|
||||||
|
permission: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
navigation = [
|
navigation = [
|
||||||
NavigationItem(
|
NavigationItem(
|
||||||
"appointments",
|
key,
|
||||||
"问诊列表",
|
"问诊列表",
|
||||||
"号",
|
"号",
|
||||||
_ShellPageDouble,
|
_ShellPageDouble,
|
||||||
("doctor.appointment/lists",),
|
(permission,),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -452,13 +467,19 @@ def test_shell_hides_global_ai_entries_without_ai_permission(
|
|||||||
window = ShellWindow(
|
window = ShellWindow(
|
||||||
object(),
|
object(),
|
||||||
{"user": {"name": "无 AI 权限医生"}, "demo_mode": True},
|
{"user": {"name": "无 AI 权限医生"}, "demo_mode": True},
|
||||||
permissions={"doctor.appointment/lists"},
|
permissions={permission},
|
||||||
)
|
)
|
||||||
window.show()
|
window.show()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
assert window.assistant_card.isHidden()
|
assert window.assistant_card.isHidden()
|
||||||
assert window.ai_top_button.isHidden()
|
assert window.ai_top_button.isHidden()
|
||||||
|
assert not window.menu_ai_action.isVisible()
|
||||||
|
assert window.menu_sidebar_action.isVisible()
|
||||||
|
assert window._active_page_key == key
|
||||||
|
assert window.sidebar.width() == 208
|
||||||
|
assert window.topbar.height() == 76
|
||||||
|
assert window.centralWidget().layout().contentsMargins().isNull()
|
||||||
|
|
||||||
window.close()
|
window.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
@@ -640,20 +661,135 @@ def test_non_fixed_tabs_close_and_active_close_renavigates(
|
|||||||
assert shell_window.stack.currentWidget() is shell_window.pages["appointments"]
|
assert shell_window.stack.currentWidget() is shell_window.pages["appointments"]
|
||||||
|
|
||||||
|
|
||||||
def test_sidebar_collapse_preserves_active_navigation(
|
def test_topbar_refresh_button_reloads_whichever_page_is_open(
|
||||||
|
application: QApplication,
|
||||||
shell_window: ShellWindow,
|
shell_window: ShellWindow,
|
||||||
) -> None:
|
) -> None:
|
||||||
assert shell_window.navigate("consultations")
|
"""The button existed in the code but was never added to the layout.
|
||||||
|
|
||||||
|
It also matters more than it used to: list loads no longer raise a banner,
|
||||||
|
so this is the only control that acknowledges a manual reload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
button = shell_window.refresh_button
|
||||||
|
assert button.isVisible()
|
||||||
|
assert button.parentWidget() is shell_window.topbar
|
||||||
|
|
||||||
|
for key in ("consultations", "patients"):
|
||||||
|
assert shell_window.navigate(key)
|
||||||
|
application.processEvents()
|
||||||
|
page = shell_window.pages[key]
|
||||||
|
before = page.refresh_count
|
||||||
|
button.click()
|
||||||
|
application.processEvents()
|
||||||
|
assert page.refresh_count == before + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_topbar_refresh_button_spins_while_the_page_loads(
|
||||||
|
application: QApplication,
|
||||||
|
shell_window: ShellWindow,
|
||||||
|
) -> None:
|
||||||
|
"""A spin that cannot stop is worse than no spin at all."""
|
||||||
|
|
||||||
|
button = shell_window.refresh_button
|
||||||
|
resting = button.icon().cacheKey()
|
||||||
|
|
||||||
|
button.click()
|
||||||
|
application.processEvents()
|
||||||
|
assert button._timer.isActive()
|
||||||
|
|
||||||
|
# The page double never reports itself busy, so the spin ends as soon as the
|
||||||
|
# minimum has elapsed rather than running for the full cap.
|
||||||
|
button._elapsed = _ElapsedStub(button._MIN_MS + 1)
|
||||||
|
button._tick()
|
||||||
|
|
||||||
|
assert not button._timer.isActive()
|
||||||
|
assert button.icon().cacheKey() == resting
|
||||||
|
|
||||||
|
|
||||||
|
class _ElapsedStub:
|
||||||
|
def __init__(self, value: int) -> None:
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def elapsed(self) -> int:
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def restart(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_window_ground_carries_the_only_corner(
|
||||||
|
application: QApplication,
|
||||||
|
shell_window: ShellWindow,
|
||||||
|
) -> None:
|
||||||
|
"""The bottom-most layer is the one that rounds; nothing nests inside it.
|
||||||
|
|
||||||
|
The rail used to paint its own 16 px corner on top of a square canvas, so
|
||||||
|
each window corner showed a corner inside a corner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert shell_window.navigate("legacy_reference")
|
||||||
|
application.processEvents()
|
||||||
|
image = shell_window.grab().toImage()
|
||||||
|
|
||||||
|
# Outside the ground's corner there is nothing at all ...
|
||||||
|
assert _logical_pixel(image, 2, 2).alpha() == 0
|
||||||
|
# ... and well inside it the ground is the flat canvas colour, both in the
|
||||||
|
# outer gutter and inside the rail.
|
||||||
|
assert _logical_pixel(image, 8, 200).name().lower() == COLORS["canvas"].lower()
|
||||||
|
assert _logical_pixel(image, 60, 200).name().lower() == COLORS["canvas"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_approved_pages_share_shell_geometry_and_other_pages_restore(
|
||||||
|
application: QApplication,
|
||||||
|
shell_window: ShellWindow,
|
||||||
|
) -> None:
|
||||||
|
"""The six approved pages share chrome; each remaining page keeps its geometry."""
|
||||||
|
|
||||||
|
geometries = set()
|
||||||
|
for key in [*shell_window.pages, "reception", "consultations", "appointments", "patients", "prescriptions", "prescription_library"]:
|
||||||
|
assert shell_window.navigate(key)
|
||||||
|
application.processEvents()
|
||||||
|
geometry = (
|
||||||
|
shell_window.sidebar.width(),
|
||||||
|
shell_window.workspace.x(),
|
||||||
|
shell_window.workspace.width(),
|
||||||
|
shell_window.stack.width(),
|
||||||
|
)
|
||||||
|
if key in {"appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"}:
|
||||||
|
assert geometry == (208, 208, shell_window.width() - 208, shell_window.width() - 208)
|
||||||
|
assert shell_window.topbar.height() == 76
|
||||||
|
assert shell_window.workspace.y() == 0
|
||||||
|
else:
|
||||||
|
assert geometry == (190, 203, shell_window.width() - 216, shell_window.width() - 216)
|
||||||
|
assert shell_window.topbar.height() == 62
|
||||||
|
assert shell_window.workspace.y() == 13
|
||||||
|
geometries.add(geometry)
|
||||||
|
|
||||||
|
assert len(geometries) == 1, f"navigation moved the shell: {geometries}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("key", "expanded_width"),
|
||||||
|
[("appointments", 208), ("consultations", 208), ("reception", 208), ("patients", 208), ("prescriptions", 208), ("prescription_library", 208), ("legacy_reference", 190)],
|
||||||
|
)
|
||||||
|
def test_sidebar_collapse_preserves_active_navigation(
|
||||||
|
shell_window: ShellWindow,
|
||||||
|
key: str,
|
||||||
|
expanded_width: int,
|
||||||
|
) -> None:
|
||||||
|
assert shell_window.navigate(key)
|
||||||
|
title = shell_window.nav_buttons[key].text()
|
||||||
|
|
||||||
shell_window.toggle_sidebar()
|
shell_window.toggle_sidebar()
|
||||||
assert shell_window.sidebar.width() == 68
|
assert shell_window.sidebar.width() == 68
|
||||||
assert shell_window.nav_buttons["consultations"].text() == ""
|
assert shell_window.nav_buttons[key].text() == ""
|
||||||
assert shell_window.nav_buttons["consultations"].isChecked()
|
assert shell_window.nav_buttons[key].isChecked()
|
||||||
|
|
||||||
shell_window.toggle_sidebar()
|
shell_window.toggle_sidebar()
|
||||||
assert shell_window.sidebar.width() == 195
|
assert shell_window.sidebar.width() == expanded_width
|
||||||
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表")
|
assert shell_window.nav_buttons[key].text() == title
|
||||||
assert shell_window.nav_buttons["consultations"].isChecked()
|
assert shell_window.nav_buttons[key].isChecked()
|
||||||
|
|
||||||
|
|
||||||
def test_shell_directional_controls_have_no_unicode_arrow_text(
|
def test_shell_directional_controls_have_no_unicode_arrow_text(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user