Compare commits
19
Commits
398f9f3726
...
UI-09-03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
691c749f34 | ||
|
|
40319eea41 | ||
|
|
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
@@ -0,0 +1,65 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const { parse, compileScript, compileTemplate, compileStyleAsync } = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const read = (relative) => fs.readFileSync(path.join(root, 'src', relative), 'utf8')
|
||||||
|
const utils = ts.transpileModule(read('utils/appointment-type.ts'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS }
|
||||||
|
}).outputText
|
||||||
|
const exportsObject = {}
|
||||||
|
vm.runInNewContext(utils, { exports: exportsObject })
|
||||||
|
for (const [value, expected] of [
|
||||||
|
['video', '视频问诊'], ['text', '图文问诊'], [undefined, '视频问诊'],
|
||||||
|
[null, '视频问诊'], ['', '视频问诊'], [' ', '视频问诊'],
|
||||||
|
['phone', '电话问诊'], ['other', '未知']
|
||||||
|
]) {
|
||||||
|
assert.equal(exportsObject.appointmentTypeDescription(value), expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const callers = [
|
||||||
|
'views/tcm/appointment/list.vue',
|
||||||
|
'views/tcm/appointment/list_h5.vue',
|
||||||
|
'views/patient/reception/index.vue'
|
||||||
|
]
|
||||||
|
for (const caller of callers) {
|
||||||
|
assert.match(read(caller), /chatDialogRef\.value\?\.open\(\{[\s\S]*?appointmentType: row\.appointment_type,/)
|
||||||
|
}
|
||||||
|
const chat = read('components/chat-dialog/index.vue')
|
||||||
|
assert.match(chat, /appointmentType\.value = data\.appointmentType/)
|
||||||
|
assert.match(chat, /class="chat-appointment-type">\{\{ appointmentTypeLabel \}\}/)
|
||||||
|
const form = read('views/tcm/diagnosis/appointment.vue')
|
||||||
|
assert.match(form, /<el-radio value="text">图文问诊<\/el-radio>/)
|
||||||
|
assert.match(form, /appointmentType: 'video'/)
|
||||||
|
assert.match(form, /form\.appointmentType = 'video'/)
|
||||||
|
assert.match(form, /appointment_type: form\.appointmentType/)
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const components = [
|
||||||
|
...callers, 'components/chat-dialog/index.vue',
|
||||||
|
'views/tcm/diagnosis/appointment.vue', 'views/consumer/prescription/guahao.vue'
|
||||||
|
]
|
||||||
|
for (const filename of components) {
|
||||||
|
const { descriptor, errors } = parse(read(filename), { filename })
|
||||||
|
assert.deepEqual(errors, [], `${filename} parses`)
|
||||||
|
const script = compileScript(descriptor, { id: filename })
|
||||||
|
const template = compileTemplate({
|
||||||
|
source: descriptor.template.content,
|
||||||
|
filename, id: filename,
|
||||||
|
compilerOptions: { bindingMetadata: script.bindings }
|
||||||
|
})
|
||||||
|
assert.deepEqual(template.errors, [], `${filename} template compiles`)
|
||||||
|
for (const style of descriptor.styles) {
|
||||||
|
const result = await compileStyleAsync({
|
||||||
|
source: style.content, filename: path.join(root, 'src', filename),
|
||||||
|
id: filename, scoped: style.scoped, preprocessLang: style.lang
|
||||||
|
})
|
||||||
|
assert.deepEqual(result.errors, [], `${filename} styles compile`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Appointment type defaults/labels, ${callers.length} chat entry contracts, ${components.length} Vue components: OK`)
|
||||||
|
}
|
||||||
|
main().catch((error) => { console.error(error); process.exitCode = 1 })
|
||||||
@@ -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,51 @@ 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
|
||||||
|
sync_status?: WecomPromotionMemberSyncStatus
|
||||||
|
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 })
|
||||||
}
|
}
|
||||||
@@ -313,9 +358,28 @@ export function wecomPromotionCheckApiPermission() {
|
|||||||
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
|
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
|
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
|
||||||
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
|
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WecomPromotionMemberSyncStatus = 'synced' | 'pending' | 'failed' | 'blocked'
|
||||||
|
|
||||||
|
export interface WecomPromotionMemberSyncResult {
|
||||||
|
pool_id: number
|
||||||
|
sync_status: WecomPromotionMemberSyncStatus
|
||||||
|
sync_error: string
|
||||||
|
sync_queued: boolean
|
||||||
|
range_userids: string[]
|
||||||
|
range_department_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomPromotionSyncMemberRange(params: { pool_id: number }) {
|
||||||
|
return request.post<WecomPromotionMemberSyncResult>({
|
||||||
|
url: '/firstvisit.wecomPromotion/syncMemberRange',
|
||||||
|
params,
|
||||||
|
timeout: 120000
|
||||||
|
}, { ignoreCancelToken: true, isOpenRetry: false })
|
||||||
|
}
|
||||||
|
|
||||||
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
|
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
|
||||||
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
|
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -424,6 +424,11 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
|
|||||||
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅修改处方业务订单创建时间,需独立的 editTime 权限 */
|
||||||
|
export function prescriptionOrderEditTime(params: { id: number; create_time: string }) {
|
||||||
|
return request.post({ url: '/tcm.prescriptionOrder/editTime', params })
|
||||||
|
}
|
||||||
|
|
||||||
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
|
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
|
||||||
export function prescriptionOrderDdcode(params: {
|
export function prescriptionOrderDdcode(params: {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
class="chat-window-header"
|
class="chat-window-header"
|
||||||
@mousedown="onHeaderMouseDown"
|
@mousedown="onHeaderMouseDown"
|
||||||
>
|
>
|
||||||
<span class="chat-window-title" :title="patientName">与 {{ patientName }} 通讯</span>
|
<div class="chat-window-heading">
|
||||||
|
<span class="chat-window-title" :title="patientName">与 {{ patientName }} 通讯</span>
|
||||||
|
<el-tag size="small" type="info" class="chat-appointment-type">{{ appointmentTypeLabel }}</el-tag>
|
||||||
|
</div>
|
||||||
<div class="chat-header-actions" @mousedown.stop>
|
<div class="chat-header-actions" @mousedown.stop>
|
||||||
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
|
<el-button type="danger" link class="chat-close-btn" @click="handleClose">
|
||||||
<el-icon><Close /></el-icon>
|
<el-icon><Close /></el-icon>
|
||||||
@@ -140,6 +143,7 @@ import {
|
|||||||
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
import { CallLocalRecorder } from '@/utils/call-local-recorder'
|
||||||
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
|
import { captureVideoFrameFromElement } from '@/utils/call-video-screenshot'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import { appointmentTypeDescription } from '@/utils/appointment-type'
|
||||||
import {
|
import {
|
||||||
formatTUICallUserError,
|
formatTUICallUserError,
|
||||||
getTUICallPackageArrearsMessage,
|
getTUICallPackageArrearsMessage,
|
||||||
@@ -180,6 +184,8 @@ const visible = ref(false)
|
|||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const patientName = ref('')
|
const patientName = ref('')
|
||||||
|
const appointmentType = ref<string | null | undefined>('video')
|
||||||
|
const appointmentTypeLabel = computed(() => appointmentTypeDescription(appointmentType.value))
|
||||||
const patientId = ref<number | null>(null)
|
const patientId = ref<number | null>(null)
|
||||||
const diagnosisId = ref<number | null>(null)
|
const diagnosisId = ref<number | null>(null)
|
||||||
const loadingText = ref('正在初始化...')
|
const loadingText = ref('正在初始化...')
|
||||||
@@ -1010,13 +1016,14 @@ watch(() => activeConversation.value, async (newConversation) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number }) => {
|
const open = async (data: { patientId: number; patientName: string; diagnosisId?: number; appointmentType?: string | null }) => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
isMinimized.value = false
|
isMinimized.value = false
|
||||||
posX.value = 100
|
posX.value = 100
|
||||||
posY.value = 80
|
posY.value = 80
|
||||||
resetCallKitPositionToLeft()
|
resetCallKitPositionToLeft()
|
||||||
patientName.value = data.patientName
|
patientName.value = data.patientName
|
||||||
|
appointmentType.value = data.appointmentType
|
||||||
patientId.value = data.patientId
|
patientId.value = data.patientId
|
||||||
diagnosisId.value = data.diagnosisId || null
|
diagnosisId.value = data.diagnosisId || null
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -1426,6 +1433,18 @@ defineExpose({ open })
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.chat-window-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-appointment-type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-window-title {
|
.chat-window-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** 展示当前挂号的问诊方式;旧记录的空值默认视频。 */
|
||||||
|
export function appointmentTypeDescription(value?: string | null): string {
|
||||||
|
if (value == null || value.trim() === '') return '视频问诊'
|
||||||
|
if (value === 'text') return '图文问诊'
|
||||||
|
if (value === 'video') return '视频问诊'
|
||||||
|
if (value === 'phone') return '电话问诊'
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="修改订单创建时间"
|
||||||
|
width="min(440px, 94vw)"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="!submitting"
|
||||||
|
:show-close="!submitting"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||||
|
<el-form-item label="业务订单">
|
||||||
|
<span class="break-all">{{ orderNo || `#${form.id}` }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="创建时间" prop="create_time">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.create_time"
|
||||||
|
type="datetime"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
placeholder="请选择创建时间"
|
||||||
|
:clearable="false"
|
||||||
|
:disabled="submitting"
|
||||||
|
class="!w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<p class="text-xs text-gray-500">保存后,列表和业绩统计将按新的创建时间归属日期,修改记录可在订单日志中查看。</p>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { nextTick, reactive, ref } from 'vue'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { prescriptionOrderEditTime } from '@/api/tcm'
|
||||||
|
import feedback from '@/utils/feedback'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (event: 'saved', id: number): void }>()
|
||||||
|
const visible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const orderNo = ref('')
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const form = reactive({ id: 0, create_time: '' })
|
||||||
|
const rules: FormRules = {
|
||||||
|
create_time: [{ required: true, message: '请选择创建时间', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表可能返回日期字符串或历史 Unix 时间戳;保留秒,避免只打开弹窗就丢失精度。
|
||||||
|
function editableTime(value: unknown): string {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
|
||||||
|
return raw.replace('T', ' ').padEnd(19, ':00')
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(raw) || Number(raw) <= 0) return ''
|
||||||
|
const timestamp = Number(raw)
|
||||||
|
const date = new Date(timestamp < 1e11 ? timestamp * 1000 : timestamp)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(row: { id?: unknown; order_no?: unknown; create_time?: unknown }) {
|
||||||
|
if (submitting.value || !Number(row.id)) return
|
||||||
|
form.id = Number(row.id)
|
||||||
|
form.create_time = editableTime(row.create_time)
|
||||||
|
orderNo.value = String(row.order_no || '')
|
||||||
|
visible.value = true
|
||||||
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (submitting.value || !formRef.value) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (!(await formRef.value.validate().catch(() => false))) return
|
||||||
|
await prescriptionOrderEditTime({ id: form.id, create_time: form.create_time })
|
||||||
|
feedback.msgSuccess('订单创建时间已修改')
|
||||||
|
visible.value = false
|
||||||
|
emit('saved', form.id)
|
||||||
|
} catch {
|
||||||
|
// 请求拦截器展示错误,保留已填写的时间便于重试。
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
@@ -178,7 +178,8 @@ export function logActionText(act: string) {
|
|||||||
ej_pharmacy_callback: '洛阳药房状态',
|
ej_pharmacy_callback: '洛阳药房状态',
|
||||||
patch_rx_patient: '处方患者信息',
|
patch_rx_patient: '处方患者信息',
|
||||||
patch_rx_usage: '服用参数',
|
patch_rx_usage: '服用参数',
|
||||||
update_amount: '修改订单金额',
|
update_amount: '修改订单金额',
|
||||||
|
edit_time: '修改创建时间',
|
||||||
complete: '完成订单',
|
complete: '完成订单',
|
||||||
refund: '退款',
|
refund: '退款',
|
||||||
manual_log: '手工备注',
|
manual_log: '手工备注',
|
||||||
|
|||||||
@@ -182,7 +182,6 @@
|
|||||||
<el-select v-model="editForm.appointment_type" class="!w-full">
|
<el-select v-model="editForm.appointment_type" class="!w-full">
|
||||||
<el-option label="视频问诊" value="video" />
|
<el-option label="视频问诊" value="video" />
|
||||||
<el-option label="图文问诊" value="text" />
|
<el-option label="图文问诊" value="text" />
|
||||||
<el-option label="电话问诊" value="phone" />
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="渠道来源" required>
|
<el-form-item label="渠道来源" required>
|
||||||
|
|||||||
@@ -1585,7 +1585,7 @@ import DaterangePicker from '@/components/daterange-picker/index.vue'
|
|||||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||||
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
|
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
|
||||||
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
|
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
|
||||||
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
|
import { computed, onMounted, onActivated, onDeactivated, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
@@ -3882,7 +3882,18 @@ const handleDelete = async (id: number) => {
|
|||||||
getLists()
|
getLists()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
// 从订单页返回缓存的处方列表时,重新读取退款后的占用状态。
|
||||||
|
let refreshOnReturn = false
|
||||||
|
onDeactivated(() => {
|
||||||
|
refreshOnReturn = true
|
||||||
|
})
|
||||||
|
onActivated(() => {
|
||||||
|
if (!refreshOnReturn) return
|
||||||
|
refreshOnReturn = false
|
||||||
|
getLists()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
await loadRoleOptions()
|
await loadRoleOptions()
|
||||||
await loadRegionData()
|
await loadRegionData()
|
||||||
await loadServicePackageOptions()
|
await loadServicePackageOptions()
|
||||||
|
|||||||
@@ -550,7 +550,13 @@
|
|||||||
type="primary"
|
type="primary"
|
||||||
link
|
link
|
||||||
@click="openEdit(row)"
|
@click="openEdit(row)"
|
||||||
>编辑</el-button>
|
>编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.prescriptionOrder/editTime']"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="orderTimeDialogRef?.open(row)"
|
||||||
|
>修改创建时间</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canQuickTrackRow(row)"
|
v-if="canQuickTrackRow(row)"
|
||||||
v-perms="['tcm.prescriptionOrder/ddcode']"
|
v-perms="['tcm.prescriptionOrder/ddcode']"
|
||||||
@@ -649,7 +655,12 @@
|
|||||||
>{{ shipModeLabel(detail.ship_mode) }}</el-tag>
|
>{{ shipModeLabel(detail.ship_mode) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #header-actions="{ detail }">
|
<template #header-actions="{ detail }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.prescriptionOrder/editTime']"
|
||||||
|
size="small"
|
||||||
|
@click="orderTimeDialogRef?.open(detail)"
|
||||||
|
>修改创建时间</el-button>
|
||||||
<gancao-submission-reconcile-button
|
<gancao-submission-reconcile-button
|
||||||
:order="detail"
|
:order="detail"
|
||||||
@resolved="handleGancaoSubmissionResolved"
|
@resolved="handleGancaoSubmissionResolved"
|
||||||
@@ -1511,7 +1522,9 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
<!-- 订单退款:须填写原因 -->
|
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
|
||||||
|
|
||||||
|
<!-- 订单退款:须填写原因 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="refundOrderDialogVisible"
|
v-model="refundOrderDialogVisible"
|
||||||
title="订单退款"
|
title="订单退款"
|
||||||
@@ -2031,7 +2044,8 @@ import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncCompone
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
|
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
|
||||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
||||||
|
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
|
||||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||||
import {
|
import {
|
||||||
TCM_ASSISTANT_ROLE_ID,
|
TCM_ASSISTANT_ROLE_ID,
|
||||||
@@ -3087,7 +3101,13 @@ function canUploadPharmacyRow(row: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
|
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
|
||||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||||
|
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
|
||||||
|
|
||||||
|
async function onOrderTimeSaved(id: number) {
|
||||||
|
getLists()
|
||||||
|
await detailDrawerRef.value?.refreshIfCurrent(id)
|
||||||
|
}
|
||||||
/** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh()) */
|
/** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh()) */
|
||||||
const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null)
|
const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null)
|
||||||
|
|
||||||
|
|||||||
@@ -474,7 +474,12 @@
|
|||||||
v-perms="['tcm.prescriptionOrder/edit']"
|
v-perms="['tcm.prescriptionOrder/edit']"
|
||||||
size="small"
|
size="small"
|
||||||
@click="openEdit(row)"
|
@click="openEdit(row)"
|
||||||
>编辑</el-button>
|
>编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.prescriptionOrder/editTime']"
|
||||||
|
size="small"
|
||||||
|
@click="orderTimeDialogRef?.open(row)"
|
||||||
|
>修改创建时间</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canQuickTrackRow(row)"
|
v-if="canQuickTrackRow(row)"
|
||||||
v-perms="['tcm.prescriptionOrder/ddcode']"
|
v-perms="['tcm.prescriptionOrder/ddcode']"
|
||||||
@@ -592,8 +597,13 @@
|
|||||||
>
|
>
|
||||||
撤回支付审核
|
撤回支付审核
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canShipRow(detailData)"
|
v-perms="['tcm.prescriptionOrder/editTime']"
|
||||||
|
size="small"
|
||||||
|
@click="orderTimeDialogRef?.open(detailData)"
|
||||||
|
>修改创建时间</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canShipRow(detailData)"
|
||||||
v-perms="['tcm.prescriptionOrder/ship']"
|
v-perms="['tcm.prescriptionOrder/ship']"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -2566,12 +2576,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
<!-- 与 consumer/prescription/index、诊单 edit 同一套界面:只读诊单与全部分页签 -->
|
<!-- 与 consumer/prescription/index、诊单 edit 同一套界面:只读诊单与全部分页签 -->
|
||||||
<TcmDiagnosisEditView ref="diagnosisViewRef" />
|
<TcmDiagnosisEditView ref="diagnosisViewRef" />
|
||||||
|
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup name="prescriptionOrderListH5">
|
<script lang="ts" setup name="prescriptionOrderListH5">
|
||||||
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
|
import { computed, onMounted, reactive, ref, nextTick, watch, defineAsyncComponent } from 'vue'
|
||||||
import {
|
import {
|
||||||
Refresh,
|
Refresh,
|
||||||
@@ -2585,7 +2596,8 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
User
|
User
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||||
|
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
|
||||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||||
import {
|
import {
|
||||||
@@ -3513,7 +3525,15 @@ function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | nul
|
|||||||
|
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailData = ref<Record<string, any> | null>(null)
|
const detailData = ref<Record<string, any> | null>(null)
|
||||||
|
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
|
||||||
|
|
||||||
|
async function onOrderTimeSaved(id: number) {
|
||||||
|
getLists()
|
||||||
|
if (detailVisible.value && Number(detailData.value?.id) === id) {
|
||||||
|
await openDetail(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const detailServicePackageText = computed(() =>
|
const detailServicePackageText = computed(() =>
|
||||||
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
|
||||||
@@ -3838,7 +3858,8 @@ function logActionText(act: string) {
|
|||||||
patch_rx_patient: '处方患者信息',
|
patch_rx_patient: '处方患者信息',
|
||||||
update_amount: '修改订单金额',
|
update_amount: '修改订单金额',
|
||||||
complete: '完成订单',
|
complete: '完成订单',
|
||||||
refund: '退款'
|
refund: '退款',
|
||||||
|
edit_time: '修改创建时间'
|
||||||
}
|
}
|
||||||
return m[act] || act
|
return m[act] || act
|
||||||
}
|
}
|
||||||
|
|||||||
+299
-21
@@ -94,15 +94,32 @@
|
|||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="跟进人">
|
<el-form-item label="跟进人">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="queryParams.follow_user"
|
v-model="queryParams.follow_user"
|
||||||
placeholder="跟进人姓名(后台姓名或企微账号)"
|
placeholder="跟进人姓名(后台姓名或企微账号)"
|
||||||
clearable
|
clearable
|
||||||
@keyup.enter="resetPage"
|
@keyup.enter="resetPage"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="添加时间">
|
<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-date-picker
|
<el-date-picker
|
||||||
v-model="addTimeRange"
|
v-model="addTimeRange"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
@@ -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>
|
||||||
@@ -500,6 +553,21 @@
|
|||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="添加时间" :span="2">
|
<el-descriptions-item label="添加时间" :span="2">
|
||||||
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
|
||||||
|
</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>
|
||||||
<el-descriptions-item label="更新时间" :span="2">
|
<el-descriptions-item label="更新时间" :span="2">
|
||||||
{{ formatTime(currentCustomer.update_time) }}
|
{{ formatTime(currentCustomer.update_time) }}
|
||||||
@@ -549,8 +617,9 @@ import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-v
|
|||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
import {
|
import {
|
||||||
qywxCustomerLists,
|
qywxCustomerLists,
|
||||||
qywxCustomerSync,
|
qywxCustomerDelete,
|
||||||
|
qywxCustomerSync,
|
||||||
qywxCustomerStats,
|
qywxCustomerStats,
|
||||||
qywxSyncSettingsGet,
|
qywxSyncSettingsGet,
|
||||||
qywxSyncSettingsSave,
|
qywxSyncSettingsSave,
|
||||||
@@ -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,
|
||||||
@@ -591,17 +661,19 @@ const syncSettings = reactive({
|
|||||||
interval: 3600
|
interval: 3600
|
||||||
})
|
})
|
||||||
|
|
||||||
const queryParams = reactive<{
|
const queryParams = reactive<{
|
||||||
name: string
|
name: string
|
||||||
follow_user: string
|
follow_user: string
|
||||||
tag_ids: string[]
|
add_way: number | ''
|
||||||
|
tag_ids: string[]
|
||||||
add_time_start: string
|
add_time_start: string
|
||||||
add_time_end: string
|
add_time_end: string
|
||||||
dedupe_mode: 'first' | 'any'
|
dedupe_mode: 'first' | 'any'
|
||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
follow_user: '',
|
follow_user: '',
|
||||||
tag_ids: [],
|
add_way: '',
|
||||||
|
tag_ids: [],
|
||||||
add_time_start: '',
|
add_time_start: '',
|
||||||
add_time_end: '',
|
add_time_end: '',
|
||||||
dedupe_mode: 'first'
|
dedupe_mode: 'first'
|
||||||
@@ -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,
|
||||||
@@ -847,10 +935,11 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
|||||||
params: queryParams
|
params: queryParams
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
queryParams.name = ''
|
queryParams.name = ''
|
||||||
queryParams.follow_user = ''
|
queryParams.follow_user = ''
|
||||||
queryParams.tag_ids = []
|
queryParams.add_way = ''
|
||||||
|
queryParams.tag_ids = []
|
||||||
queryParams.add_time_start = ''
|
queryParams.add_time_start = ''
|
||||||
queryParams.add_time_end = ''
|
queryParams.add_time_end = ''
|
||||||
queryParams.dedupe_mode = 'first'
|
queryParams.dedupe_mode = 'first'
|
||||||
@@ -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"
|
||||||
@@ -67,8 +72,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="operationResults.length" class="member-sync-results" aria-live="polite">
|
||||||
|
<strong>本次保存与企微同步结果</strong>
|
||||||
|
<ul>
|
||||||
|
<li v-for="result in operationResults" :key="result.id">
|
||||||
|
<span>{{ result.name || `方案 ${result.id}` }}:{{ operationResultText(result) }}</span>
|
||||||
|
<el-button
|
||||||
|
v-if="result.success && result.sync_status !== 'synced' && canSyncPool(result.id)"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
:loading="syncingPoolId === result.id"
|
||||||
|
:disabled="syncingPoolId > 0 || savingBatchConfig"
|
||||||
|
@click="syncMemberRange(result.id)"
|
||||||
|
>重试同步</el-button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div v-if="overview.pools.length" class="pool-layout">
|
<div v-if="overview.pools.length" class="pool-layout">
|
||||||
<aside class="pool-sidebar">
|
<aside class="pool-sidebar">
|
||||||
|
<div class="pool-select-all">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="allManageablePoolsSelected"
|
||||||
|
:indeterminate="someManageablePoolsSelected"
|
||||||
|
:disabled="!manageablePoolIds.length"
|
||||||
|
aria-label="全选可管理的分流方案"
|
||||||
|
@change="toggleAllPoolSelection"
|
||||||
|
>全选</el-checkbox>
|
||||||
|
<span>可选 {{ manageablePoolIds.length }} 个</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="pool in overview.pools"
|
v-for="pool in overview.pools"
|
||||||
:key="pool.id"
|
:key="pool.id"
|
||||||
@@ -111,6 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
|
<el-button :icon="CircleCheck" :loading="checkingApi" @click="checkApiPermission">验证 API</el-button>
|
||||||
|
<el-button v-if="selectedPool.can_operate" :icon="Refresh" :loading="syncingPoolId === Number(selectedPool.id)" :disabled="!selectedPool.official_link || syncingPoolId > 0 || togglingMemberId > 0" @click="syncMemberRange(Number(selectedPool.id))">同步成员范围</el-button>
|
||||||
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
|
<el-button :icon="DocumentCopy" :disabled="!selectedPool.main_url" @click="copyText(selectedPool.main_url, '官方获客链接')">复制链接</el-button>
|
||||||
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
|
<el-button :icon="DocumentCopy" @click="copyText(selectedPool.install_code, 'JS 安装代码')">复制 JS</el-button>
|
||||||
<el-button v-if="selectedPool.can_manage_access" :icon="User" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
|
<el-button v-if="selectedPool.can_manage_access" :icon="User" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
|
||||||
@@ -135,10 +168,20 @@
|
|||||||
show-icon
|
show-icon
|
||||||
:closable="false"
|
:closable="false"
|
||||||
title="企业微信原生多人路由"
|
title="企业微信原生多人路由"
|
||||||
description="当前全部可用医助会同时写入官方链接的成员范围,由企业微信在打开和添加阶段直接进行多人路由。回调只用于统计实际承接结果,并在禁用、过期或达到上限后更新成员范围。"
|
description="上线开关和可用性表示本地规则;企微范围显示上次远端确认结果。“待移出”的成员仍可能获客,只有同步完成才会从官方链接移除。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-table :data="selectedMemberRules" class="link-table" stripe>
|
<el-alert
|
||||||
|
class="legacy-sync-alert member-sync-state"
|
||||||
|
:type="selectedSyncState.type"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
:title="selectedSyncState.title"
|
||||||
|
:description="selectedSyncState.description"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="member-table-area">
|
||||||
|
<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">
|
||||||
@@ -147,10 +190,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="企微范围同步" width="130" align="center">
|
<el-table-column label="企微范围(上次确认)" width="165" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="status-tag" :class="routeStatus(row).className">{{ routeStatus(row).label }}</span>
|
<span class="status-tag" :class="routeStatus(row).className">{{ routeStatus(row).label }}</span>
|
||||||
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败,后台重试中</div>
|
<div v-if="Number(row.sync_status) === 3" class="sync-retry-tip">同步失败,请查看上方原因</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="今日 / 上限" width="125" align="center">
|
<el-table-column label="今日 / 上限" width="125" align="center">
|
||||||
@@ -164,7 +207,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="上线" width="90" align="center">
|
<el-table-column label="上线" width="90" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
|
<el-switch :model-value="Number(row.enabled) === 1" :disabled="!selectedPool.can_operate || togglingMemberId > 0 || syncingPoolId > 0" :loading="togglingMemberId === Number(row.id)" @change="(value) => handleMemberToggle(row, value)" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" min-width="100" fixed="right">
|
<el-table-column label="操作" min-width="100" fixed="right">
|
||||||
@@ -174,7 +217,8 @@
|
|||||||
</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="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接">
|
||||||
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
|
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">创建第一个方案</el-button>
|
||||||
@@ -429,6 +473,151 @@
|
|||||||
<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="batchSyncProgress.total" class="uploading-save-tip" role="status">本地配置已保存,正在同步企微 {{ batchSyncProgress.completed }} / {{ batchSyncProgress.total }} 个方案</span>
|
||||||
|
<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 +714,7 @@ import {
|
|||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
wecomPromotionBatchSetOperators,
|
wecomPromotionBatchSetOperators,
|
||||||
|
wecomPromotionBatchUpdatePools,
|
||||||
wecomPromotionCheckApiPermission,
|
wecomPromotionCheckApiPermission,
|
||||||
wecomPromotionCustomerStats,
|
wecomPromotionCustomerStats,
|
||||||
wecomPromotionDeletePool,
|
wecomPromotionDeletePool,
|
||||||
@@ -532,15 +722,18 @@ import {
|
|||||||
wecomPromotionSaveMember,
|
wecomPromotionSaveMember,
|
||||||
wecomPromotionSavePool,
|
wecomPromotionSavePool,
|
||||||
wecomPromotionSyncCustomers,
|
wecomPromotionSyncCustomers,
|
||||||
|
wecomPromotionSyncMemberRange,
|
||||||
wecomPromotionToggleMember
|
wecomPromotionToggleMember
|
||||||
} from '@/api/first_visit'
|
} from '@/api/first_visit'
|
||||||
import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit'
|
import type { WecomPromotionBatchUpdatePoolResult, WecomPromotionCustomerChatStatus, WecomPromotionMemberSyncResult } from '@/api/first_visit'
|
||||||
|
|
||||||
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue'
|
||||||
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
|
import PromotionAutomationForm from './components/PromotionAutomationForm.vue'
|
||||||
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 +750,12 @@ interface PromotionMemberOption {
|
|||||||
dept_names: string[]
|
dept_names: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchMemberStatusOption extends PromotionMemberOption {
|
||||||
|
pool_count: number
|
||||||
|
online_count: number
|
||||||
|
offline_count: number
|
||||||
|
}
|
||||||
|
|
||||||
interface PromotionOperatorOption {
|
interface PromotionOperatorOption {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -606,11 +805,21 @@ 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 syncingPoolId = ref(0)
|
||||||
|
const batchSyncProgress = reactive({ completed: 0, total: 0 })
|
||||||
|
const operationResults = ref<WecomPromotionBatchUpdatePoolResult[]>([])
|
||||||
|
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)
|
||||||
@@ -631,8 +840,118 @@ const customerStats = reactive({
|
|||||||
|
|
||||||
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value))
|
||||||
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
const selectedMemberRules = computed(() => Array.isArray(selectedPool.value?.member_rules) ? selectedPool.value.member_rules : [])
|
||||||
|
const selectedSyncState = computed(() => {
|
||||||
|
const pool = selectedPool.value
|
||||||
|
const sync = pool?.dispatch_sync || {}
|
||||||
|
const remote = Array.isArray(pool?.official_link?.range_userids) ? pool.official_link.range_userids.map(String) : []
|
||||||
|
const planned = selectedMemberRules.value.filter((row: any) => eligibility(row).className === 'is-ok').map((row: any) => String(row.userid))
|
||||||
|
const names = new Map<string, string>(selectedMemberRules.value.map((row: any) => [String(row.userid), String(row.name || row.userid)]))
|
||||||
|
const remoteNames = remote.map((id: string) => names.get(id) || id).join('、') || '暂无确认记录'
|
||||||
|
const plannedNames = planned.map((id: string) => names.get(id) || id).join('、') || '暂无可用成员'
|
||||||
|
const lastSync = pool?.official_link?.last_sync_time
|
||||||
|
const detail = `当前计划:${plannedNames};上次企微确认:${remoteNames}${lastSync ? `(${formatCustomerTime(lastSync)})` : ''}。`
|
||||||
|
const error = String(sync.last_error || pool?.official_link?.sync_error || '')
|
||||||
|
if (Number(sync.status) === 3 || error) return { type: 'warning' as const, title: '企微成员范围尚未同步成功', description: `${detail}${error || '同步失败,请点击“同步成员范围”重试。'}` }
|
||||||
|
if (Number(sync.status) === 4) return { type: 'warning' as const, title: '企微成员范围同步受阻', description: `${detail}请检查可用成员后重试同步。` }
|
||||||
|
if ([1, 2].includes(Number(sync.status))) return { type: 'warning' as const, title: '本地规则已保存,企微成员范围同步待完成', description: `${detail}可点击“同步成员范围”立即重试。` }
|
||||||
|
const hasDepartments = (pool?.official_link?.range_department_ids || []).length > 0
|
||||||
|
const matches = remote.length > 0 && new Set(remote).size === new Set(planned).size && planned.every((id: string) => remote.includes(id)) && !hasDepartments
|
||||||
|
if (!matches) return { type: 'warning' as const, title: '当前计划与上次企微范围不一致', description: `${detail}${hasDepartments ? '企微范围仍包含部门。' : ''}请点击“同步成员范围”更新并确认。` }
|
||||||
|
return { type: 'info' as const, title: '上次确认的企微成员范围与当前计划一致', description: detail }
|
||||||
|
})
|
||||||
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
const selectedInstallPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedInstallPoolId.value))
|
||||||
const accessDialogPools = computed(() => overview.pools.filter((item: any) => accessForm.pool_ids.includes(Number(item.id))))
|
const accessDialogPools = computed(() => overview.pools.filter((item: any) => accessForm.pool_ids.includes(Number(item.id))))
|
||||||
|
const manageablePoolIds = computed(() => overview.pools
|
||||||
|
.filter((pool: any) => pool.can_manage_access)
|
||||||
|
.map((pool: any) => Number(pool.id)))
|
||||||
|
const selectedManageablePoolCount = computed(() => {
|
||||||
|
const selected = new Set(selectedPoolIds.value)
|
||||||
|
return manageablePoolIds.value.filter((id) => selected.has(id)).length
|
||||||
|
})
|
||||||
|
const allManageablePoolsSelected = computed(() => manageablePoolIds.value.length > 0
|
||||||
|
&& selectedManageablePoolCount.value === manageablePoolIds.value.length)
|
||||||
|
const someManageablePoolsSelected = computed(() => selectedManageablePoolCount.value > 0
|
||||||
|
&& selectedManageablePoolCount.value < manageablePoolIds.value.length)
|
||||||
|
const batchConfigPools = computed(() => overview.pools.filter((item: any) => batchConfigForm.pool_ids.includes(Number(item.id))))
|
||||||
|
const 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 +1011,249 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncErrorMessage(error: unknown, fallback: string) {
|
||||||
|
if (typeof error === 'string' && error) return error
|
||||||
|
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationResultText(result: WecomPromotionBatchUpdatePoolResult) {
|
||||||
|
if (!result.success) return `本地保存失败:${result.error || '请检查配置后重试'}`
|
||||||
|
if (result.sync_status === 'synced') return '本地已保存,企微成员范围已确认同步'
|
||||||
|
if (result.sync_status === 'blocked' || result.sync_status === 'failed' || result.sync_error) {
|
||||||
|
return `本地已保存,企微同步${result.sync_status === 'blocked' ? '受阻' : '失败'}:${result.sync_error || '请重试同步'}`
|
||||||
|
}
|
||||||
|
return '本地已保存,企微成员范围尚未确认同步,请重试同步'
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordOperationResult(result: WecomPromotionBatchUpdatePoolResult) {
|
||||||
|
const index = operationResults.value.findIndex((item) => item.id === result.id)
|
||||||
|
if (index < 0) operationResults.value.push(result)
|
||||||
|
else operationResults.value[index] = result
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifySavedResult(label: string, result: Partial<WecomPromotionMemberSyncResult>) {
|
||||||
|
if (result.sync_status === 'synced') {
|
||||||
|
ElMessage.success(`${label},企微成员范围已确认同步`)
|
||||||
|
} else {
|
||||||
|
const reason = result.sync_error || '企微成员范围尚未确认同步,请点击“同步成员范围”重试'
|
||||||
|
ElMessage.warning({ message: `${label}。${reason}`, duration: 8000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordSavedResult(poolId: number, name: string, result: Partial<WecomPromotionMemberSyncResult>) {
|
||||||
|
recordOperationResult({ id: poolId, name, success: true, sync_status: result.sync_status, sync_error: result.sync_error, sync_queued: result.sync_queued })
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSyncPool(poolId: number) {
|
||||||
|
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
|
||||||
|
return Boolean(pool?.can_operate && pool?.official_link)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestMemberSync(poolId: number): Promise<Partial<WecomPromotionMemberSyncResult>> {
|
||||||
|
try {
|
||||||
|
return await wecomPromotionSyncMemberRange({ pool_id: poolId })
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { pool_id: poolId, sync_status: 'failed', sync_error: syncErrorMessage(error, '同步请求失败,请重试确认企微范围'), sync_queued: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncMemberRange(poolId: number) {
|
||||||
|
if (syncingPoolId.value || savingBatchConfig.value || !canSyncPool(poolId)) return
|
||||||
|
const pool = overview.pools.find((item: any) => Number(item.id) === poolId)
|
||||||
|
syncingPoolId.value = poolId
|
||||||
|
try {
|
||||||
|
const result = await requestMemberSync(poolId)
|
||||||
|
recordOperationResult({ id: poolId, name: pool.name, success: true, ...result })
|
||||||
|
await loadOverview()
|
||||||
|
notifySavedResult('本地规则已保存', result)
|
||||||
|
} finally {
|
||||||
|
syncingPoolId.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncBatchResults(results: WecomPromotionBatchUpdatePoolResult[]) {
|
||||||
|
operationResults.value = results.map((item) => ({ ...item }))
|
||||||
|
const queued = results.filter((item) => item.success && item.sync_queued)
|
||||||
|
Object.assign(batchSyncProgress, { total: queued.length, completed: 0 })
|
||||||
|
let next = 0
|
||||||
|
// Two workers keep large batches responsive without flooding the enterprise API.
|
||||||
|
await Promise.all(Array.from({ length: Math.min(2, queued.length) }, async () => {
|
||||||
|
while (next < queued.length) {
|
||||||
|
const item = queued[next++]
|
||||||
|
const synced = await requestMemberSync(item.id)
|
||||||
|
recordOperationResult({ ...item, ...synced })
|
||||||
|
batchSyncProgress.completed++
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBatchConfig() {
|
||||||
|
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
|
||||||
|
operationResults.value = []
|
||||||
|
try {
|
||||||
|
const result = await wecomPromotionBatchUpdatePools({
|
||||||
|
pool_ids: [...batchConfigForm.pool_ids],
|
||||||
|
changes
|
||||||
|
})
|
||||||
|
operationResults.value = result.results.map((item) => ({ ...item }))
|
||||||
|
if (result.updated === 0) {
|
||||||
|
const detail = result.results
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((item) => `${item.name || `方案 ${item.id}`}:${item.error || '保存失败'}`)
|
||||||
|
.join(';')
|
||||||
|
return setBatchConfigError(detail || '所选方案均未能保存,请检查配置后重试')
|
||||||
|
}
|
||||||
|
await syncBatchResults(result.results)
|
||||||
|
batchConfigDialogVisible.value = false
|
||||||
|
selectedPoolIds.value = operationResults.value
|
||||||
|
.filter((item) => !item.success || item.sync_status !== 'synced')
|
||||||
|
.map((item) => item.id)
|
||||||
|
await loadOverview()
|
||||||
|
const memberStatusText = batchConfigForm.member_status === 1 ? '上线' : '下线'
|
||||||
|
const memberResultText = hasMemberStatusChange
|
||||||
|
? (result.member_updated > 0
|
||||||
|
? `,${result.member_updated} 条本地员工规则已${memberStatusText}`
|
||||||
|
: `,所选员工本地均已处于${memberStatusText}状态`)
|
||||||
|
: ''
|
||||||
|
const synced = operationResults.value.filter((item) => item.success && item.sync_status === 'synced').length
|
||||||
|
const unconfirmed = result.updated - synced
|
||||||
|
const summary = `本地已保存 ${result.updated} 个方案${memberResultText};企微已确认同步 ${synced} 个${unconfirmed ? `,${unconfirmed} 个尚未确认同步` : ''}${result.failed ? `;${result.failed} 个本地保存失败` : ''}`
|
||||||
|
ElMessage({ type: unconfirmed || result.failed ? 'warning' : 'success', message: `${summary}。详情见方案列表上方。`, duration: 8000 })
|
||||||
|
} catch (error: any) {
|
||||||
|
setBatchConfigError(error?.message || '批量修改分流方案失败')
|
||||||
|
} finally {
|
||||||
|
savingBatchConfig.value = false
|
||||||
|
Object.assign(batchSyncProgress, { completed: 0, total: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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: [],
|
||||||
@@ -810,11 +1366,10 @@ async function savePool() {
|
|||||||
throw new Error('方案已提交,但服务端未确认标签和欢迎语配置保存成功,请刷新后重试')
|
throw new Error('方案已提交,但服务端未确认标签和欢迎语配置保存成功,请刷新后重试')
|
||||||
}
|
}
|
||||||
poolDialogVisible.value = false
|
poolDialogVisible.value = false
|
||||||
await loadOverview()
|
recordSavedResult(Number(result?.id || poolForm.id), poolForm.name, result || {})
|
||||||
if (result?.id) selectedPoolId.value = Number(result.id)
|
await loadOverview()
|
||||||
result?.sync_error
|
if (result?.id) selectedPoolId.value = Number(result.id)
|
||||||
? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步')
|
notifySavedResult(poolForm.id ? '本地方案已保存' : '分流方案已创建', result || {})
|
||||||
: ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
|
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
|
||||||
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
@@ -863,7 +1418,10 @@ function openMemberDialog(row: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveMemberRule() {
|
async function saveMemberRule() {
|
||||||
|
if (savingMember.value) return
|
||||||
savingMember.value = true
|
savingMember.value = true
|
||||||
|
const poolId = Number(selectedPool.value?.id)
|
||||||
|
const poolName = String(selectedPool.value?.name || '')
|
||||||
try {
|
try {
|
||||||
const result: any = await wecomPromotionSaveMember({
|
const result: any = await wecomPromotionSaveMember({
|
||||||
...memberForm,
|
...memberForm,
|
||||||
@@ -871,10 +1429,9 @@ async function saveMemberRule() {
|
|||||||
active_end: memberForm.active_range?.[1] || ''
|
active_end: memberForm.active_range?.[1] || ''
|
||||||
})
|
})
|
||||||
memberDialogVisible.value = false
|
memberDialogVisible.value = false
|
||||||
|
recordSavedResult(poolId, poolName, result || {})
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
result?.sync_error
|
notifySavedResult('本地医助规则已保存', result || {})
|
||||||
? ElMessage.warning('规则已保存,企业微信成员范围将在后台自动重试同步')
|
|
||||||
: ElMessage.success('医助分流规则已保存')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '医助分流规则保存失败')
|
ElMessage.error(error?.message || '医助分流规则保存失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -883,13 +1440,15 @@ async function saveMemberRule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleMemberToggle(row: any, value: unknown) {
|
async function handleMemberToggle(row: any, value: unknown) {
|
||||||
|
if (togglingMemberId.value || syncingPoolId.value) return
|
||||||
togglingMemberId.value = Number(row.id)
|
togglingMemberId.value = Number(row.id)
|
||||||
|
const poolId = Number(selectedPool.value?.id)
|
||||||
|
const poolName = String(selectedPool.value?.name || '')
|
||||||
try {
|
try {
|
||||||
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
|
const result: any = await wecomPromotionToggleMember({ id: Number(row.id), status: value ? 1 : 0 })
|
||||||
|
recordSavedResult(poolId, poolName, result || {})
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
result?.sync_error
|
notifySavedResult('本地成员状态已保存', result || {})
|
||||||
? ElMessage.warning('成员状态已保存,企业微信成员范围将在后台自动重试同步')
|
|
||||||
: ElMessage.success('成员规则已保存,企微多人路由范围已重新计算')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.message || '成员状态更新失败')
|
ElMessage.error(error?.message || '成员状态更新失败')
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
@@ -1027,8 +1586,10 @@ function eligibility(row: any) {
|
|||||||
|
|
||||||
function routeStatus(row: any) {
|
function routeStatus(row: any) {
|
||||||
const available = eligibility(row).className === 'is-ok'
|
const available = eligibility(row).className === 'is-ok'
|
||||||
if (row.is_in_remote_range) return { label: available ? '企微路由中' : '待移出', className: available ? 'is-online' : 'is-offline' }
|
if (row.is_in_remote_range === true || Number(row.is_in_remote_range) === 1) {
|
||||||
return { label: available ? '待同步' : '未在路由范围', className: 'is-offline' }
|
return { label: available ? '企微范围内' : '待移出(仍在企微)', className: available ? 'is-online' : 'is-pending' }
|
||||||
|
}
|
||||||
|
return { label: available ? '待加入企微' : '未在企微范围', className: 'is-offline' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function todayCount(row: any) {
|
function todayCount(row: any) {
|
||||||
@@ -1038,7 +1599,11 @@ function todayCount(row: any) {
|
|||||||
|
|
||||||
function buildMemberDepartmentTree(
|
function buildMemberDepartmentTree(
|
||||||
departmentOptions: PromotionDepartmentOption[],
|
departmentOptions: PromotionDepartmentOption[],
|
||||||
memberOptions: PromotionMemberOption[]
|
memberOptions: PromotionMemberOption[],
|
||||||
|
options: {
|
||||||
|
selectableDepartments?: boolean
|
||||||
|
memberDetail?: (member: PromotionMemberOption) => string
|
||||||
|
} = {}
|
||||||
): PromotionMemberTreeNode[] {
|
): PromotionMemberTreeNode[] {
|
||||||
const availableDeptIds = new Set<number>()
|
const availableDeptIds = new Set<number>()
|
||||||
const collectDeptIds = (departments: PromotionDepartmentOption[]) => {
|
const collectDeptIds = (departments: PromotionDepartmentOption[]) => {
|
||||||
@@ -1083,7 +1648,8 @@ function buildMemberDepartmentTree(
|
|||||||
const placedMemberIds = new Set<number>()
|
const placedMemberIds = new Set<number>()
|
||||||
const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => {
|
const toMemberNode = (member: PromotionMemberOption): PromotionMemberTreeNode => {
|
||||||
const departments = member.dept_names.length ? member.dept_names.join(' / ') : '未分部门'
|
const departments = member.dept_names.length ? member.dept_names.join(' / ') : '未分部门'
|
||||||
const detail = member.userid ? `${departments} · ${member.userid}` : departments
|
const detail = options.memberDetail?.(member)
|
||||||
|
|| (member.userid ? `${departments} · ${member.userid}` : departments)
|
||||||
return {
|
return {
|
||||||
value: member.id,
|
value: member.id,
|
||||||
label: member.name,
|
label: member.name,
|
||||||
@@ -1118,7 +1684,7 @@ function buildMemberDepartmentTree(
|
|||||||
value: `dept:${departmentId}`,
|
value: `dept:${departmentId}`,
|
||||||
label: departmentName,
|
label: departmentName,
|
||||||
kind: 'department',
|
kind: 'department',
|
||||||
disabled: true,
|
disabled: !options.selectableDepartments,
|
||||||
member_count: memberCount,
|
member_count: memberCount,
|
||||||
search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
search_text: `${path.join(' ')} ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
||||||
children
|
children
|
||||||
@@ -1135,7 +1701,7 @@ function buildMemberDepartmentTree(
|
|||||||
value: 'dept:unassigned',
|
value: 'dept:unassigned',
|
||||||
label: '未分部门',
|
label: '未分部门',
|
||||||
kind: 'department',
|
kind: 'department',
|
||||||
disabled: true,
|
disabled: !options.selectableDepartments,
|
||||||
member_count: children.length,
|
member_count: children.length,
|
||||||
search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
search_text: `未分部门 ${children.map((child) => child.search_text).join(' ')}`.toLocaleLowerCase(),
|
||||||
children
|
children
|
||||||
@@ -1319,18 +1885,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 +1935,22 @@ 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; }
|
||||||
|
.status-tag.is-pending { color: #a36413; background: #fff1dd; }
|
||||||
|
.member-sync-state :deep(.el-alert__description) { max-height: 84px; overflow-y: auto; overflow-wrap: anywhere; }
|
||||||
|
.member-sync-results { margin-bottom: 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: #f8fafb; font-size: 12px; }
|
||||||
|
.member-sync-results ul { max-height: 150px; margin: 8px 0 0; padding: 0; overflow-y: auto; list-style: none; }
|
||||||
|
.member-sync-results li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; }
|
||||||
|
.member-sync-results li span { overflow-wrap: anywhere; }.member-sync-results .el-button { flex-shrink: 0; }
|
||||||
:global(.promotion-pool-dialog) { max-width: calc(100vw - 32px); margin-top: 5vh; }
|
:global(.promotion-pool-dialog) { max-width: calc(100vw - 32px); margin-top: 5vh; }
|
||||||
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
|
@media (max-width: 1100px) { .heading-actions { flex-wrap: wrap; justify-content: flex-end; }.metric-grid, .customer-metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }.pool-layout { grid-template-columns: 210px minmax(0,1fr); }.pool-toolbar { align-items: flex-start; flex-direction: column; } }
|
||||||
@media (max-width: 760px) { .promotion-page { padding: 10px; }.page-header, .section-heading { align-items: flex-start; flex-direction: column; }.section-heading-actions { width: 100%; justify-content: flex-start; }.update-time { display: none; }.metric-grid, .customer-metric-grid, .form-grid, .rule-form-grid { 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()
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ interface QueueRow {
|
|||||||
assistant_name?: string
|
assistant_name?: string
|
||||||
appointment_date?: string
|
appointment_date?: string
|
||||||
appointment_time?: string
|
appointment_time?: string
|
||||||
|
appointment_type?: string | null
|
||||||
gender?: number
|
gender?: number
|
||||||
age?: number | null
|
age?: number | null
|
||||||
status?: number
|
status?: number
|
||||||
@@ -456,6 +457,7 @@ const handleCall = async (row: QueueRow) => {
|
|||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res
|
signatureData: res
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1022,6 +1022,7 @@ const handleChat = async (row: any) => {
|
|||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res // 传入签名数据
|
signatureData: res // 传入签名数据
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -662,6 +662,7 @@ const handleChat = async (row: any) => {
|
|||||||
patientId: sourcePatientId,
|
patientId: sourcePatientId,
|
||||||
patientName: row.patient_name,
|
patientName: row.patient_name,
|
||||||
diagnosisId,
|
diagnosisId,
|
||||||
|
appointmentType: row.appointment_type,
|
||||||
signatureData: res
|
signatureData: res
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -32,8 +32,9 @@
|
|||||||
|
|
||||||
<!-- 预约类型 -->
|
<!-- 预约类型 -->
|
||||||
<el-form-item label="预约类型:">
|
<el-form-item label="预约类型:">
|
||||||
<el-radio-group v-model="form.appointmentType">
|
<el-radio-group v-model="form.appointmentType">
|
||||||
<el-radio value="video">视频问诊</el-radio>
|
<el-radio value="video">视频问诊</el-radio>
|
||||||
|
<el-radio value="text">图文问诊</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
|
||||||
|
const filename = path.join(__dirname, '../src/views/consumer/prescription/components/PrescriptionOrderTimeDialog.vue')
|
||||||
|
const source = fs.readFileSync(filename, 'utf8')
|
||||||
|
const { descriptor, errors } = parse(source, { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: 'order-time-test' })
|
||||||
|
const template = compileTemplate({
|
||||||
|
source: descriptor.template.content,
|
||||||
|
filename,
|
||||||
|
id: 'order-time-test',
|
||||||
|
compilerOptions: { bindingMetadata: script.bindings }
|
||||||
|
})
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
const compiled = ts.transpileModule(script.content, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
function dialog(save = async () => ({})) {
|
||||||
|
const calls = []
|
||||||
|
const events = []
|
||||||
|
const module = { exports: {} }
|
||||||
|
const mockRequire = (name) => {
|
||||||
|
if (name === 'vue') return vue
|
||||||
|
if (name === '@/api/tcm') return {
|
||||||
|
prescriptionOrderEditTime: async (payload) => {
|
||||||
|
calls.push(payload)
|
||||||
|
return save(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name === '@/utils/feedback') return { default: { msgSuccess() {} } }
|
||||||
|
throw new Error(`Unexpected dependency: ${name}`)
|
||||||
|
}
|
||||||
|
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
|
||||||
|
const state = module.exports.default.setup({}, {
|
||||||
|
expose() {},
|
||||||
|
emit: (...event) => events.push(event)
|
||||||
|
})
|
||||||
|
state.formRef.value = { validate: async () => true, clearValidate() {} }
|
||||||
|
return { state, calls, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('opening and saving preserves creation time seconds and submits only the selected order', async () => {
|
||||||
|
const { state, calls, events } = dialog()
|
||||||
|
state.open({ id: 42, order_no: 'RX42', create_time: '2026-09-09 11:12:37' })
|
||||||
|
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
|
||||||
|
state.form.create_time = '2026-08-31 09:08:07'
|
||||||
|
await state.submit()
|
||||||
|
assert.deepEqual(calls, [{ id: 42, create_time: '2026-08-31 09:08:07' }])
|
||||||
|
assert.deepEqual(events, [['saved', 42]])
|
||||||
|
assert.equal(state.visible.value, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy seconds, milliseconds and strings populate the same local picker time', () => {
|
||||||
|
const { state } = dialog()
|
||||||
|
const date = new Date(2026, 8, 9, 11, 12, 37)
|
||||||
|
for (const value of [date.getTime() / 1000, String(date.getTime() / 1000), date.getTime(), '2026-09-09T11:12:37']) {
|
||||||
|
state.open({ id: 1, create_time: value })
|
||||||
|
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
|
||||||
|
}
|
||||||
|
state.open({ id: 1, create_time: '2026-09-09 11:12' })
|
||||||
|
assert.equal(state.form.create_time, '2026-09-09 11:12:00')
|
||||||
|
state.open({ id: 1, create_time: 0 })
|
||||||
|
assert.equal(state.form.create_time, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validation failures do not send requests, and failed saves retain editable input', async () => {
|
||||||
|
const { state, calls, events } = dialog(async () => { throw new Error('denied') })
|
||||||
|
state.open({ id: 9, create_time: '2026-09-09 11:12:37' })
|
||||||
|
state.formRef.value.validate = async () => { throw new Error('required') }
|
||||||
|
await state.submit()
|
||||||
|
assert.equal(calls.length, 0)
|
||||||
|
state.formRef.value.validate = async () => true
|
||||||
|
await state.submit()
|
||||||
|
assert.equal(state.visible.value, true)
|
||||||
|
assert.equal(state.submitting.value, false)
|
||||||
|
assert.equal(state.form.create_time, '2026-09-09 11:12:37')
|
||||||
|
assert.deepEqual(events, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a pending request cannot submit twice or switch its target order', async () => {
|
||||||
|
let finish
|
||||||
|
const pending = new Promise((resolve) => { finish = resolve })
|
||||||
|
const { state, calls, events } = dialog(() => pending)
|
||||||
|
state.open({ id: 7, create_time: '2026-09-09 11:12:37' })
|
||||||
|
const saving = state.submit()
|
||||||
|
await state.submit()
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
state.open({ id: 8, create_time: '2026-09-08 00:00:00' })
|
||||||
|
await state.submit()
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
assert.equal(state.form.id, 7)
|
||||||
|
finish({})
|
||||||
|
await saving
|
||||||
|
assert.deepEqual(events, [['saved', 7]])
|
||||||
|
})
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const test = require('node:test')
|
||||||
|
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vue = require('vue')
|
||||||
|
|
||||||
|
const filename = path.join(__dirname, '../src/views/first_visit/wecom_promotion/index.vue')
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: 'member-sync-test' })
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'member-sync-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'member-sync-test', scoped: true, preprocessLang: 'scss' }).errors, [])
|
||||||
|
|
||||||
|
function loadModule(source, mockRequire) {
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const module = { exports: {} }
|
||||||
|
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
|
||||||
|
return module.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const automation = loadModule(fs.readFileSync(path.join(path.dirname(filename), 'components/promotion-automation.ts'), 'utf8'), require)
|
||||||
|
const member = (id, enabled = 1) => ({ id, admin_id: id, userid: `member-${id}`, name: `医助 ${id}`, enabled, reception_available: enabled === 1, is_in_remote_range: true })
|
||||||
|
const pool = (id = 1) => ({ id, name: `方案 ${id}`, status: 1, can_operate: true, can_manage_access: true, member_admin_ids: [1, 2], member_rules: [member(1), member(2, 0)], official_link: { range_userids: ['member-1', 'member-2'] }, dispatch_sync: { status: 1 } })
|
||||||
|
|
||||||
|
function page(api = {}, pools = [pool()]) {
|
||||||
|
const messages = []
|
||||||
|
const emitMessage = (type, value) => messages.push({ type, message: typeof value === 'string' ? value : value.message })
|
||||||
|
const ElMessage = (value) => emitMessage(value.type, value)
|
||||||
|
for (const type of ['success', 'warning', 'error']) ElMessage[type] = (value) => emitMessage(type, value)
|
||||||
|
const instance = loadModule(script.content, (name) => {
|
||||||
|
if (name === 'vue') return { ...vue, onMounted() {} }
|
||||||
|
if (name === 'element-plus') return { ElMessage, ElMessageBox: {} }
|
||||||
|
if (name === '@element-plus/icons-vue') return {}
|
||||||
|
if (name === '@/api/first_visit') return { wecomPromotionOverview: async () => ({ pools }), ...api }
|
||||||
|
if (name.endsWith('.vue')) return {}
|
||||||
|
if (name === './components/promotion-automation') return automation
|
||||||
|
throw new Error(`Unexpected dependency: ${name}`)
|
||||||
|
}).default.setup({}, { expose() {} })
|
||||||
|
Object.assign(instance.overview, { pools })
|
||||||
|
instance.selectedPoolId.value = pools[0]?.id
|
||||||
|
return { state: instance, messages }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('disabled local member still in remote snapshot is explicitly pending removal', () => {
|
||||||
|
const { state } = page()
|
||||||
|
assert.equal(state.routeStatus(member(2, 0)).label, '待移出(仍在企微)')
|
||||||
|
assert.equal(state.routeStatus({ ...member(1), is_in_remote_range: '0' }).label, '待加入企微')
|
||||||
|
assert.match(state.selectedSyncState.value.description, /当前计划:医助 1;上次企微确认:医助 1、医助 2/)
|
||||||
|
state.overview.pools[0].dispatch_sync = { status: 3, last_error: '可信 IP 校验失败' }
|
||||||
|
assert.match(state.selectedSyncState.value.description, /可信 IP 校验失败/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('single toggles never infer remote success from an empty error or planned dispatch', async () => {
|
||||||
|
const calls = []
|
||||||
|
const { state, messages } = page({ wecomPromotionToggleMember: async (payload) => { calls.push(payload); return { sync_error: '', dispatch: { queued: true } } } })
|
||||||
|
await state.handleMemberToggle(member(2), false)
|
||||||
|
assert.deepEqual(calls, [{ id: 2, status: 0 }])
|
||||||
|
assert.equal(messages.at(-1).type, 'warning')
|
||||||
|
assert.match(messages.at(-1).message, /尚未确认同步/)
|
||||||
|
assert.doesNotMatch(messages.at(-1).message, /已确认同步|已重新计算/)
|
||||||
|
assert.match(state.operationResultText(state.operationResults.value[0]), /尚未确认同步/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('single rule save reports explicit remote confirmation and preserves precise failures', async () => {
|
||||||
|
const { state, messages } = page({ wecomPromotionSaveMember: async () => ({ sync_status: 'failed', sync_error: '企微成员范围不一致' }) })
|
||||||
|
Object.assign(state.memberForm, { id: 2, active_range: [] })
|
||||||
|
await state.saveMemberRule()
|
||||||
|
assert.equal(messages.at(-1).type, 'warning')
|
||||||
|
assert.match(messages.at(-1).message, /企微成员范围不一致/)
|
||||||
|
state.notifySavedResult('本地已保存', { sync_status: 'synced' })
|
||||||
|
assert.equal(messages.at(-1).type, 'success')
|
||||||
|
assert.match(messages.at(-1).message, /企微成员范围已确认同步/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pool saves with queued work retain a warning instead of implying official link completion', async () => {
|
||||||
|
const { state, messages } = page({ wecomPromotionSavePool: async () => ({ id: 1, sync_status: 'pending', sync_queued: true, sync_error: '' }) })
|
||||||
|
Object.assign(state.poolForm, { id: 1, name: '方案 1', member_admin_ids: [1] })
|
||||||
|
await state.savePool()
|
||||||
|
assert.equal(state.poolDialogVisible.value, false)
|
||||||
|
assert.equal(messages.at(-1).type, 'warning')
|
||||||
|
assert.match(messages.at(-1).message, /尚未确认同步/)
|
||||||
|
assert.equal(state.operationResults.value[0].sync_queued, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manual retry targets only its pool and does not import remote links', async () => {
|
||||||
|
const calls = []
|
||||||
|
const { state, messages } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { pool_id: 1, sync_status: 'synced', sync_error: '', sync_queued: false } } })
|
||||||
|
await state.syncMemberRange(1)
|
||||||
|
assert.deepEqual(calls, [{ pool_id: 1 }])
|
||||||
|
assert.equal(state.operationResults.value[0].sync_status, 'synced')
|
||||||
|
assert.equal(state.syncingPoolId.value, 0)
|
||||||
|
assert.equal(messages.at(-1).type, 'success')
|
||||||
|
state.overview.pools[0].can_operate = false
|
||||||
|
await state.syncMemberRange(1)
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('batch sync awaits all queued successful pools with at most two concurrent requests', async () => {
|
||||||
|
const pending = []
|
||||||
|
let active = 0
|
||||||
|
let maximum = 0
|
||||||
|
const { state } = page({ wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
|
||||||
|
active++
|
||||||
|
maximum = Math.max(maximum, active)
|
||||||
|
pending.push({ id: pool_id, finish: (result) => { active--; resolve({ pool_id, ...result }) } })
|
||||||
|
}) })
|
||||||
|
const results = [1, 2, 3].map((id) => ({ id, name: `方案 ${id}`, success: true, sync_queued: true }))
|
||||||
|
results.push({ id: 4, name: '保存失败方案', success: false, sync_queued: true, error: '无权限' })
|
||||||
|
const saving = state.syncBatchResults(results)
|
||||||
|
assert.deepEqual(pending.map((item) => item.id), [1, 2])
|
||||||
|
pending[0].finish({ sync_status: 'synced', sync_error: '' })
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
|
||||||
|
assert.equal(state.batchSyncProgress.completed, 1)
|
||||||
|
pending[1].finish({ sync_status: 'failed', sync_error: '范围校验失败' })
|
||||||
|
pending[2].finish({ sync_status: 'pending', sync_error: '' })
|
||||||
|
await saving
|
||||||
|
assert.equal(maximum, 2)
|
||||||
|
assert.equal(state.batchSyncProgress.completed, 3)
|
||||||
|
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', undefined])
|
||||||
|
assert.match(state.operationResultText(state.operationResults.value[1]), /范围校验失败/)
|
||||||
|
assert.match(state.operationResultText(state.operationResults.value[3]), /本地保存失败:无权限/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('batch save retains partial failures and never reports queued work as remote success', async () => {
|
||||||
|
const pools = [pool(1), pool(2), pool(3)]
|
||||||
|
const syncedIds = []
|
||||||
|
const { state, messages } = page({
|
||||||
|
wecomPromotionBatchUpdatePools: async () => ({ updated: 2, failed: 1, member_updated: 2, results: [
|
||||||
|
{ id: 1, name: '方案 1', success: true, sync_queued: true },
|
||||||
|
{ id: 2, name: '方案 2', success: true, sync_queued: true },
|
||||||
|
{ id: 3, name: '方案 3', success: false, error: '保存失败' }
|
||||||
|
] }),
|
||||||
|
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
|
||||||
|
syncedIds.push(pool_id)
|
||||||
|
if (pool_id === 2) throw '企微请求超时'
|
||||||
|
return { pool_id, sync_status: 'synced', sync_error: '', sync_queued: false }
|
||||||
|
}
|
||||||
|
}, pools)
|
||||||
|
Object.assign(state.batchConfigApply, { member_status: true })
|
||||||
|
Object.assign(state.batchConfigForm, { pool_ids: [1, 2, 3], member_admin_ids: [2], member_status: 0 })
|
||||||
|
state.batchConfigDialogVisible.value = true
|
||||||
|
await state.saveBatchConfig()
|
||||||
|
assert.deepEqual(syncedIds, [1, 2])
|
||||||
|
assert.equal(state.savingBatchConfig.value, false)
|
||||||
|
assert.equal(state.batchConfigDialogVisible.value, false)
|
||||||
|
assert.deepEqual(state.selectedPoolIds.value, [2, 3])
|
||||||
|
assert.match(messages.at(-1).message, /企微已确认同步 1 个,1 个尚未确认同步;1 个本地保存失败/)
|
||||||
|
assert.equal(messages.at(-1).type, 'warning')
|
||||||
|
assert.match(state.operationResults.value[1].sync_error, /企微请求超时/)
|
||||||
|
})
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 73 KiB |
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Bundled Noto Sans SC
|
||||||
|
|
||||||
|
`NotoSansSC-VF.ttf` is the unmodified Google Fonts distribution of
|
||||||
|
`NotoSansSC[wght].ttf`, stored under a filename without brackets for simpler
|
||||||
|
resource lookup. No font bytes were changed or subsetted locally.
|
||||||
|
|
||||||
|
- Qt family: `Noto Sans SC`
|
||||||
|
- Font version: `Version 2.004-H2;hotconv 1.0.118;makeotfexe 2.5.65603`
|
||||||
|
- Variable axis: `wght`, 100–900; named instances at every 100, including 600.
|
||||||
|
- Size: 17,772,300 bytes.
|
||||||
|
- SHA-256: `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da`
|
||||||
|
- Official repository revision: `google/fonts@5e35378e6bda803962ee6fd257e444a7d459660d`.
|
||||||
|
- [Pinned font source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf).
|
||||||
|
- [Pinned license source](https://github.com/google/fonts/blob/5e35378e6bda803962ee6fd257e444a7d459660d/ofl/notosanssc/OFL.txt).
|
||||||
|
|
||||||
|
The font is distributed under the SIL Open Font License 1.1. Retain
|
||||||
|
`OFL-NotoSansSC.txt`, including its copyright notice, when redistributing the
|
||||||
|
font with the application. The license applies to the font, independently of
|
||||||
|
the application's license.
|
||||||
|
|
||||||
|
Load this local resource through `QFontDatabase.addApplicationFont` after
|
||||||
|
creating `QApplication`, then use the returned family name. The application
|
||||||
|
must not fetch fonts at runtime. The PyInstaller spec already copies the
|
||||||
|
entire `resources` directory, including this directory and its license.
|
||||||
|
|
||||||
|
Google Fonts supplies explicit Regular (400), Medium (500), and SemiBold (600)
|
||||||
|
instances. The Noto CJK upstream 2.004 file lacks a named 600 instance and Qt
|
||||||
|
may select Medium for a plain `font-weight: 600` request; this distribution
|
||||||
|
preserves distinct results with the application's normal QSS font weights.
|
||||||
@@ -169,8 +169,30 @@ try {
|
|||||||
& $Npm run build --prefix $CompanionRoot
|
& $Npm run build --prefix $CompanionRoot
|
||||||
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
|
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
|
||||||
|
|
||||||
& $Python -m PyInstaller --noconfirm --clean $Spec
|
$BuildPythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
|
||||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
|
if ($LASTEXITCODE -ne 0 -or -not $BuildPythonBase) {
|
||||||
|
throw "Unable to resolve the build Python runtime directory"
|
||||||
|
}
|
||||||
|
# Dependency scanning must not collect unrelated ICU/OpenSSL libraries from
|
||||||
|
# an editor's helper tools (for example Poppler) ahead of Windows libraries.
|
||||||
|
$PreviousBuildPath = $env:PATH
|
||||||
|
$BuildRuntimePaths = @(
|
||||||
|
(Split-Path -Parent $Python),
|
||||||
|
$BuildPythonBase,
|
||||||
|
(Join-Path $BuildPythonBase "DLLs"),
|
||||||
|
(Join-Path $env:SystemRoot "System32"),
|
||||||
|
$env:SystemRoot,
|
||||||
|
(Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0")
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
$env:PATH = ($BuildRuntimePaths | Select-Object -Unique) -join [System.IO.Path]::PathSeparator
|
||||||
|
& $Python -m PyInstaller --noconfirm --clean $Spec
|
||||||
|
$PyInstallerExitCode = $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$env:PATH = $PreviousBuildPath
|
||||||
|
}
|
||||||
|
if ($PyInstallerExitCode -ne 0) { throw "PyInstaller build failed" }
|
||||||
|
|
||||||
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
|
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
|
||||||
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
|
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ from typing import Any
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PySide6.QtGui import QFont, QFontDatabase
|
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui import apply_theme
|
||||||
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog
|
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog
|
||||||
from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog
|
from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog
|
||||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||||
@@ -480,12 +480,7 @@ def _run_immediately(
|
|||||||
|
|
||||||
def render() -> list[Path]:
|
def render() -> list[Path]:
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
apply_theme(app)
|
||||||
if font_path.is_file():
|
|
||||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
|
||||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
|
||||||
if families:
|
|
||||||
app.setFont(QFont(families[0], 9))
|
|
||||||
diagnosis_module.run_async = _run_immediately
|
diagnosis_module.run_async = _run_immediately
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
output = root / "artifacts" / "diagnosis_visual"
|
output = root / "artifacts" / "diagnosis_visual"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PySide6.QtCore import QThreadPool
|
from PySide6.QtCore import QThreadPool
|
||||||
from PySide6.QtGui import QFont, QFontDatabase
|
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from doctor_workstation.services import DemoDoctorRepository
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
@@ -19,13 +18,6 @@ def render() -> list[Path]:
|
|||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
apply_theme(app)
|
apply_theme(app)
|
||||||
|
|
||||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
|
||||||
if font_path.is_file():
|
|
||||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
|
||||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
|
||||||
if families:
|
|
||||||
app.setFont(QFont(families[0], 9))
|
|
||||||
|
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
output = root / "artifacts" / "diagnosis_visual"
|
output = root / "artifacts" / "diagnosis_visual"
|
||||||
output.mkdir(parents=True, exist_ok=True)
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ from typing import Any
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QThreadPool, Signal
|
from PySide6.QtCore import Qt, QThreadPool, Signal
|
||||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap
|
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
|
||||||
from PySide6.QtWidgets import QApplication, QToolButton, QWidget
|
from PySide6.QtWidgets import QApplication, QToolButton, QWidget
|
||||||
|
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui import apply_theme
|
||||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||||
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||||||
|
|
||||||
@@ -339,14 +340,7 @@ def _save_with_payment_qr(
|
|||||||
|
|
||||||
def _application() -> QApplication:
|
def _application() -> QApplication:
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
# The offscreen Windows plugin does not enumerate system fonts. Register
|
apply_theme(app)
|
||||||
# the same CJK face used by the production QSS when it is available.
|
|
||||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
|
||||||
if font_path.is_file():
|
|
||||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
|
||||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
|
||||||
if families:
|
|
||||||
app.setFont(QFont(families[0], 9))
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from typing import Any
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PySide6.QtGui import QFont, QFontDatabase
|
|
||||||
from PySide6.QtWidgets import QApplication, QWidget
|
from PySide6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
from doctor_workstation.core.permissions import PermissionSet
|
from doctor_workstation.core.permissions import PermissionSet
|
||||||
@@ -136,12 +135,6 @@ def _settle(app: QApplication, rounds: int = 8) -> None:
|
|||||||
def render() -> list[Path]:
|
def render() -> list[Path]:
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
apply_theme(app)
|
apply_theme(app)
|
||||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
|
||||||
if font_path.is_file():
|
|
||||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
|
||||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
|
||||||
if families:
|
|
||||||
app.setFont(QFont(families[0], 9))
|
|
||||||
|
|
||||||
output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
|
output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
|
||||||
output.mkdir(parents=True, exist_ok=True)
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PySide6.QtCore import QThreadPool
|
from PySide6.QtCore import QThreadPool
|
||||||
from PySide6.QtGui import QFontDatabase
|
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||||
@@ -19,9 +18,6 @@ from doctor_workstation.ui.theme import apply_theme
|
|||||||
def main() -> int:
|
def main() -> int:
|
||||||
application = QApplication.instance() or QApplication([])
|
application = QApplication.instance() or QApplication([])
|
||||||
apply_theme(application)
|
apply_theme(application)
|
||||||
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
|
|
||||||
if font_path.is_file():
|
|
||||||
QFontDatabase.addApplicationFont(str(font_path))
|
|
||||||
|
|
||||||
repository = DemoDoctorRepository()
|
repository = DemoDoctorRepository()
|
||||||
session = repository.login("doctor", "doctor123")
|
session = repository.login("doctor", "doctor123")
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Render the clinical reading surfaces with demo data and production fonts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
|
from PySide6.QtGui import QFontInfo, QGuiApplication, QPalette
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(app: QApplication) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
QThreadPool.globalInstance().waitForDone(3000)
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(100)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--output", type=Path, default=Path("artifacts/ui_comfort"))
|
||||||
|
parser.add_argument("--width", type=int, default=1536)
|
||||||
|
parser.add_argument("--height", type=int, default=912)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.output.mkdir(parents=True, exist_ok=True)
|
||||||
|
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||||
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
|
)
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
repo = DemoDoctorRepository()
|
||||||
|
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||||
|
shell = ShellWindow(
|
||||||
|
repo, {"session": session, "demo_mode": True}, permissions=session.permissions
|
||||||
|
)
|
||||||
|
shell.resize(args.width, args.height)
|
||||||
|
shell.show()
|
||||||
|
try:
|
||||||
|
shell.navigate("reception")
|
||||||
|
_settle(app)
|
||||||
|
page = shell.pages["reception"]
|
||||||
|
page._set_queue_filter(None)
|
||||||
|
_settle(app)
|
||||||
|
# A synthetic multiline case tests paragraph rhythm without capturing
|
||||||
|
# a live patient or connecting to a production service.
|
||||||
|
page.case_labels["present"].setText(
|
||||||
|
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
|
||||||
|
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
|
||||||
|
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
|
||||||
|
)
|
||||||
|
app.processEvents()
|
||||||
|
if not shell.grab().save(str(args.output / "reception.png")):
|
||||||
|
raise RuntimeError("Could not save reception preview")
|
||||||
|
daily = next(
|
||||||
|
index
|
||||||
|
for index in range(page.detail_tabs.count())
|
||||||
|
if page.detail_tabs.tabText(index) == "日常记录"
|
||||||
|
)
|
||||||
|
page.detail_tabs.setCurrentIndex(daily)
|
||||||
|
_settle(app)
|
||||||
|
if not shell.grab().save(str(args.output / "daily_records.png")):
|
||||||
|
raise RuntimeError("Could not save daily-record preview")
|
||||||
|
metrics = {
|
||||||
|
"family": QFontInfo(app.font()).family(),
|
||||||
|
"pixel_size": app.font().pixelSize(),
|
||||||
|
"font_strategy": app.font().styleStrategy().value,
|
||||||
|
"font_hinting": app.font().hintingPreference().name,
|
||||||
|
"text_color": app.palette().color(QPalette.ColorRole.Text).name(),
|
||||||
|
"device_pixel_ratio": shell.devicePixelRatioF(),
|
||||||
|
"window": [shell.width(), shell.height()],
|
||||||
|
"daily_table_font": QFontInfo(page.daily_panel.matrix.font()).family(),
|
||||||
|
"daily_table_size": page.daily_panel.matrix.font().pixelSize(),
|
||||||
|
}
|
||||||
|
(args.output / "render.json").write_text(
|
||||||
|
json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(args.output)
|
||||||
|
finally:
|
||||||
|
_settle(app)
|
||||||
|
shell.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,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.2"
|
||||||
|
|
||||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||||
# 正式发布请保持 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"
|
||||||
|
|||||||
+380
-380
@@ -5,9 +5,9 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
|
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
|
||||||
@@ -27,7 +27,7 @@ from doctor_workstation.config import AppConfig
|
|||||||
from doctor_workstation.core import Session
|
from doctor_workstation.core import Session
|
||||||
from doctor_workstation.core.errors import AuthenticationExpiredError
|
from doctor_workstation.core.errors import AuthenticationExpiredError
|
||||||
from doctor_workstation.logging_setup import configure_logging
|
from doctor_workstation.logging_setup import configure_logging
|
||||||
from doctor_workstation.resources import app_icon_path, video_dist_path
|
from doctor_workstation.resources import app_icon_path, video_dist_path
|
||||||
from doctor_workstation.services import (
|
from doctor_workstation.services import (
|
||||||
DemoDoctorRepository,
|
DemoDoctorRepository,
|
||||||
RemoteDoctorRepository,
|
RemoteDoctorRepository,
|
||||||
@@ -35,179 +35,179 @@ from doctor_workstation.services import (
|
|||||||
build_repository,
|
build_repository,
|
||||||
)
|
)
|
||||||
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
|
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
|
||||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
||||||
from doctor_workstation.ui.widgets import (
|
from doctor_workstation.ui.widgets import (
|
||||||
first_value,
|
first_value,
|
||||||
friendly_error,
|
friendly_error,
|
||||||
gender_text,
|
gender_text,
|
||||||
get_value,
|
get_value,
|
||||||
run_async,
|
run_async,
|
||||||
set_authentication_expired_handler,
|
set_authentication_expired_handler,
|
||||||
show_toast,
|
show_toast,
|
||||||
)
|
)
|
||||||
from doctor_workstation.video import BackendMode, launch_video_call
|
from doctor_workstation.video import BackendMode, launch_video_call
|
||||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
||||||
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
||||||
|
|
||||||
if value in (None, "", [], {}):
|
if value in (None, "", [], {}):
|
||||||
return ""
|
return ""
|
||||||
if isinstance(value, Mapping):
|
if isinstance(value, Mapping):
|
||||||
parts = [
|
parts = [
|
||||||
f"{key}:{_video_case_text(item, limit=limit)}"
|
f"{key}:{_video_case_text(item, limit=limit)}"
|
||||||
for key, item in value.items()
|
for key, item in value.items()
|
||||||
if item not in (None, "", [], {})
|
if item not in (None, "", [], {})
|
||||||
]
|
]
|
||||||
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
||||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||||
return "、".join(
|
return "、".join(
|
||||||
part
|
part
|
||||||
for item in value
|
for item in value
|
||||||
if (part := _video_case_text(item, limit=limit))
|
if (part := _video_case_text(item, limit=limit))
|
||||||
)[:limit]
|
)[:limit]
|
||||||
return str(value).strip()[:limit]
|
return str(value).strip()[:limit]
|
||||||
|
|
||||||
|
|
||||||
def _video_identity(value: Any) -> str:
|
def _video_identity(value: Any) -> str:
|
||||||
if value in (None, "") or isinstance(value, bool):
|
if value in (None, "") or isinstance(value, bool):
|
||||||
return ""
|
return ""
|
||||||
text = str(value).strip()
|
text = str(value).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
with suppress(ValueError, TypeError):
|
with suppress(ValueError, TypeError):
|
||||||
return str(int(text))
|
return str(int(text))
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
||||||
normalized_actual = _video_identity(actual)
|
normalized_actual = _video_identity(actual)
|
||||||
return not normalized_actual or normalized_actual == _video_identity(expected)
|
return not normalized_actual or normalized_actual == _video_identity(expected)
|
||||||
|
|
||||||
|
|
||||||
def _build_video_patient_case(
|
def _build_video_patient_case(
|
||||||
detail: Any,
|
detail: Any,
|
||||||
fallback_record: Any,
|
fallback_record: Any,
|
||||||
*,
|
*,
|
||||||
diagnosis_id: Any,
|
diagnosis_id: Any,
|
||||||
patient_id: Any,
|
patient_id: Any,
|
||||||
patient_name: str,
|
patient_name: str,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
||||||
|
|
||||||
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
||||||
nested = get_value(detail, "data", None)
|
nested = get_value(detail, "data", None)
|
||||||
if isinstance(nested, Mapping):
|
if isinstance(nested, Mapping):
|
||||||
detail = nested
|
detail = nested
|
||||||
diagnosis = get_value(detail, "diagnosis", None)
|
diagnosis = get_value(detail, "diagnosis", None)
|
||||||
if not diagnosis and isinstance(detail, Mapping):
|
if not diagnosis and isinstance(detail, Mapping):
|
||||||
diagnosis = detail
|
diagnosis = detail
|
||||||
diagnosis = diagnosis or {}
|
diagnosis = diagnosis or {}
|
||||||
patient = get_value(detail, "patient", None) or {}
|
patient = get_value(detail, "patient", None) or {}
|
||||||
appointment = get_value(detail, "appointment", None) or {}
|
appointment = get_value(detail, "appointment", None) or {}
|
||||||
|
|
||||||
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
||||||
detail_patient_id = first_value(
|
detail_patient_id = first_value(
|
||||||
diagnosis,
|
diagnosis,
|
||||||
"source_patient_id",
|
"source_patient_id",
|
||||||
default=first_value(
|
default=first_value(
|
||||||
patient,
|
patient,
|
||||||
"source_patient_id",
|
"source_patient_id",
|
||||||
default=None,
|
default=None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if not _video_identity_matches(
|
if not _video_identity_matches(
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
detail_diagnosis_id,
|
detail_diagnosis_id,
|
||||||
) or not _video_identity_matches(patient_id, detail_patient_id):
|
) or not _video_identity_matches(patient_id, detail_patient_id):
|
||||||
detail = diagnosis = patient = appointment = {}
|
detail = diagnosis = patient = appointment = {}
|
||||||
|
|
||||||
fallback_diagnosis_id = first_value(
|
fallback_diagnosis_id = first_value(
|
||||||
fallback_record,
|
fallback_record,
|
||||||
"diagnosis_id",
|
"diagnosis_id",
|
||||||
"id",
|
"id",
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
fallback_patient_id = first_value(
|
fallback_patient_id = first_value(
|
||||||
fallback_record,
|
fallback_record,
|
||||||
"source_patient_id",
|
"source_patient_id",
|
||||||
"patient_id",
|
"patient_id",
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
if not _video_identity_matches(
|
if not _video_identity_matches(
|
||||||
diagnosis_id,
|
diagnosis_id,
|
||||||
fallback_diagnosis_id,
|
fallback_diagnosis_id,
|
||||||
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
||||||
fallback_record = {}
|
fallback_record = {}
|
||||||
sources = (diagnosis, patient, appointment, fallback_record)
|
sources = (diagnosis, patient, appointment, fallback_record)
|
||||||
|
|
||||||
def pick(*keys: str, limit: int = 2000) -> str:
|
def pick(*keys: str, limit: int = 2000) -> str:
|
||||||
for source in sources:
|
for source in sources:
|
||||||
value = first_value(source, *keys, default=None)
|
value = first_value(source, *keys, default=None)
|
||||||
text = _video_case_text(value, limit=limit)
|
text = _video_case_text(value, limit=limit)
|
||||||
if text:
|
if text:
|
||||||
return text
|
return text
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
raw_gender = next(
|
raw_gender = next(
|
||||||
(
|
(
|
||||||
first_value(source, "gender_desc", "gender", default=None)
|
first_value(source, "gender_desc", "gender", default=None)
|
||||||
for source in sources
|
for source in sources
|
||||||
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
||||||
"name": pick("patient_name", "name", limit=120)
|
"name": pick("patient_name", "name", limit=120)
|
||||||
or _video_case_text(patient_name, limit=120)
|
or _video_case_text(patient_name, limit=120)
|
||||||
or "患者",
|
or "患者",
|
||||||
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
||||||
"age": pick("age", limit=20),
|
"age": pick("age", limit=20),
|
||||||
"height": pick("height", limit=20),
|
"height": pick("height", limit=20),
|
||||||
"weight": pick("weight", limit=20),
|
"weight": pick("weight", limit=20),
|
||||||
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
||||||
"appointmentDate": pick(
|
"appointmentDate": pick(
|
||||||
"appointment_date",
|
"appointment_date",
|
||||||
"latest_appointment_date",
|
"latest_appointment_date",
|
||||||
limit=80,
|
limit=80,
|
||||||
),
|
),
|
||||||
"clinicalDiagnosis": pick(
|
"clinicalDiagnosis": pick(
|
||||||
"clinical_diagnosis",
|
"clinical_diagnosis",
|
||||||
"diagnosis_name",
|
"diagnosis_name",
|
||||||
"disease_name",
|
"disease_name",
|
||||||
),
|
),
|
||||||
"chiefComplaint": pick("chief_complaint", "complaint"),
|
"chiefComplaint": pick("chief_complaint", "complaint"),
|
||||||
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
||||||
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
||||||
"allergyHistory": pick(
|
"allergyHistory": pick(
|
||||||
"allergy_history_text",
|
"allergy_history_text",
|
||||||
"allergy_history_desc",
|
"allergy_history_desc",
|
||||||
"allergy_history",
|
"allergy_history",
|
||||||
),
|
),
|
||||||
"personalHistory": pick(
|
"personalHistory": pick(
|
||||||
"personal_history_text",
|
"personal_history_text",
|
||||||
"personal_history_desc",
|
"personal_history_desc",
|
||||||
"personal_history",
|
"personal_history",
|
||||||
),
|
),
|
||||||
"familyHistory": pick(
|
"familyHistory": pick(
|
||||||
"family_history_text",
|
"family_history_text",
|
||||||
"family_history_desc",
|
"family_history_desc",
|
||||||
"family_history",
|
"family_history",
|
||||||
),
|
),
|
||||||
"currentMedication": pick(
|
"currentMedication": pick(
|
||||||
"current_medications",
|
"current_medications",
|
||||||
"current_medicine",
|
"current_medicine",
|
||||||
"current_medication",
|
"current_medication",
|
||||||
),
|
),
|
||||||
"tongue": pick("tongue", "tongue_coating"),
|
"tongue": pick("tongue", "tongue_coating"),
|
||||||
"pulse": pick("pulse", "pulse_condition"),
|
"pulse": pick("pulse", "pulse_condition"),
|
||||||
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
||||||
"remark": pick("remark"),
|
"remark": pick("remark"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _ChineseQtTranslator(QTranslator):
|
class _ChineseQtTranslator(QTranslator):
|
||||||
@@ -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;")
|
||||||
@@ -400,22 +400,22 @@ class ApplicationController(QObject):
|
|||||||
"""Own windows, repositories and the authenticated application lifecycle."""
|
"""Own windows, repositories and the authenticated application lifecycle."""
|
||||||
|
|
||||||
def __init__(self, application: QApplication, config: AppConfig) -> None:
|
def __init__(self, application: QApplication, config: AppConfig) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.application = application
|
self.application = application
|
||||||
self.config = config
|
self.config = config
|
||||||
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
||||||
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
||||||
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
|
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
|
||||||
self.remote_repository: RemoteDoctorRepository | None = None
|
self.remote_repository: RemoteDoctorRepository | None = None
|
||||||
self.login_window: LoginWindow | None = None
|
self.login_window: LoginWindow | None = None
|
||||||
self.shell_window: ShellWindow | None = None
|
self.shell_window: ShellWindow | None = None
|
||||||
self.current_repository: Any = None
|
self.current_repository: Any = None
|
||||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||||
self.video_calls: dict[str, Any] = {}
|
self.video_calls: dict[str, Any] = {}
|
||||||
self.video_pending: dict[str, object] = {}
|
self.video_pending: dict[str, object] = {}
|
||||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||||
self._video_preview_state: dict[str, Any] | None = None
|
self._video_preview_state: dict[str, Any] | None = None
|
||||||
self._video_preview_generation = 0
|
self._video_preview_generation = 0
|
||||||
self._restore_generation = 0
|
self._restore_generation = 0
|
||||||
self._restore_in_progress = False
|
self._restore_in_progress = False
|
||||||
self._restore_worker: Any = None
|
self._restore_worker: Any = None
|
||||||
@@ -506,11 +506,11 @@ class ApplicationController(QObject):
|
|||||||
if self.login_window is not None:
|
if self.login_window is not None:
|
||||||
self.login_window.config = self.config
|
self.login_window.config = self.config
|
||||||
|
|
||||||
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
||||||
allowed = self.debug_mode and enabled
|
allowed = self.debug_mode and enabled
|
||||||
self.current_demo_mode = allowed
|
self.current_demo_mode = allowed
|
||||||
if allowed:
|
if allowed:
|
||||||
self._cancel_session_restore()
|
self._cancel_session_restore()
|
||||||
|
|
||||||
def _rebuild_remote_repository(self) -> None:
|
def _rebuild_remote_repository(self) -> None:
|
||||||
old = self.remote_repository
|
old = self.remote_repository
|
||||||
@@ -670,8 +670,8 @@ class ApplicationController(QObject):
|
|||||||
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
|
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
|
||||||
return
|
return
|
||||||
|
|
||||||
demo_mode = self.debug_mode and bool(payload.get("demo_mode"))
|
demo_mode = self.debug_mode and bool(payload.get("demo_mode"))
|
||||||
payload["demo_mode"] = demo_mode
|
payload["demo_mode"] = demo_mode
|
||||||
if not demo_mode and not session.menu:
|
if not demo_mode and not session.menu:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
repository.logout()
|
repository.logout()
|
||||||
@@ -715,17 +715,17 @@ class ApplicationController(QObject):
|
|||||||
self._logout(message="登录状态已失效,请重新登录。")
|
self._logout(message="登录状态已失效,请重新登录。")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _logout(self, *, message: str = "") -> None:
|
def _logout(self, *, message: str = "") -> None:
|
||||||
"""Clear authenticated resources and return to the login window."""
|
"""Clear authenticated resources and return to the login window."""
|
||||||
|
|
||||||
self._restore_video_preview(activate=False)
|
self._restore_video_preview(activate=False)
|
||||||
calls = tuple(self.video_calls.values())
|
calls = tuple(self.video_calls.values())
|
||||||
for call in calls:
|
for call in calls:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
call.close()
|
call.close()
|
||||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||||
self.video_calls.clear()
|
self.video_calls.clear()
|
||||||
self.video_pending.clear()
|
self.video_pending.clear()
|
||||||
for dialog in self.demo_video_dialogs.values():
|
for dialog in self.demo_video_dialogs.values():
|
||||||
dialog.close()
|
dialog.close()
|
||||||
self.demo_video_dialogs.clear()
|
self.demo_video_dialogs.clear()
|
||||||
@@ -748,8 +748,8 @@ class ApplicationController(QObject):
|
|||||||
patient_id = payload.get("patient_id")
|
patient_id = payload.get("patient_id")
|
||||||
diagnosis_id = payload.get("diagnosis_id")
|
diagnosis_id = payload.get("diagnosis_id")
|
||||||
patient_name = str(payload.get("patient_name") or "患者")
|
patient_name = str(payload.get("patient_name") or "患者")
|
||||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||||
fallback_record = payload.get("record")
|
fallback_record = payload.get("record")
|
||||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||||
return
|
return
|
||||||
@@ -803,49 +803,49 @@ class ApplicationController(QObject):
|
|||||||
marker = object()
|
marker = object()
|
||||||
self.video_pending[call_key] = marker
|
self.video_pending[call_key] = marker
|
||||||
|
|
||||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||||
ticket = repository.get_call_ticket(
|
ticket = repository.get_call_ticket(
|
||||||
patient_id=int(patient_id),
|
patient_id=int(patient_id),
|
||||||
diagnosis_id=int(diagnosis_id),
|
diagnosis_id=int(diagnosis_id),
|
||||||
)
|
)
|
||||||
detail: Any = {}
|
detail: Any = {}
|
||||||
detail_loader = getattr(repository, "patient_detail", None)
|
detail_loader = getattr(repository, "patient_detail", None)
|
||||||
if callable(detail_loader):
|
if callable(detail_loader):
|
||||||
try:
|
try:
|
||||||
detail = detail_loader(int(diagnosis_id))
|
detail = detail_loader(int(diagnosis_id))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"patient detail could not be loaded for video call",
|
"patient detail could not be loaded for video call",
|
||||||
extra={
|
extra={
|
||||||
"diagnosis_id": diagnosis_id,
|
"diagnosis_id": diagnosis_id,
|
||||||
"error_type": type(error).__name__,
|
"error_type": type(error).__name__,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
patient_case = _build_video_patient_case(
|
patient_case = _build_video_patient_case(
|
||||||
detail,
|
detail,
|
||||||
fallback_record,
|
fallback_record,
|
||||||
diagnosis_id=diagnosis_id,
|
diagnosis_id=diagnosis_id,
|
||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
)
|
)
|
||||||
return ticket, patient_case
|
return ticket, patient_case
|
||||||
|
|
||||||
def request_ticket() -> None:
|
def request_ticket() -> None:
|
||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
run_async(
|
run_async(
|
||||||
get_video_context,
|
get_video_context,
|
||||||
on_success=lambda context: self._launch_video(
|
on_success=lambda context: self._launch_video(
|
||||||
context[0],
|
context[0],
|
||||||
diagnosis_id=diagnosis_id,
|
diagnosis_id=diagnosis_id,
|
||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
repository=repository,
|
repository=repository,
|
||||||
call_key=call_key,
|
call_key=call_key,
|
||||||
marker=marker,
|
marker=marker,
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
patient_case=context[1],
|
patient_case=context[1],
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._video_ticket_error(
|
on_error=lambda error: self._video_ticket_error(
|
||||||
call_key,
|
call_key,
|
||||||
marker,
|
marker,
|
||||||
@@ -888,9 +888,9 @@ class ApplicationController(QObject):
|
|||||||
repository: Any,
|
repository: Any,
|
||||||
call_key: str,
|
call_key: str,
|
||||||
marker: object,
|
marker: object,
|
||||||
open_im: bool = False,
|
open_im: bool = False,
|
||||||
patient_name: str = "患者",
|
patient_name: str = "患者",
|
||||||
patient_case: Mapping[str, Any] | None = None,
|
patient_case: Mapping[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
@@ -916,13 +916,13 @@ class ApplicationController(QObject):
|
|||||||
local_dist=video_dist_path(),
|
local_dist=video_dist_path(),
|
||||||
remote_url=self.config.video_web_url or None,
|
remote_url=self.config.video_web_url or None,
|
||||||
logger=logging.getLogger("doctor_workstation.video"),
|
logger=logging.getLogger("doctor_workstation.video"),
|
||||||
open_im=open_im,
|
open_im=open_im,
|
||||||
patient_name=patient_name,
|
patient_name=patient_name,
|
||||||
patient_case=patient_case,
|
patient_case=patient_case,
|
||||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||||
self._open_video_diagnosis(current_id)
|
self._open_video_diagnosis(current_id)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
LOGGER.exception("video call could not be launched")
|
LOGGER.exception("video call could not be launched")
|
||||||
show_toast(
|
show_toast(
|
||||||
@@ -931,123 +931,123 @@ class ApplicationController(QObject):
|
|||||||
"danger",
|
"danger",
|
||||||
5600,
|
5600,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
self.video_calls[call_key] = call
|
self.video_calls[call_key] = call
|
||||||
qt_window = getattr(call, "qt_window", None)
|
qt_window = getattr(call, "qt_window", None)
|
||||||
if qt_window is not None:
|
if qt_window is not None:
|
||||||
qt_window.destroyed.connect(
|
qt_window.destroyed.connect(
|
||||||
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
|
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
|
||||||
key,
|
key,
|
||||||
expected,
|
expected,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||||
|
|
||||||
shell = self.shell_window
|
shell = self.shell_window
|
||||||
if shell is None or self.current_repository is None:
|
if shell is None or self.current_repository is None:
|
||||||
return
|
return
|
||||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||||
if dialog is None:
|
if dialog is None:
|
||||||
return
|
return
|
||||||
call = self.video_calls.get(str(diagnosis_id))
|
call = self.video_calls.get(str(diagnosis_id))
|
||||||
video_window = getattr(call, "qt_window", None)
|
video_window = getattr(call, "qt_window", None)
|
||||||
if video_window is not None:
|
if video_window is not None:
|
||||||
self._show_video_preview(video_window, dialog)
|
self._show_video_preview(video_window, dialog)
|
||||||
|
|
||||||
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
||||||
"""Pin a compact call window above the modeless diagnosis drawer."""
|
"""Pin a compact call window above the modeless diagnosis drawer."""
|
||||||
|
|
||||||
current = self._video_preview_state
|
current = self._video_preview_state
|
||||||
if current is not None and current.get("window") is not video_window:
|
if current is not None and current.get("window") is not video_window:
|
||||||
self._restore_video_preview(activate=False)
|
self._restore_video_preview(activate=False)
|
||||||
|
|
||||||
self._video_preview_generation += 1
|
self._video_preview_generation += 1
|
||||||
generation = self._video_preview_generation
|
generation = self._video_preview_generation
|
||||||
if current is None or current.get("window") is not video_window:
|
if current is None or current.get("window") is not video_window:
|
||||||
try:
|
try:
|
||||||
state = {
|
state = {
|
||||||
"window": video_window,
|
"window": video_window,
|
||||||
"geometry": video_window.geometry(),
|
"geometry": video_window.geometry(),
|
||||||
"minimum_size": video_window.minimumSize(),
|
"minimum_size": video_window.minimumSize(),
|
||||||
"maximized": video_window.isMaximized(),
|
"maximized": video_window.isMaximized(),
|
||||||
"full_screen": video_window.isFullScreen(),
|
"full_screen": video_window.isFullScreen(),
|
||||||
"stays_on_top": bool(
|
"stays_on_top": bool(
|
||||||
video_window.windowFlags()
|
video_window.windowFlags()
|
||||||
& Qt.WindowType.WindowStaysOnTopHint
|
& Qt.WindowType.WindowStaysOnTopHint
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
||||||
available = screen.availableGeometry()
|
available = screen.availableGeometry()
|
||||||
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
||||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||||
margin = 18
|
margin = 18
|
||||||
|
|
||||||
video_window.showNormal()
|
video_window.showNormal()
|
||||||
video_window.setMinimumSize(440, 300)
|
video_window.setMinimumSize(440, 300)
|
||||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||||
video_window.resize(preview_width, preview_height)
|
video_window.resize(preview_width, preview_height)
|
||||||
video_window.move(
|
video_window.move(
|
||||||
available.x() + available.width() - preview_width - margin,
|
available.x() + available.width() - preview_width - margin,
|
||||||
available.y() + margin,
|
available.y() + margin,
|
||||||
)
|
)
|
||||||
video_window.show()
|
video_window.show()
|
||||||
self._video_preview_state = state
|
self._video_preview_state = state
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
self._video_preview_state = None
|
self._video_preview_state = None
|
||||||
return
|
return
|
||||||
|
|
||||||
dialog.finished.connect(
|
dialog.finished.connect(
|
||||||
lambda _result, expected=generation: self._restore_video_preview(expected)
|
lambda _result, expected=generation: self._restore_video_preview(expected)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
video_window.raise_()
|
video_window.raise_()
|
||||||
dialog.raise_()
|
dialog.raise_()
|
||||||
dialog.activateWindow()
|
dialog.activateWindow()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
self._video_preview_state = None
|
self._video_preview_state = None
|
||||||
|
|
||||||
def _restore_video_preview(
|
def _restore_video_preview(
|
||||||
self,
|
self,
|
||||||
generation: int | None = None,
|
generation: int | None = None,
|
||||||
*,
|
*,
|
||||||
activate: bool = True,
|
activate: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
if generation is not None and generation != self._video_preview_generation:
|
if generation is not None and generation != self._video_preview_generation:
|
||||||
return
|
return
|
||||||
state = self._video_preview_state
|
state = self._video_preview_state
|
||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
self._video_preview_state = None
|
self._video_preview_state = None
|
||||||
self._video_preview_generation += 1
|
self._video_preview_generation += 1
|
||||||
window = state.get("window")
|
window = state.get("window")
|
||||||
try:
|
try:
|
||||||
window.setWindowFlag(
|
window.setWindowFlag(
|
||||||
Qt.WindowType.WindowStaysOnTopHint,
|
Qt.WindowType.WindowStaysOnTopHint,
|
||||||
bool(state.get("stays_on_top")),
|
bool(state.get("stays_on_top")),
|
||||||
)
|
)
|
||||||
window.setMinimumSize(state["minimum_size"])
|
window.setMinimumSize(state["minimum_size"])
|
||||||
window.setGeometry(state["geometry"])
|
window.setGeometry(state["geometry"])
|
||||||
if state.get("full_screen"):
|
if state.get("full_screen"):
|
||||||
window.showFullScreen()
|
window.showFullScreen()
|
||||||
elif state.get("maximized"):
|
elif state.get("maximized"):
|
||||||
window.showMaximized()
|
window.showMaximized()
|
||||||
else:
|
else:
|
||||||
window.showNormal()
|
window.showNormal()
|
||||||
window.raise_()
|
window.raise_()
|
||||||
if activate:
|
if activate:
|
||||||
window.activateWindow()
|
window.activateWindow()
|
||||||
except (AttributeError, RuntimeError):
|
except (AttributeError, RuntimeError):
|
||||||
return
|
return
|
||||||
|
|
||||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||||
preview = self._video_preview_state
|
preview = self._video_preview_state
|
||||||
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
||||||
self._video_preview_state = None
|
self._video_preview_state = None
|
||||||
self._video_preview_generation += 1
|
self._video_preview_generation += 1
|
||||||
if self.video_calls.get(call_key) is call:
|
if self.video_calls.get(call_key) is call:
|
||||||
self.video_calls.pop(call_key, None)
|
self.video_calls.pop(call_key, None)
|
||||||
|
|
||||||
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
||||||
if self.demo_video_dialogs.get(call_key) is dialog:
|
if self.demo_video_dialogs.get(call_key) is dialog:
|
||||||
@@ -1072,30 +1072,30 @@ class ApplicationController(QObject):
|
|||||||
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
|
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
|
||||||
return complete
|
return complete
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _apply_window_icon(window: QWidget) -> None:
|
def _apply_window_icon(window: QWidget) -> None:
|
||||||
icon_file = app_icon_path()
|
icon_file = app_icon_path()
|
||||||
if icon_file.exists():
|
if icon_file.exists():
|
||||||
window.setWindowIcon(QIcon(str(icon_file)))
|
window.setWindowIcon(QIcon(str(icon_file)))
|
||||||
|
|
||||||
def request_quit(self) -> None:
|
def request_quit(self) -> None:
|
||||||
"""Queue a normal application exit so owned resources are released."""
|
"""Queue a normal application exit so owned resources are released."""
|
||||||
|
|
||||||
if self._shutting_down:
|
if self._shutting_down:
|
||||||
return
|
return
|
||||||
QTimer.singleShot(0, self.application.quit)
|
QTimer.singleShot(0, self.application.quit)
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Invalidate asynchronous restoration and release owned resources."""
|
"""Invalidate asynchronous restoration and release owned resources."""
|
||||||
|
|
||||||
if self._shutting_down:
|
if self._shutting_down:
|
||||||
return
|
return
|
||||||
self._shutting_down = True
|
self._shutting_down = True
|
||||||
self._cancel_session_restore()
|
self._cancel_session_restore()
|
||||||
self.app_updater.shutdown()
|
self.app_updater.shutdown()
|
||||||
set_authentication_expired_handler(None)
|
set_authentication_expired_handler(None)
|
||||||
self._restore_video_preview(activate=False)
|
self._restore_video_preview(activate=False)
|
||||||
calls = tuple(self.video_calls.values())
|
calls = tuple(self.video_calls.values())
|
||||||
for call in calls:
|
for call in calls:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
call.close()
|
call.close()
|
||||||
@@ -1167,7 +1167,7 @@ def _create_application(argv: list[str]) -> QApplication:
|
|||||||
application.setOrganizationName("ZhenYangTang")
|
application.setOrganizationName("ZhenYangTang")
|
||||||
application.setOrganizationDomain("zhenyangtang.com")
|
application.setOrganizationDomain("zhenyangtang.com")
|
||||||
application.setQuitOnLastWindowClosed(True)
|
application.setQuitOnLastWindowClosed(True)
|
||||||
icon_file = app_icon_path()
|
icon_file = app_icon_path()
|
||||||
if icon_file.exists():
|
if icon_file.exists():
|
||||||
application.setWindowIcon(QIcon(str(icon_file)))
|
application.setWindowIcon(QIcon(str(icon_file)))
|
||||||
apply_theme(application)
|
apply_theme(application)
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ class DemoDoctorRepository:
|
|||||||
return detail
|
return detail
|
||||||
raise ApiBusinessError("挂号不存在", code=0)
|
raise ApiBusinessError("挂号不存在", code=0)
|
||||||
|
|
||||||
def list_departments(self) -> list[dict[str, Any]]:
|
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||||
"""Return a small demo department tree."""
|
"""Return a small demo department tree."""
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -335,8 +335,8 @@ class DoctorRepository(Protocol):
|
|||||||
def get_appointment_detail(self, appointment_id: int) -> dict[str, Any]:
|
def get_appointment_detail(self, appointment_id: int) -> dict[str, Any]:
|
||||||
"""Return one appointment detail from ``doctor.appointment/detail``."""
|
"""Return one appointment detail from ``doctor.appointment/detail``."""
|
||||||
|
|
||||||
def list_departments(self) -> list[dict[str, Any]]:
|
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||||
"""Return the department tree used by appointment list filters."""
|
"""Return a department tree, optionally scoped to the account's data permissions."""
|
||||||
|
|
||||||
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
||||||
"""Return an editable or permission-aware readonly diagnosis detail."""
|
"""Return an editable or permission-aware readonly diagnosis detail."""
|
||||||
@@ -976,17 +976,20 @@ class RemoteDoctorRepository:
|
|||||||
payload = self.client.get("doctor.appointment/detail", {"id": appointment_id})
|
payload = self.client.get("doctor.appointment/detail", {"id": appointment_id})
|
||||||
return dict(_require_mapping(payload, "doctor.appointment/detail"))
|
return dict(_require_mapping(payload, "doctor.appointment/detail"))
|
||||||
|
|
||||||
def list_departments(self) -> list[dict[str, Any]]:
|
def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
|
||||||
"""Load the department tree through ``dept.dept/all``."""
|
"""Load ``dept.dept/all`` with optional server-enforced role data scope."""
|
||||||
|
|
||||||
payload = self.client.get("dept.dept/all")
|
payload = self.client.get(
|
||||||
if isinstance(payload, list):
|
"dept.dept/all", {"apply_data_scope": 1} if apply_data_scope else None
|
||||||
return [dict(row) for row in payload if isinstance(row, Mapping)]
|
)
|
||||||
if isinstance(payload, Mapping):
|
for _depth in range(5):
|
||||||
rows = payload.get("lists", payload.get("data", payload.get("tree")))
|
if isinstance(payload, list):
|
||||||
if isinstance(rows, list):
|
return [dict(row) for row in payload if isinstance(row, Mapping)]
|
||||||
return [dict(row) for row in rows if isinstance(row, Mapping)]
|
if isinstance(payload, Mapping):
|
||||||
return []
|
payload = payload.get("lists", payload.get("data", payload.get("tree")))
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
raise ApiProtocolError("部门数据格式异常,请重试")
|
||||||
|
|
||||||
def list_reception_queue(
|
def list_reception_queue(
|
||||||
self,
|
self,
|
||||||
@@ -2056,7 +2059,10 @@ class RemoteDoctorRepository:
|
|||||||
) -> PageResult[Consultation]:
|
) -> PageResult[Consultation]:
|
||||||
"""List diagnosis records using ``tcm.diagnosis/lists``."""
|
"""List diagnosis records using ``tcm.diagnosis/lists``."""
|
||||||
|
|
||||||
request_filters = dict(filters)
|
request_filters = dict(filters)
|
||||||
|
department_id = request_filters.pop("department_id", None)
|
||||||
|
if department_id not in (None, ""):
|
||||||
|
request_filters.setdefault("assistant_dept_id", department_id)
|
||||||
start_date = str(request_filters.pop("start_date", "") or "").strip()
|
start_date = str(request_filters.pop("start_date", "") or "").strip()
|
||||||
end_date = str(request_filters.pop("end_date", "") or "").strip()
|
end_date = str(request_filters.pop("end_date", "") or "").strip()
|
||||||
if start_date and start_date == end_date:
|
if start_date and start_date == end_date:
|
||||||
|
|||||||
@@ -55,26 +55,25 @@ from .widgets import (
|
|||||||
APPOINTMENT_DRAWER_QSS = r"""
|
APPOINTMENT_DRAWER_QSS = r"""
|
||||||
QDialog#AppointmentDrawerOverlay {
|
QDialog#AppointmentDrawerOverlay {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
font-size: 14px;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border-left: 1px solid #E6EAF5;
|
border-left: 1px solid #EDEDEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #E6EAF5;
|
border-bottom: 1px solid #EDEDEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
|
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
||||||
@@ -86,14 +85,14 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: #7886AA;
|
color: #606163;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
|
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
|
||||||
color: #4451E2;
|
color: #1A1C1F;
|
||||||
background-color: #F0F2FF;
|
background-color: #F0F0F0;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
|
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
|
||||||
@@ -109,13 +108,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
|
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
|
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
|
||||||
color: #7886AA;
|
color: #606163; font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox,
|
QDialog#AppointmentDrawerOverlay QComboBox,
|
||||||
@@ -123,28 +122,28 @@ QDialog#AppointmentDrawerOverlay QLineEdit,
|
|||||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: 0 11px;
|
padding: 0 11px;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
selection-background-color: #5761F4;
|
selection-background-color: #EEF1FA;
|
||||||
selection-color: #FFFFFF;
|
selection-color: #1A1C1F; font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||||||
padding: 7px 11px;
|
padding: 7px 11px; font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox:hover,
|
QDialog#AppointmentDrawerOverlay QComboBox:hover,
|
||||||
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
|
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
|
||||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
|
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
|
||||||
border-color: #5761F4;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox:focus,
|
QDialog#AppointmentDrawerOverlay QComboBox:focus,
|
||||||
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
|
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
|
||||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
|
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
|
||||||
border: 2px solid #8D9BFF;
|
border: 2px solid #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
||||||
@@ -154,81 +153,81 @@ QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
|||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
|
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
selection-background-color: #5761F4;
|
selection-background-color: #EEF1FA;
|
||||||
selection-color: #FFFFFF;
|
selection-color: #1A1C1F;
|
||||||
outline: 0;
|
outline: 0; font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton {
|
QDialog#AppointmentDrawerOverlay QRadioButton {
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
spacing: 8px;
|
spacing: 8px;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
|
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
|
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
|
||||||
border-color: #5761F4;
|
border-color: #4156C4;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
|
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border: 5px solid #5761F4;
|
border: 5px solid #4F63D9;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
|
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
|
||||||
color: #4451E2;
|
color: #4F63D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
max-height: 38px;
|
max-height: 38px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
border-color: #5761F4;
|
border-color: #4156C4;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
background-color: #5761F4;
|
background-color: #4F63D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
|
||||||
background-color: #F7F9FE;
|
background-color: #F7F7F7;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
|
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
||||||
@@ -238,87 +237,83 @@ QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: #4451E2;
|
color: #4F63D9;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
|
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
|
||||||
min-width: 110px;
|
min-width: 110px;
|
||||||
min-height: 70px;
|
min-height: 70px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: 2px solid #E6EAF5;
|
border: 2px solid #EDEDEE;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #F4F4F5;
|
background-color: #F7F7F7;
|
||||||
color: #7886AA;
|
color: #606163;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
|
||||||
color: #17A77D;
|
color: #287B65;
|
||||||
background-color: #EAF9F3;
|
background-color: #EAF9F3;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
border-color: #5761F4;
|
border-color: #4156C4;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
background: qlineargradient(
|
background: #4F63D9;
|
||||||
x1:0, y1:0, x2:1, y2:1,
|
|
||||||
stop:0 #5761F4,
|
|
||||||
stop:1 #7769F7
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotTime,
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime[slotSelected="true"],
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||||||
background-color: rgba(255, 255, 255, 46);
|
background-color: rgba(255, 255, 255, 46);
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
|
||||||
color: #A4ADC3;
|
color: #8E8F90;
|
||||||
border-color: #E6EAF5;
|
border-color: #EDEDEE;
|
||||||
background-color: #F0F2F8;
|
background-color: #F7F7F7;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotTime,
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime:disabled,
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||||||
color: #A4ADC3;
|
color: #8E8F90;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
|
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||||||
background-color: #F0F2F8;
|
background-color: #F7F7F7;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
||||||
@@ -326,13 +321,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
|
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
|
||||||
color: #7886AA;
|
color: #606163;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
|
||||||
background-color: #F0F4FF;
|
background-color: #EEF1FA;
|
||||||
border: 1px solid #DDE5FF;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,65 +350,65 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
|
||||||
color: #4D69ED;
|
color: #4F63D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
|
||||||
color: #D38625;
|
color: #A9691D;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
|
||||||
color: #F15B67;
|
color: #BE4B58;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
|
||||||
color: #17A77D;
|
color: #287B65;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-top: 1px solid #E6EAF5;
|
border-top: 1px solid #EDEDEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
max-height: 30px;
|
max-height: 30px;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
border-color: #5761F4;
|
border-color: #4156C4;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5761F4, stop:1 #7769F7);
|
background: #4F63D9;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #4C57E9;
|
border-color: #4156C4;
|
||||||
background-color: #4C57E9;
|
background-color: #4156C4;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
border-color: #E6EAF5;
|
border-color: #EDEDEE;
|
||||||
background-color: #A4ADC3;
|
background-color: #8E8F90;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
||||||
@@ -422,7 +417,7 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
|
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
|
||||||
color: #7886AA;
|
color: #606163;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,11 +431,11 @@ QDialog#AppointmentDrawerOverlay QScrollBar:vertical {
|
|||||||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
|
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background-color: #E6EAF5;
|
background-color: #EDEDEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
|
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
|
||||||
background-color: #8D9BFF;
|
background-color: #E4E4E5;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
|
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
|
||||||
@@ -722,15 +717,15 @@ class _EmptyIllustration(QWidget):
|
|||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
painter.setBrush(QColor("#EEF2F8"))
|
painter.setBrush(QColor("#F7F7F7"))
|
||||||
painter.drawEllipse(QRect(9, 48, 62, 8))
|
painter.drawEllipse(QRect(9, 48, 62, 8))
|
||||||
|
|
||||||
painter.setPen(QPen(QColor("#D8DEEA"), 1))
|
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||||||
painter.setBrush(QColor("#FFFFFF"))
|
painter.setBrush(QColor("#FFFFFF"))
|
||||||
painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4)
|
painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4)
|
||||||
painter.setBrush(QColor("#E9EDFF"))
|
painter.setBrush(QColor("#F0F0F0"))
|
||||||
painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4)
|
painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4)
|
||||||
painter.setPen(QPen(QColor("#667085"), 2))
|
painter.setPen(QPen(QColor("#606163"), 2))
|
||||||
painter.drawLine(30, 35, 50, 35)
|
painter.drawLine(30, 35, 50, 35)
|
||||||
painter.drawLine(34, 41, 46, 41)
|
painter.drawLine(34, 41, 46, 41)
|
||||||
painter.end()
|
painter.end()
|
||||||
@@ -751,7 +746,7 @@ class _HoverLiftButton(QPushButton):
|
|||||||
shadow = QGraphicsDropShadowEffect(self)
|
shadow = QGraphicsDropShadowEffect(self)
|
||||||
shadow.setBlurRadius(12)
|
shadow.setBlurRadius(12)
|
||||||
shadow.setOffset(0, 4)
|
shadow.setOffset(0, 4)
|
||||||
shadow.setColor(QColor(102, 117, 245, 72))
|
shadow.setColor(QColor(26, 28, 31, 72))
|
||||||
self.setGraphicsEffect(shadow)
|
self.setGraphicsEffect(shadow)
|
||||||
self._lifted = True
|
self._lifted = True
|
||||||
super().enterEvent(event)
|
super().enterEvent(event)
|
||||||
@@ -784,6 +779,15 @@ class _SlotCard(_HoverLiftButton):
|
|||||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||||
layout.addWidget(self.status_label)
|
layout.addWidget(self.status_label)
|
||||||
|
self.toggled.connect(self._sync_label_selection)
|
||||||
|
|
||||||
|
def _sync_label_selection(self, checked: bool) -> None:
|
||||||
|
# Qt does not reliably resolve ancestor pseudo states for child labels.
|
||||||
|
for label in (self.time_label, self.status_label):
|
||||||
|
label.setProperty("slotSelected", checked)
|
||||||
|
label.style().unpolish(label)
|
||||||
|
label.style().polish(label)
|
||||||
|
label.update()
|
||||||
|
|
||||||
|
|
||||||
class AppointmentDrawer(QDialog):
|
class AppointmentDrawer(QDialog):
|
||||||
@@ -1705,7 +1709,7 @@ class AppointmentDrawer(QDialog):
|
|||||||
|
|
||||||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.fillRect(self.rect(), QColor(8, 11, 20, 196))
|
painter.fillRect(self.rect(), QColor(26, 28, 31, 196))
|
||||||
painter.end()
|
painter.end()
|
||||||
super().paintEvent(event)
|
super().paintEvent(event)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Page-scoped palette and compact typography for the approved appointment list."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
def appointments_stylesheet() -> str:
|
||||||
|
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
_QSS = """
|
||||||
|
#AppointmentsPage { background: #F3F7FD; color: #273244; }
|
||||||
|
#AppointmentsPage QLabel, #AppointmentsPage QPushButton,
|
||||||
|
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox,
|
||||||
|
#AppointmentsPage QTabBar, #AppointmentsPage QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="muted"],
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumb"],
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
|
||||||
|
#AppointmentsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
|
||||||
|
color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QFrame#AppointmentFilterPanel,
|
||||||
|
#AppointmentsPage QFrame#AppointmentMainCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton {
|
||||||
|
min-height: 30px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 6px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
|
||||||
|
#AppointmentsPage QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#AppointmentsPage QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#AppointmentsPage QPushButton[variant="primary"] {
|
||||||
|
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[variant="primary"]:hover { background: #155BCC; }
|
||||||
|
#AppointmentsPage QPushButton[variant="primary"]:pressed { background: #124EA9; }
|
||||||
|
#AppointmentsPage QPushButton[variant="danger"] {
|
||||||
|
color: #B84652; background: #FFFFFF; border-color: #EFC8CE;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[variant="danger"]:hover { background: #FFF0F2; }
|
||||||
|
#AppointmentsPage QPushButton[variant="ghost"] { background: transparent; border-color: transparent; }
|
||||||
|
#AppointmentsPage QPushButton:disabled {
|
||||||
|
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLineEdit, #AppointmentsPage QComboBox {
|
||||||
|
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLineEdit:focus, #AppointmentsPage QComboBox:focus { border-color: #75A5F0; }
|
||||||
|
#AppointmentsPage QLineEdit QToolButton {
|
||||||
|
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLineEdit QToolButton:focus { background: #EAF2FF; }
|
||||||
|
#AppointmentsPage QPushButton#AppointmentSearchButton {
|
||||||
|
min-height: 39px; max-height: 39px; min-width: 52px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLineEdit#AppointmentPatientSearch { min-height: 39px; max-height: 39px; }
|
||||||
|
#AppointmentsPage QPushButton[appointmentStat="true"] {
|
||||||
|
min-height: 34px; max-height: 34px; padding: 0 10px; background: #F4F7FC;
|
||||||
|
border-color: transparent; color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[appointmentStat="true"]:hover { color: #1555B6; background: #EAF2FF; }
|
||||||
|
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
|
||||||
|
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"]:!checked {
|
||||||
|
color: #9C681F; background: #FFF6E7; border-color: #EEDCBF;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLabel#FilterRowLabel { color: #5D6B80; font-size: 13px; }
|
||||||
|
#AppointmentsPage QLabel#FilterDivider { color: #DBE5F2; padding: 0 10px; }
|
||||||
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
|
||||||
|
min-width: 52px; min-height: 32px; padding: 0 12px; color: #5D6B80;
|
||||||
|
background: transparent; border: 0; border-bottom: 2px solid transparent; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover { color: #1555B6; background: #F7FAFE; }
|
||||||
|
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
|
||||||
|
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[filterChoice="true"] {
|
||||||
|
min-height: 30px; max-height: 30px; padding: 0 12px; color: #5D6B80;
|
||||||
|
background: transparent; border-color: transparent; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[filterChoice="true"]:checked { color: #1555B6; background: #EAF2FF; }
|
||||||
|
#AppointmentsPage QFrame#AppointmentToolbar { background: transparent; border: 0; }
|
||||||
|
#AppointmentsPage QPushButton[compactAction="true"] {
|
||||||
|
min-height: 36px; max-height: 36px; padding: 0 17px; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QFrame#AppointmentToolbar QPushButton[variant="secondary"]:enabled {
|
||||||
|
color: #1769E8; border-color: #ADC8F0;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QTableWidget#AppointmentTable {
|
||||||
|
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||||
|
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QTableWidget#AppointmentTable::item { padding: 0; border: 0; border-bottom: 1px solid #E6EDF6; }
|
||||||
|
#AppointmentsPage QTableWidget#AppointmentTable::item:selected { background: #EAF2FF; color: #273244; }
|
||||||
|
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
|
||||||
|
min-height: 40px; padding: 0; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-top: 1px solid #E1E9F4; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget[appointmentSelectionHost="true"] {
|
||||||
|
background: transparent; border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget[appointmentSelectionHost="true"][selected="true"] { border-left-color: #1769E8; }
|
||||||
|
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
|
||||||
|
width: 16px; height: 16px; background: #FFFFFF; border: 1px solid #C8D5E6; border-radius: 3px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||||
|
#AppointmentsPage QWidget[appointmentInfoHost="true"],
|
||||||
|
#AppointmentsPage QWidget[appointmentImHost="true"] { background: transparent; }
|
||||||
|
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
|
||||||
|
min-height: 20px; max-height: 20px; padding: 0 6px; color: #1555B6;
|
||||||
|
background: #EAF2FF; border-radius: 4px; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] { color: #9C681F; background: #FFF3DD; }
|
||||||
|
#AppointmentsPage QLabel[tableAppointmentStatusKind="muted"] { color: #66758A; background: #EEF2F7; }
|
||||||
|
#AppointmentsPage QLabel[tableAppointmentMeta="true"] { color: #5D6B80; font-size: 13px; }
|
||||||
|
#AppointmentsPage QPushButton[tableCancelAction="true"] {
|
||||||
|
min-height: 20px; max-height: 20px; padding: 0 5px; color: #B84652;
|
||||||
|
background: #FFF0F2; border: 0; border-radius: 4px; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[appointmentImAction="true"] {
|
||||||
|
min-width: 62px; min-height: 32px; max-height: 32px; padding: 0 10px;
|
||||||
|
color: #1555B6; background: #EAF2FF; border-color: #C9DCF7; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover { color: #FFFFFF; background: #1769E8; }
|
||||||
|
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled { color: #8A97A9; background: #F3F6FB; border-color: #DFE7F2; }
|
||||||
|
#AppointmentsPage QWidget#Pager { background: #FFFFFF; border: 0; border-top: 1px solid #E1E9F4; }
|
||||||
|
#AppointmentsPage QWidget#Pager QLabel { color: #5D6B80; font-size: 13px; border: 0; }
|
||||||
|
#AppointmentsPage QWidget#Pager QPushButton {
|
||||||
|
min-width: 30px; max-width: 30px; min-height: 32px; max-height: 32px;
|
||||||
|
padding: 0; border: 1px solid #DBE5F2; background: #FFFFFF; color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#AppointmentsPage QWidget#Pager QPushButton[active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||||
|
#AppointmentsPage QWidget#Pager QPushButton:disabled { color: #9AA6B7; background: #F7F9FC; }
|
||||||
|
"""
|
||||||
@@ -46,51 +46,51 @@ QFrame#ChatNotifyCard {
|
|||||||
border: 1px solid #BBF0CE;
|
border: 1px solid #BBF0CE;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
QFrame#ChatNotifyCard[kind="left"] { border-color: #D8DEEE; }
|
QFrame#ChatNotifyCard[kind="left"] { border-color: #E4E4E5; }
|
||||||
QFrame#ChatNotifyCard[kind="complete"] { border-color: #C3D6FF; }
|
QFrame#ChatNotifyCard[kind="complete"] { border-color: #8B9AD9; }
|
||||||
QLabel#ChatNotifyBadge {
|
QLabel#ChatNotifyBadge {
|
||||||
min-width: 34px;
|
min-width: 34px;
|
||||||
max-width: 34px;
|
max-width: 34px;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
max-height: 34px;
|
max-height: 34px;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background-color: #22C55E;
|
background-color: #287B65;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
QLabel#ChatNotifyBadge[kind="left"] { background-color: #8A94B3; }
|
QLabel#ChatNotifyBadge[kind="left"] { background-color: #6A6B6D; }
|
||||||
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #3B82F6; }
|
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #4F63D9; }
|
||||||
QLabel#ChatNotifyTitle { color: #1F2A44; font-size: 13px; font-weight: 700; }
|
QLabel#ChatNotifyTitle { color: #1A1C1F; font-size: 13px; font-weight: 700; }
|
||||||
QLabel#ChatNotifyDesc { color: #4A5878; font-size: 12px; }
|
QLabel#ChatNotifyDesc { color: #1A1C1F; font-size: 12px; }
|
||||||
QLabel#ChatNotifyTime { color: #8A94B3; font-size: 11px; }
|
QLabel#ChatNotifyTime { color: #6A6B6D; font-size: 11px; }
|
||||||
QPushButton#ChatNotifyOpen {
|
QPushButton#ChatNotifyOpen {
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
color: #3F4E75;
|
color: #4F63D9;
|
||||||
background-color: #F4F6FC;
|
background-color: #EEF1FA;
|
||||||
border: 1px solid #DDE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QPushButton#ChatNotifyOpen:hover {
|
QPushButton#ChatNotifyOpen:hover {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
background-color: #EEF1FF;
|
background-color: #EEF1FA;
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
QPushButton#ChatNotifyClose {
|
QPushButton#ChatNotifyClose {
|
||||||
min-width: 22px;
|
min-width: 22px;
|
||||||
max-width: 22px;
|
max-width: 22px;
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
max-height: 22px;
|
max-height: 22px;
|
||||||
color: #8A94B3;
|
color: #6A6B6D;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
QPushButton#ChatNotifyClose:hover { color: #4A5878; background-color: #EDF0F7; }
|
QPushButton#ChatNotifyClose:hover { color: #1A1C1F; background-color: #F7F7F7; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Scoped colors and compact typography for the approved consultation list."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
def consultations_stylesheet() -> str:
|
||||||
|
return Template(_QSS).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
_QSS = """
|
||||||
|
#DiagnosisIndex, #DiagnosisIndexContent, #DiagnosisPageScroll {
|
||||||
|
background: #F3F7FD; color: #273244; border: 0;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QLabel, #DiagnosisIndex QPushButton, #DiagnosisIndex QToolButton,
|
||||||
|
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit,
|
||||||
|
#DiagnosisIndex QSpinBox, #DiagnosisIndex QTableView {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QLabel[role="muted"], #DiagnosisIndex QLabel[role="breadcrumb"],
|
||||||
|
#DiagnosisIndex QLabel[role="breadcrumbCurrent"], #DiagnosisIndex QLabel[role="breadcrumbSeparator"],
|
||||||
|
#DiagnosisIndex QLabel[filterGroup="true"], #DiagnosisIndex QLabel[pagerMuted="true"] {
|
||||||
|
color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisFilterCard, #DiagnosisIndex QFrame#DiagnosisListCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisStatusCard, #DiagnosisIndex QFrame#DiagnosisQuickFilters,
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisListToolbar { background: transparent; border: 0; }
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisAdvancedFilters {
|
||||||
|
background: transparent; border: 0; border-top: 1px solid #E6EDF6; border-radius: 0;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QPushButton {
|
||||||
|
min-height: 34px; padding: 0 13px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 6px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QPushButton:hover { color: #1555B6; background: #F2F7FF; border-color: #B6CDEE; }
|
||||||
|
#DiagnosisIndex QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#DiagnosisIndex QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#DiagnosisIndex QPushButton[variant="primary"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||||
|
#DiagnosisIndex QPushButton[variant="primary"]:hover { background: #155BCC; }
|
||||||
|
#DiagnosisIndex QPushButton[consultationTool="true"] { color: #1555B6; border-color: #C2D5EF; }
|
||||||
|
#DiagnosisIndex QPushButton[consultationDanger="true"] { color: #BE4B58; border-color: #EBCDD2; }
|
||||||
|
#DiagnosisIndex QPushButton:disabled, #DiagnosisIndex QPushButton[consultationDanger="true"]:disabled {
|
||||||
|
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QLineEdit, #DiagnosisIndex QComboBox, #DiagnosisIndex QDateEdit, #DiagnosisIndex QSpinBox {
|
||||||
|
min-height: 32px; padding: 0 10px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 6px; selection-background-color: #DCEAFF;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QLineEdit:focus, #DiagnosisIndex QComboBox:focus,
|
||||||
|
#DiagnosisIndex QDateEdit:focus, #DiagnosisIndex QSpinBox:focus { border-color: #75A5F0; }
|
||||||
|
#DiagnosisIndex QLineEdit QToolButton {
|
||||||
|
min-width: 0; min-height: 0; padding: 0; border: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QComboBox::drop-down, #DiagnosisIndex QDateEdit::drop-down {
|
||||||
|
width: 22px; border: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QComboBox QAbstractItemView {
|
||||||
|
color: #273244; background: #FFFFFF; border: 1px solid #DBE5F2;
|
||||||
|
selection-background-color: #EAF2FF; selection-color: #1555B6; outline: 0;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit,
|
||||||
|
#DiagnosisIndex QWidget#DiagnosisStatusSearch QPushButton { min-height: 39px; max-height: 39px; }
|
||||||
|
#DiagnosisIndex QToolButton { min-width: 0; min-height: 0; border: 0; padding: 0; background: transparent; }
|
||||||
|
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
|
||||||
|
min-height: 32px; max-height: 32px; padding: 0 14px; border: 1px solid transparent;
|
||||||
|
border-radius: 5px; color: #5D6B80; background: transparent; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[diagnosisChip="true"]:hover { background: #F2F7FF; color: #1555B6; }
|
||||||
|
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked { background: #1769E8; color: #FFFFFF; }
|
||||||
|
#DiagnosisIndex QToolButton[dateChoice="true"] { padding: 0 17px; border-color: #E1E9F4; }
|
||||||
|
#DiagnosisIndex QToolButton[dateChoice="true"]:checked { border-color: #1769E8; }
|
||||||
|
#DiagnosisIndex QToolButton[statusTab="true"] {
|
||||||
|
min-height: 44px; max-height: 44px; padding: 0 20px; border: 0;
|
||||||
|
border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[statusTab="true"]:checked {
|
||||||
|
color: #1769E8; background: transparent; border-bottom-color: #1769E8;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton#DiagnosisMoreFilter {
|
||||||
|
min-height: 32px; padding: 0 6px; color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton#DiagnosisMoreFilter:hover { color: #1769E8; background: #F2F7FF; }
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisFilterDivider { min-width: 1px; max-width: 1px; min-height: 20px; background: #E1E9F4; border: 0; }
|
||||||
|
#DiagnosisIndex QFrame#DiagnosisDateRange {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 6px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QDateEdit[diagnosisRangePart="true"] { min-height: 30px; padding: 0 4px; border: 0; }
|
||||||
|
#DiagnosisIndex QTableView {
|
||||||
|
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||||
|
gridline-color: #E6EDF6; selection-background-color: #EAF2FF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QTableView QHeaderView::section {
|
||||||
|
min-height: 41px; padding: 0; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[rowLink], #DiagnosisIndex QToolButton[appointmentCancel="true"] {
|
||||||
|
color: #1769E8; min-height: 26px; padding: 0 4px; border: 0; background: transparent; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[appointmentCancel="true"] { color: #BE4B58; }
|
||||||
|
#DiagnosisIndex QToolButton[rowLink]:hover { color: #1555B6; background: #DCEAFF; border-radius: 4px; }
|
||||||
|
#DiagnosisIndex QToolButton[rowLink="muted"], #DiagnosisIndex QLabel[fixedMuted="true"] {
|
||||||
|
color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton#DiagnosisRowMore { padding-right: 18px; }
|
||||||
|
#DiagnosisIndex QWidget#DiagnosisFixedCell { background: transparent; }
|
||||||
|
#DiagnosisIndex QLabel#DiagnosisTableEmpty { color: #5D6B80; background: #FFFFFF; }
|
||||||
|
#DiagnosisIndex QLabel#DiagnosisTableEmpty[stateKind="error"] { color: #BE4B58; }
|
||||||
|
#DiagnosisIndex QTableView#DiagnosisFixedTable { border-left: 1px solid #E1E9F4; }
|
||||||
|
#DiagnosisIndex QWidget#DiagnosisPager { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#DiagnosisIndex QToolButton[pagerButton="true"] {
|
||||||
|
min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 5px; color: #5D6B80; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] { color: #FFFFFF; background: #1769E8; border-color: #1769E8; }
|
||||||
|
#DiagnosisIndex QToolButton[pagerButton="true"]:disabled { color: #A4B0C0; background: #F6F8FC; }
|
||||||
|
#DiagnosisIndex QComboBox#DiagnosisPageSize { min-width: 95px; min-height: 30px; }
|
||||||
|
#DiagnosisIndex QSpinBox#DiagnosisPageJumper { min-height: 30px; padding: 0 8px; }
|
||||||
|
#DiagnosisIndex QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#DiagnosisIndex QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#DiagnosisIndex QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#DiagnosisIndex QScrollBar::add-line, #DiagnosisIndex QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#DiagnosisIndex QScrollBar::add-page, #DiagnosisIndex QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -134,63 +134,63 @@ def open_safe_http_url(target: str) -> bool:
|
|||||||
_INLINE_PLAYER_QSS = """
|
_INLINE_PLAYER_QSS = """
|
||||||
QWidget#DiagnosisInlineRecordingPlayer {
|
QWidget#DiagnosisInlineRecordingPlayer {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
QFrame#DiagnosisInlineRecordingSurface {
|
QFrame#DiagnosisInlineRecordingSurface {
|
||||||
background: #11182E;
|
background: #1A1C1F;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
}
|
}
|
||||||
QLabel#DiagnosisInlineRecordingPlaceholder {
|
QLabel#DiagnosisInlineRecordingPlaceholder {
|
||||||
color: #C7D0E8;
|
color: #E4E4E5;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
QLabel#DiagnosisInlineRecordingTime {
|
QLabel#DiagnosisInlineRecordingTime {
|
||||||
color: #64739A;
|
color: #6A6B6D;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
QPushButton[recordingControl="true"] {
|
QPushButton[recordingControl="true"] {
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
max-height: 24px;
|
max-height: 24px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
background: #FAFBFE;
|
background: #F7F7F7;
|
||||||
border: 1px solid #D8DEEE;
|
border: 1px solid #E4E4E5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QPushButton[recordingControl="true"]:hover,
|
QPushButton[recordingControl="true"]:hover,
|
||||||
QPushButton[recordingControl="true"]:focus {
|
QPushButton[recordingControl="true"]:focus {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
background: #F0F2FF;
|
background: #EEF1FA;
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
QPushButton#DiagnosisInlineRecordingPlay {
|
QPushButton#DiagnosisInlineRecordingPlay {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background: #5761F4;
|
background: #4F63D9;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
}
|
}
|
||||||
QPushButton#DiagnosisInlineRecordingPlay:hover,
|
QPushButton#DiagnosisInlineRecordingPlay:hover,
|
||||||
QPushButton#DiagnosisInlineRecordingPlay:focus {
|
QPushButton#DiagnosisInlineRecordingPlay:focus {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background: #4C57E9;
|
background: #4156C4;
|
||||||
border-color: #4C57E9;
|
border-color: #4156C4;
|
||||||
}
|
}
|
||||||
QPushButton[recordingControl="true"]:disabled {
|
QPushButton[recordingControl="true"]:disabled {
|
||||||
color: #A4ADC3;
|
color: #8E8F90;
|
||||||
background: #F0F2F8;
|
background: #F7F7F7;
|
||||||
border-color: #E6EAF5;
|
border-color: #EDEDEE;
|
||||||
}
|
}
|
||||||
QSlider::groove:horizontal { height: 3px; background: #D8DEEE; border-radius: 1px; }
|
QSlider::groove:horizontal { height: 3px; background: #E4E4E5; border-radius: 1px; }
|
||||||
QSlider::sub-page:horizontal { background: #5761F4; border-radius: 1px; }
|
QSlider::sub-page:horizontal { background: #4F63D9; border-radius: 1px; }
|
||||||
QSlider::handle:horizontal {
|
QSlider::handle:horizontal {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
margin: -4px 0;
|
margin: -4px 0;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #5761F4;
|
border: 1px solid #4F63D9;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@@ -466,11 +466,11 @@ class RecordingPlaybackCell(QWidget):
|
|||||||
separator = QFrame()
|
separator = QFrame()
|
||||||
separator.setObjectName("DiagnosisRecordingAlternateSeparator")
|
separator.setObjectName("DiagnosisRecordingAlternateSeparator")
|
||||||
separator.setFrameShape(QFrame.Shape.HLine)
|
separator.setFrameShape(QFrame.Shape.HLine)
|
||||||
separator.setStyleSheet("color:#E6EAF5;")
|
separator.setStyleSheet("color:#EDEDEE;")
|
||||||
layout.addWidget(separator)
|
layout.addWidget(separator)
|
||||||
label = QLabel("备用地址")
|
label = QLabel("备用地址")
|
||||||
label.setObjectName("DiagnosisRecordingAlternateLabel")
|
label.setObjectName("DiagnosisRecordingAlternateLabel")
|
||||||
label.setStyleSheet("color:#7886AA; font-size:12px;")
|
label.setStyleSheet("color:#606163; font-size:12px;")
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
links = QHBoxLayout()
|
links = QHBoxLayout()
|
||||||
links.setContentsMargins(0, 0, 0, 0)
|
links.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -490,7 +490,7 @@ class RecordingPlaybackCell(QWidget):
|
|||||||
layout.addLayout(links)
|
layout.addLayout(links)
|
||||||
self.link_status = QLabel("")
|
self.link_status = QLabel("")
|
||||||
self.link_status.setObjectName("DiagnosisRecordingLinkStatus")
|
self.link_status.setObjectName("DiagnosisRecordingLinkStatus")
|
||||||
self.link_status.setStyleSheet("color:#D94856; font-size:11px;")
|
self.link_status.setStyleSheet("color:#BE4B58; font-size:11px;")
|
||||||
self.link_status.setWordWrap(True)
|
self.link_status.setWordWrap(True)
|
||||||
self.link_status.hide()
|
self.link_status.hide()
|
||||||
layout.addWidget(self.link_status)
|
layout.addWidget(self.link_status)
|
||||||
@@ -722,41 +722,41 @@ def image_display_name(target: str, ordinal: int) -> str:
|
|||||||
|
|
||||||
_IMAGE_PREVIEW_QSS = """
|
_IMAGE_PREVIEW_QSS = """
|
||||||
QDialog#DiagnosisImagePreview { background: #FFFFFF; }
|
QDialog#DiagnosisImagePreview { background: #FFFFFF; }
|
||||||
QLabel#DiagnosisImagePreviewName { color: #1F2A44; font-size: 14px; font-weight: 600; }
|
QLabel#DiagnosisImagePreviewName { color: #1A1C1F; font-size: 14px; font-weight: 600; }
|
||||||
QLabel#DiagnosisImagePreviewCounter { color: #64739A; font-size: 12px; }
|
QLabel#DiagnosisImagePreviewCounter { color: #6A6B6D; font-size: 12px; }
|
||||||
QLabel#DiagnosisImagePreviewStatus { color: #64739A; font-size: 12px; }
|
QLabel#DiagnosisImagePreviewStatus { color: #6A6B6D; font-size: 12px; }
|
||||||
QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #C0392B; }
|
QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #BE4B58; }
|
||||||
QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #9A650F; }
|
QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #A9691D; }
|
||||||
QScrollArea#DiagnosisImagePreviewViewport {
|
QScrollArea#DiagnosisImagePreviewViewport {
|
||||||
background: #11182E;
|
background: #1A1C1F;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
QLabel#DiagnosisImagePreviewCanvas {
|
QLabel#DiagnosisImagePreviewCanvas {
|
||||||
background: #11182E;
|
background: #1A1C1F;
|
||||||
color: #C7D0E8;
|
color: #E4E4E5;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
QPushButton[imagePreviewControl="true"] {
|
QPushButton[imagePreviewControl="true"] {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
background: #FAFBFE;
|
background: #F7F7F7;
|
||||||
border: 1px solid #D8DEEE;
|
border: 1px solid #E4E4E5;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QPushButton[imagePreviewControl="true"]:hover,
|
QPushButton[imagePreviewControl="true"]:hover,
|
||||||
QPushButton[imagePreviewControl="true"]:focus {
|
QPushButton[imagePreviewControl="true"]:focus {
|
||||||
color: #4451E2;
|
color: #4156C4;
|
||||||
background: #F0F2FF;
|
background: #EEF1FA;
|
||||||
border-color: #8D9BFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
QPushButton[imagePreviewControl="true"]:disabled {
|
QPushButton[imagePreviewControl="true"]:disabled {
|
||||||
color: #A4ADC3;
|
color: #8E8F90;
|
||||||
background: #F0F2F8;
|
background: #F7F7F7;
|
||||||
border-color: #E6EAF5;
|
border-color: #EDEDEE;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -1103,6 +1103,11 @@ class ImagePreviewDialog(QDialog):
|
|||||||
self._invalidate_request()
|
self._invalidate_request()
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
def done(self, result: int) -> None:
|
||||||
|
# QDialog.reject() (including Escape) bypasses closeEvent.
|
||||||
|
self._invalidate_request()
|
||||||
|
super().done(result)
|
||||||
|
|
||||||
|
|
||||||
def _clock(milliseconds: int) -> str:
|
def _clock(milliseconds: int) -> str:
|
||||||
seconds = max(0, int(milliseconds) // 1000)
|
seconds = max(0, int(milliseconds) // 1000)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,13 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..infinite_list import InfiniteList
|
||||||
from ..theme import mark_business_dialog
|
from ..theme import mark_business_dialog
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
BusyOverlay,
|
BusyOverlay,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
MessageBanner,
|
MessageBanner,
|
||||||
OverlayHost,
|
OverlayHost,
|
||||||
Pager,
|
|
||||||
SortableTable,
|
SortableTable,
|
||||||
TableColumn,
|
TableColumn,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -33,7 +33,6 @@ from ..widgets import (
|
|||||||
get_value,
|
get_value,
|
||||||
invoke,
|
invoke,
|
||||||
page_items,
|
page_items,
|
||||||
page_total,
|
|
||||||
run_async,
|
run_async,
|
||||||
)
|
)
|
||||||
from .ai_consult import can_open_ai_consult, present_ai_consult
|
from .ai_consult import can_open_ai_consult, present_ai_consult
|
||||||
@@ -220,8 +219,8 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
self.body.busy_overlay = self.busy_overlay
|
self.body.busy_overlay = self.busy_overlay
|
||||||
root.addWidget(self.body, 1)
|
root.addWidget(self.body, 1)
|
||||||
|
|
||||||
self.pager = Pager(self.PAGE_SIZE, self)
|
self.pager = InfiniteList(self.PAGE_SIZE, self)
|
||||||
self.pager.page_changed.connect(self._change_page)
|
self.pager.bind(self.table)
|
||||||
root.addWidget(self.pager)
|
root.addWidget(self.pager)
|
||||||
|
|
||||||
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
|
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
|
||||||
@@ -266,42 +265,41 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
self.load(1)
|
self.load(1)
|
||||||
|
|
||||||
def retry(self) -> None:
|
def retry(self) -> None:
|
||||||
self.load(self._page)
|
self.load(1)
|
||||||
|
|
||||||
def _change_page(self, page: int) -> None:
|
|
||||||
self.load(page)
|
|
||||||
|
|
||||||
def load(self, page: int) -> None:
|
def load(self, page: int) -> None:
|
||||||
if not self._active:
|
if not self._active:
|
||||||
return
|
return
|
||||||
self._page = max(1, int(page))
|
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
generation = self._generation
|
generation = self._generation
|
||||||
page_snapshot = self._page
|
|
||||||
keyword_snapshot = self.search_edit.text().strip()
|
keyword_snapshot = self.search_edit.text().strip()
|
||||||
self._invalidate_selection()
|
if keyword_snapshot != getattr(self, "_loaded_keyword", None):
|
||||||
self._set_loading(True)
|
self._invalidate_selection()
|
||||||
|
self._loaded_keyword = keyword_snapshot
|
||||||
|
self._set_loading(not self.pager.rows)
|
||||||
self.banner.clear()
|
self.banner.clear()
|
||||||
|
|
||||||
run_async(
|
self.pager.reload(
|
||||||
lambda: invoke(
|
lambda requested_page: invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
"list_ai_patient_options",
|
"list_ai_patient_options",
|
||||||
page_no=page_snapshot,
|
page_no=requested_page,
|
||||||
page_size=self.PAGE_SIZE,
|
page_size=self.PAGE_SIZE,
|
||||||
keyword=keyword_snapshot,
|
keyword=keyword_snapshot,
|
||||||
),
|
),
|
||||||
on_success=lambda result: self._apply_result(
|
apply=lambda result: self._apply_result(
|
||||||
result, generation, page_snapshot, keyword_snapshot
|
result, generation, keyword_snapshot
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._apply_error(error, generation),
|
on_error=lambda error: self._apply_error(error, generation),
|
||||||
|
runner=run_async,
|
||||||
|
query_key=(keyword_snapshot,),
|
||||||
|
on_finished=lambda: self._finish_loading(generation),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_result(
|
def _apply_result(
|
||||||
self,
|
self,
|
||||||
result: Any,
|
result: Any,
|
||||||
generation: int,
|
generation: int,
|
||||||
page_snapshot: int,
|
|
||||||
keyword_snapshot: str,
|
keyword_snapshot: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not self._is_current(generation):
|
if not self._is_current(generation):
|
||||||
@@ -316,36 +314,32 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
for target in (AiConsultTarget.from_row(row) for row in page_items(result))
|
for target in (AiConsultTarget.from_row(row) for row in page_items(result))
|
||||||
if target is not None
|
if target is not None
|
||||||
]
|
]
|
||||||
total = max(0, page_total(result, len(targets)))
|
|
||||||
page_count = max(1, (total + self.PAGE_SIZE - 1) // self.PAGE_SIZE)
|
|
||||||
if page_snapshot > page_count:
|
|
||||||
self.load(page_count)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._page = page_snapshot
|
|
||||||
self.table.set_rows(targets)
|
self.table.set_rows(targets)
|
||||||
self.table.setSortingEnabled(False)
|
self.table.setSortingEnabled(False)
|
||||||
self.table.clearSelection()
|
self._page = self.pager.page
|
||||||
self.pager.update_state(page_snapshot, total)
|
self.empty_state.setVisible(not targets and not self.pager.has_more)
|
||||||
self.empty_state.setVisible(not targets)
|
self.table.setVisible(bool(targets) or self.pager.has_more)
|
||||||
self.table.setVisible(bool(targets))
|
|
||||||
self.banner.clear()
|
self.banner.clear()
|
||||||
self._set_loading(False)
|
self._set_loading(False)
|
||||||
|
|
||||||
def _apply_error(self, error: Exception, generation: int) -> None:
|
def _apply_error(self, error: Exception, generation: int) -> None:
|
||||||
if not self._is_current(generation):
|
if not self._is_current(generation):
|
||||||
return
|
return
|
||||||
self.table.set_rows(())
|
if not self.pager.rows:
|
||||||
self.table.setSortingEnabled(False)
|
self.table.set_rows(())
|
||||||
self.table.clearSelection()
|
self.table.setSortingEnabled(False)
|
||||||
self.table.hide()
|
self.table.clearSelection()
|
||||||
self.empty_state.show()
|
self.table.hide()
|
||||||
self.pager.update_state(1, 0)
|
self.empty_state.show()
|
||||||
self.banner.show_message(
|
self.banner.show_message(
|
||||||
f"患者诊单加载失败:{friendly_error(error)}", "danger"
|
f"患者诊单加载失败:{friendly_error(error)}", "danger"
|
||||||
)
|
)
|
||||||
self._set_loading(False)
|
self._set_loading(False)
|
||||||
|
|
||||||
|
def _finish_loading(self, generation: int) -> None:
|
||||||
|
if self._is_current(generation):
|
||||||
|
self._set_loading(False)
|
||||||
|
|
||||||
def _is_current(self, generation: int) -> bool:
|
def _is_current(self, generation: int) -> bool:
|
||||||
return self._active and generation == self._generation
|
return self._active and generation == self._generation
|
||||||
|
|
||||||
@@ -365,7 +359,6 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
def _set_loading(self, loading: bool) -> None:
|
def _set_loading(self, loading: bool) -> None:
|
||||||
self._loading = loading
|
self._loading = loading
|
||||||
self.table.setEnabled(not loading)
|
self.table.setEnabled(not loading)
|
||||||
self.pager.setEnabled(not loading)
|
|
||||||
self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0)
|
self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0)
|
||||||
self.busy_overlay.setVisible(loading)
|
self.busy_overlay.setVisible(loading)
|
||||||
if loading:
|
if loading:
|
||||||
@@ -383,6 +376,7 @@ class AiConsultTargetDialog(QDialog):
|
|||||||
def done(self, result: int) -> None:
|
def done(self, result: int) -> None:
|
||||||
self._active = False
|
self._active = False
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
|
self.pager.invalidate()
|
||||||
self._search_timer.stop()
|
self._search_timer.stop()
|
||||||
super().done(result)
|
super().done(result)
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ class AppUpdateDialog(QDialog):
|
|||||||
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
|
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
|
||||||
self.badge.setObjectName("UpdateBadge")
|
self.badge.setObjectName("UpdateBadge")
|
||||||
self.badge.setStyleSheet(
|
self.badge.setStyleSheet(
|
||||||
"color:#B45309;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
|
"color:#A9691D;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||||
if offer.force
|
if offer.force
|
||||||
else "color:#4451E2;background:#F0F2FF;border-radius:8px;padding:4px 10px;font-weight:600;"
|
else "color:#4F63D9;background:#EEF1FA;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||||
)
|
)
|
||||||
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
|
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
latest = offer.latest_version or "新版本"
|
latest = offer.latest_version or "新版本"
|
||||||
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
|
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
|
||||||
self.version_label.setObjectName("UpdateVersionLabel")
|
self.version_label.setObjectName("UpdateVersionLabel")
|
||||||
self.version_label.setStyleSheet("color:#7886AA;")
|
self.version_label.setStyleSheet("color:#606163;")
|
||||||
root.addWidget(self.version_label)
|
root.addWidget(self.version_label)
|
||||||
|
|
||||||
self.notes = QTextEdit()
|
self.notes = QTextEdit()
|
||||||
@@ -160,7 +160,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
self.status_label = QLabel("")
|
self.status_label = QLabel("")
|
||||||
self.status_label.setObjectName("UpdateStatus")
|
self.status_label.setObjectName("UpdateStatus")
|
||||||
self.status_label.setWordWrap(True)
|
self.status_label.setWordWrap(True)
|
||||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||||
self.status_label.hide()
|
self.status_label.hide()
|
||||||
root.addWidget(self.status_label)
|
root.addWidget(self.status_label)
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
|
|
||||||
self.progress_text = QLabel("")
|
self.progress_text = QLabel("")
|
||||||
self.progress_text.setObjectName("UpdateProgressText")
|
self.progress_text.setObjectName("UpdateProgressText")
|
||||||
self.progress_text.setStyleSheet("color:#7886AA;font-size:12px;")
|
self.progress_text.setStyleSheet("color:#606163;font-size:12px;")
|
||||||
self.progress_text.hide()
|
self.progress_text.hide()
|
||||||
root.addWidget(self.progress_text)
|
root.addWidget(self.progress_text)
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
def show_download_progress(self, received: int, total: int) -> None:
|
def show_download_progress(self, received: int, total: int) -> None:
|
||||||
self.progress.show()
|
self.progress.show()
|
||||||
self.progress_text.show()
|
self.progress_text.show()
|
||||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||||
self.status_label.setText("正在下载安装包…")
|
self.status_label.setText("正在下载安装包…")
|
||||||
self.status_label.show()
|
self.status_label.show()
|
||||||
if total > 0:
|
if total > 0:
|
||||||
@@ -237,7 +237,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
self.progress_text.setText(_format_bytes(received))
|
self.progress_text.setText(_format_bytes(received))
|
||||||
|
|
||||||
def show_status(self, message: str, *, determinate: bool = False) -> None:
|
def show_status(self, message: str, *, determinate: bool = False) -> None:
|
||||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
self.status_label.show()
|
self.status_label.show()
|
||||||
self.progress.show()
|
self.progress.show()
|
||||||
@@ -251,7 +251,7 @@ class AppUpdateDialog(QDialog):
|
|||||||
def show_error(self, message: str) -> None:
|
def show_error(self, message: str) -> None:
|
||||||
self._busy = False
|
self._busy = False
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
self.status_label.setStyleSheet("color:#F15B67;")
|
self.status_label.setStyleSheet("color:#BE4B58;")
|
||||||
self.status_label.show()
|
self.status_label.show()
|
||||||
self.progress.hide()
|
self.progress.hide()
|
||||||
self.progress_text.hide()
|
self.progress_text.hide()
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ from ..diagnosis_drawer import (
|
|||||||
set_tag_item,
|
set_tag_item,
|
||||||
)
|
)
|
||||||
from ..diagnosis_editors import DailyRecordEditorDialog
|
from ..diagnosis_editors import DailyRecordEditorDialog
|
||||||
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
|
from ..diagnosis_media import (
|
||||||
|
ImagePreviewDialog,
|
||||||
|
RecordingPlaybackCell,
|
||||||
|
RecordingPlayerDialog,
|
||||||
|
safe_image_sources,
|
||||||
|
)
|
||||||
|
from ..infinite_list import InfiniteList
|
||||||
from ..widgets import (
|
from ..widgets import (
|
||||||
display_text,
|
display_text,
|
||||||
first_value,
|
first_value,
|
||||||
@@ -190,74 +196,74 @@ _ORDER_OFFSET_HELP = (
|
|||||||
|
|
||||||
_ORDER_DETAIL_QSS = """
|
_ORDER_DETAIL_QSS = """
|
||||||
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
|
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
|
||||||
QFrame#DiagnosisOrderDetailScrim { background: rgba(30, 64, 175, 0.18); border: 0; }
|
QFrame#DiagnosisOrderDetailScrim { background: rgba(26, 28, 31, 0.18); border: 0; }
|
||||||
QFrame#DiagnosisOrderDetailDrawer {
|
QFrame#DiagnosisOrderDetailDrawer {
|
||||||
background: #F7F9FE;
|
background: #F7F7F7;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-left: 1px solid #DDE7FF;
|
border-left: 1px solid #F0F0F0;
|
||||||
}
|
}
|
||||||
QFrame#DiagnosisOrderDetailHeader {
|
QFrame#DiagnosisOrderDetailHeader {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #DDE7FF;
|
border-bottom: 1px solid #F0F0F0;
|
||||||
}
|
}
|
||||||
QLabel#DiagnosisOrderDetailTitle { color: #15224A; font-size: 19px; font-weight: 650; }
|
QLabel#DiagnosisOrderDetailTitle { color: #1A1C1F; font-size: 19px; font-weight: 650; }
|
||||||
QLabel#DiagnosisOrderDetailMeta { color: #7481A3; font-size: 12px; }
|
QLabel#DiagnosisOrderDetailMeta { color: #606163; font-size: 12px; }
|
||||||
QLabel#DiagnosisOrderReadonlyBadge {
|
QLabel#DiagnosisOrderReadonlyBadge {
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
background: #F7F9FE;
|
background: #F7F7F7;
|
||||||
border: 1px solid #E2E7F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F9FE; }
|
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F7F7; }
|
||||||
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F9FE; }
|
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F7F7; }
|
||||||
QFrame[orderAmountCard="true"] {
|
QFrame[orderAmountCard="true"] {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #DDE7FF;
|
border: 1px solid #F0F0F0;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
QLabel[orderAmountTitle="true"] { color: #7481A3; font-size: 11px; font-weight: 550; }
|
QLabel[orderAmountTitle="true"] { color: #606163; font-size: 11px; font-weight: 550; }
|
||||||
QLabel[orderAmountValue="true"] {
|
QLabel[orderAmountValue="true"] {
|
||||||
color: #15224A;
|
color: #1A1C1F;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
QLabel[orderAmountTone="danger"] { color: #C43E55; }
|
QLabel[orderAmountTone="danger"] { color: #BE4B58; }
|
||||||
QLabel[orderAmountTone="success"] { color: #16876C; }
|
QLabel[orderAmountTone="success"] { color: #287B65; }
|
||||||
QLabel[orderAmountTone="warning"] { color: #9A6813; }
|
QLabel[orderAmountTone="warning"] { color: #A9691D; }
|
||||||
QFrame[orderDetailSection="true"] {
|
QFrame[orderDetailSection="true"] {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #E2E7F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
QLabel[orderSectionTitle="true"] { color: #15224A; font-size: 15px; font-weight: 650; }
|
QLabel[orderSectionTitle="true"] { color: #1A1C1F; font-size: 15px; font-weight: 650; }
|
||||||
QLabel[orderSectionHint="true"] { color: #7481A3; font-size: 11px; }
|
QLabel[orderSectionHint="true"] { color: #606163; font-size: 11px; }
|
||||||
QFrame[orderField="true"] {
|
QFrame[orderField="true"] {
|
||||||
background: #F2F6FE;
|
background: #F7F7F7;
|
||||||
border: 1px solid #E2E7F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
QLabel[orderFieldLabel="true"] { color: #7481A3; font-size: 11px; }
|
QLabel[orderFieldLabel="true"] { color: #6A6B6D; font-size: 11px; }
|
||||||
QLabel[orderFieldValue="true"] { color: #15224A; font-size: 13px; }
|
QLabel[orderFieldValue="true"] { color: #1A1C1F; font-size: 13px; }
|
||||||
QLabel[orderEmptyState="true"] {
|
QLabel[orderEmptyState="true"] {
|
||||||
color: #7481A3;
|
color: #606163;
|
||||||
background: #F2F6FE;
|
background: #F7F7F7;
|
||||||
border: 1px dashed #C9D8F2;
|
border: 1px dashed #E4E4E5;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 18px 12px;
|
padding: 18px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #93B4F4; }
|
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #8B9AD9; }
|
||||||
QLabel#DiagnosisOrderTimelineTime { color: #7481A3; font-size: 11px; }
|
QLabel#DiagnosisOrderTimelineTime { color: #606163; font-size: 11px; }
|
||||||
QLabel#DiagnosisOrderTimelineTitle { color: #15224A; font-size: 12px; font-weight: 600; }
|
QLabel#DiagnosisOrderTimelineTitle { color: #1A1C1F; font-size: 12px; font-weight: 600; }
|
||||||
QLabel#DiagnosisOrderTimelineBody { color: #7481A3; font-size: 12px; }
|
QLabel#DiagnosisOrderTimelineBody { color: #606163; font-size: 12px; }
|
||||||
QFrame#DiagnosisOrderDetailFooter {
|
QFrame#DiagnosisOrderDetailFooter {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-top: 1px solid #DDE7FF;
|
border-top: 1px solid #F0F0F0;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -758,9 +764,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
|
||||||
@@ -829,6 +837,8 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._saving = False
|
self._saving = False
|
||||||
self._load_mode = "readonly"
|
self._load_mode = "readonly"
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
|
self._dictionary_requested_generation = -1
|
||||||
|
self._dictionary_error_message = ""
|
||||||
self._save_generation = 0
|
self._save_generation = 0
|
||||||
self._orders_generation = 0
|
self._orders_generation = 0
|
||||||
self._order_detail_generation = 0
|
self._order_detail_generation = 0
|
||||||
@@ -843,6 +853,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._loading_tabs: set[str] = set()
|
self._loading_tabs: set[str] = set()
|
||||||
self._daily_todo_status: int | None = None
|
self._daily_todo_status: int | None = None
|
||||||
self._recording_players: list[RecordingPlayerDialog] = []
|
self._recording_players: list[RecordingPlayerDialog] = []
|
||||||
|
self._image_preview: ImagePreviewDialog | None = None
|
||||||
self._inline_recording_cells: list[RecordingPlaybackCell] = []
|
self._inline_recording_cells: list[RecordingPlaybackCell] = []
|
||||||
self._last_order_detail_dialog: QDialog | None = None
|
self._last_order_detail_dialog: QDialog | None = None
|
||||||
self._local_audio_dialog: LocalAudioQueueDialog | None = None
|
self._local_audio_dialog: LocalAudioQueueDialog | None = None
|
||||||
@@ -882,11 +893,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 +928,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)
|
||||||
@@ -947,6 +973,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._add_readonly_section("daily", "日常记录", daily)
|
self._add_readonly_section("daily", "日常记录", daily)
|
||||||
notes = NotesTimeline(editable=False)
|
notes = NotesTimeline(editable=False)
|
||||||
notes.open_attachment_requested.connect(self._open_safe_resource)
|
notes.open_attachment_requested.connect(self._open_safe_resource)
|
||||||
|
notes.preview_images_requested.connect(self._preview_note_images)
|
||||||
self._notes_timelines.append(notes)
|
self._notes_timelines.append(notes)
|
||||||
self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes)
|
self._add_readonly_section("notes", "医生备注 & 舌苔照片 & 检查报告", notes)
|
||||||
orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable")
|
orders = self._new_table("orders", "DiagnosisReadonlyOrdersTable")
|
||||||
@@ -981,16 +1008,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 +1036,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)
|
||||||
@@ -1412,6 +1446,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
timeline.upload_requested.connect(self._upload_doctor_note_material)
|
timeline.upload_requested.connect(self._upload_doctor_note_material)
|
||||||
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
|
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
|
||||||
timeline.open_attachment_requested.connect(self._open_safe_resource)
|
timeline.open_attachment_requested.connect(self._open_safe_resource)
|
||||||
|
timeline.preview_images_requested.connect(self._preview_note_images)
|
||||||
self.drawer_notes_timeline = timeline
|
self.drawer_notes_timeline = timeline
|
||||||
self._notes_timelines.append(timeline)
|
self._notes_timelines.append(timeline)
|
||||||
return self._wrap_tab("DiagnosisTabNotes", timeline)
|
return self._wrap_tab("DiagnosisTabNotes", timeline)
|
||||||
@@ -1508,20 +1543,11 @@ class DiagnosisDialog(QDialog):
|
|||||||
layout.addWidget(toolbar)
|
layout.addWidget(toolbar)
|
||||||
self.orders_table = self._new_table("orders", "DiagnosisTableOrders")
|
self.orders_table = self._new_table("orders", "DiagnosisTableOrders")
|
||||||
layout.addWidget(self.orders_table, 1)
|
layout.addWidget(self.orders_table, 1)
|
||||||
footer = QHBoxLayout()
|
self.orders_list = InfiniteList(self._orders_page_size, page)
|
||||||
self.orders_summary = QLabel("共 0 条")
|
for table in self._table_registry["orders"]:
|
||||||
self.orders_summary.setObjectName("DiagnosisOrdersSummary")
|
self.orders_list.bind(table)
|
||||||
footer.addWidget(self.orders_summary)
|
self._orders_footer_layout = layout
|
||||||
footer.addStretch(1)
|
layout.addWidget(self.orders_list)
|
||||||
self.orders_previous = QPushButton("上一页")
|
|
||||||
self.orders_previous.clicked.connect(lambda: self._change_orders_page(-1))
|
|
||||||
footer.addWidget(self.orders_previous)
|
|
||||||
self.orders_page_label = QLabel("1 / 1")
|
|
||||||
footer.addWidget(self.orders_page_label)
|
|
||||||
self.orders_next = QPushButton("下一页")
|
|
||||||
self.orders_next.clicked.connect(lambda: self._change_orders_page(1))
|
|
||||||
footer.addWidget(self.orders_next)
|
|
||||||
layout.addLayout(footer)
|
|
||||||
return page
|
return page
|
||||||
|
|
||||||
def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea:
|
def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea:
|
||||||
@@ -1682,22 +1708,23 @@ class DiagnosisDialog(QDialog):
|
|||||||
if hasattr(self, "readonly_ai_button"):
|
if hasattr(self, "readonly_ai_button"):
|
||||||
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
self.readonly_ai_button.setVisible(can_open_diagnosis_ai_report(self.permissions))
|
||||||
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
|
self.readonly_ai_button.setEnabled(self._diagnosis_id > 0)
|
||||||
current_key = self.tabs.tabBar().tabData(self.tabs.currentIndex())
|
previous_key = self._current_tab_key()
|
||||||
self.tabs.clear()
|
allowed_tabs = [
|
||||||
for key, label, codes in _TAB_DEFINITIONS:
|
(key, label) for key, label, codes in _TAB_DEFINITIONS if self._tab_allowed(codes)
|
||||||
if not self._tab_allowed(codes):
|
]
|
||||||
continue
|
if [self.tabs.tabBar().tabData(i) for i in range(self.tabs.count())] != [
|
||||||
index = self.tabs.addTab(self._tab_pages[key], label)
|
key for key, _label in allowed_tabs
|
||||||
self.tabs.tabBar().setTabData(index, key)
|
]:
|
||||||
target = next(
|
blocked = self.tabs.blockSignals(True)
|
||||||
(
|
self.tabs.clear()
|
||||||
index
|
target = 0
|
||||||
for index in range(self.tabs.count())
|
for key, label in allowed_tabs:
|
||||||
if self.tabs.tabBar().tabData(index) == current_key
|
index = self.tabs.addTab(self._tab_pages[key], label)
|
||||||
),
|
self.tabs.tabBar().setTabData(index, key)
|
||||||
0,
|
if key == previous_key:
|
||||||
)
|
target = index
|
||||||
self.tabs.setCurrentIndex(target)
|
self.tabs.setCurrentIndex(target)
|
||||||
|
self.tabs.blockSignals(blocked)
|
||||||
section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
|
section_codes = {key: codes for key, _label, codes in _TAB_DEFINITIONS}
|
||||||
for key, section in self._readonly_sections.items():
|
for key, section in self._readonly_sections.items():
|
||||||
section.setVisible(self._tab_allowed(section_codes.get(key, ())))
|
section.setVisible(self._tab_allowed(section_codes.get(key, ())))
|
||||||
@@ -1727,6 +1754,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._sync_order_offset_actions()
|
self._sync_order_offset_actions()
|
||||||
for panel in self._chat_panels:
|
for panel in self._chat_panels:
|
||||||
panel.sync_button.setVisible(self._can_chat_sync)
|
panel.sync_button.setVisible(self._can_chat_sync)
|
||||||
|
self._sync_save_button()
|
||||||
|
if self._current_tab_key() != previous_key:
|
||||||
|
self._tab_changed(self.tabs.currentIndex())
|
||||||
|
|
||||||
def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
|
def _show_message(self, text: str, kind: str = "info", action_text: str = "") -> None:
|
||||||
self.drawer_banner.show_message(text, kind, action_text)
|
self.drawer_banner.show_message(text, kind, action_text)
|
||||||
@@ -1735,6 +1765,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
self.readonly_error.show()
|
self.readonly_error.show()
|
||||||
|
|
||||||
def _clear_message(self) -> None:
|
def _clear_message(self) -> None:
|
||||||
|
if self._dictionary_error_message and self._authoritative_detail_loaded:
|
||||||
|
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
|
||||||
|
return
|
||||||
self.drawer_banner.clear()
|
self.drawer_banner.clear()
|
||||||
self.readonly_error.hide()
|
self.readonly_error.hide()
|
||||||
|
|
||||||
@@ -1790,6 +1823,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 +1858,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 +1867,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 +1893,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 +1980,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):
|
||||||
@@ -1969,6 +2013,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self._authoritative_detail_loaded = False
|
self._authoritative_detail_loaded = False
|
||||||
self._saving = False
|
self._saving = False
|
||||||
|
self._dictionary_error_message = ""
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._save_generation += 1
|
self._save_generation += 1
|
||||||
self._orders_generation += 1
|
self._orders_generation += 1
|
||||||
@@ -1986,13 +2031,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 +2070,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:
|
||||||
@@ -2068,14 +2143,26 @@ class DiagnosisDialog(QDialog):
|
|||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
return {"detail": detail, "patient_id": patient_id}
|
||||||
|
|
||||||
|
def _load_dictionary_choices(self) -> dict[str, Any]:
|
||||||
|
"""Load optional editor labels without extending the authoritative-detail gate."""
|
||||||
dictionaries: dict[str, list[tuple[str, Any]]] = {}
|
dictionaries: dict[str, list[tuple[str, Any]]] = {}
|
||||||
dictionary_errors: list[str] = []
|
dictionary_errors: list[str] = []
|
||||||
if callable(getattr(self.repository, "get_dictionary", None)):
|
dictionary_types = sorted(
|
||||||
dictionary_types = {
|
{
|
||||||
"diagnosis_type",
|
"diagnosis_type",
|
||||||
*(item[0] for item in _CHOICE_DICTIONARIES.values()),
|
*(item[0] for item in _CHOICE_DICTIONARIES.values()),
|
||||||
}
|
}
|
||||||
for dictionary_type in sorted(dictionary_types):
|
)
|
||||||
|
if callable(getattr(self.repository, "get_dictionaries", None)):
|
||||||
|
raw = invoke(self.repository, "get_dictionaries", dictionary_types=dictionary_types)
|
||||||
|
dictionaries = {
|
||||||
|
key: _dictionary_choices(get_value(raw, key, []), key)
|
||||||
|
for key in dictionary_types
|
||||||
|
}
|
||||||
|
elif callable(getattr(self.repository, "get_dictionary", None)):
|
||||||
|
for dictionary_type in dictionary_types:
|
||||||
try:
|
try:
|
||||||
raw = invoke(
|
raw = invoke(
|
||||||
self.repository,
|
self.repository,
|
||||||
@@ -2086,12 +2173,41 @@ class DiagnosisDialog(QDialog):
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}")
|
dictionary_errors.append(f"{dictionary_type} 字典:{friendly_error(error)}")
|
||||||
return {
|
return {
|
||||||
"detail": detail,
|
|
||||||
"patient_id": patient_id,
|
|
||||||
"dictionaries": dictionaries,
|
"dictionaries": dictionaries,
|
||||||
"dictionary_errors": dictionary_errors,
|
"dictionary_errors": dictionary_errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _ensure_dictionary_loaded(self, *, force: bool = False) -> None:
|
||||||
|
if self._standalone_readonly or not self._authoritative_detail_loaded:
|
||||||
|
return
|
||||||
|
generation = self._generation
|
||||||
|
if not force and self._dictionary_requested_generation == generation:
|
||||||
|
return
|
||||||
|
self._dictionary_requested_generation = generation
|
||||||
|
clear_error = self.drawer_banner.label.text() == self._dictionary_error_message
|
||||||
|
self._dictionary_error_message = ""
|
||||||
|
if clear_error:
|
||||||
|
self._clear_message()
|
||||||
|
run_async(
|
||||||
|
self._load_dictionary_choices,
|
||||||
|
on_success=lambda result: self._dictionary_choices_loaded(result, generation),
|
||||||
|
on_error=lambda error: self._dictionary_choices_loaded(
|
||||||
|
{"dictionary_errors": [f"病历选项加载失败:{friendly_error(error)}"]}, generation
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dictionary_choices_loaded(self, result: Any, generation: int) -> None:
|
||||||
|
if generation != self._generation or not self._authoritative_detail_loaded:
|
||||||
|
return
|
||||||
|
# A clinician may already have edited fields while the optional labels load.
|
||||||
|
# Update only choices; re-rendering the detail would overwrite that draft.
|
||||||
|
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {}, preserve=True)
|
||||||
|
errors = get_value(result, "dictionary_errors", []) or []
|
||||||
|
if errors:
|
||||||
|
self._dictionary_requested_generation = -1
|
||||||
|
self._dictionary_error_message = ";".join(str(item) for item in errors)
|
||||||
|
self._show_message(self._dictionary_error_message, "warning", action_text="重试")
|
||||||
|
|
||||||
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
|
def _query_orders(self, diagnosis_id: int, patient_id: int, page: int) -> Any:
|
||||||
filters: dict[str, Any] = {
|
filters: dict[str, Any] = {
|
||||||
"context_diagnosis_id": diagnosis_id,
|
"context_diagnosis_id": diagnosis_id,
|
||||||
@@ -2113,12 +2229,12 @@ class DiagnosisDialog(QDialog):
|
|||||||
detail = get_value(result, "detail", None)
|
detail = get_value(result, "detail", None)
|
||||||
self._detail = detail
|
self._detail = detail
|
||||||
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
|
self._patient_id = _int(get_value(result, "patient_id", 0), 0)
|
||||||
self._apply_dictionary_choices(get_value(result, "dictionaries", {}) or {})
|
|
||||||
self._authoritative_detail_loaded = True
|
self._authoritative_detail_loaded = True
|
||||||
self._show_authoritative_content(True)
|
self._show_authoritative_content(True)
|
||||||
self._render(detail, [], [])
|
self._render(detail, [], [])
|
||||||
self._sync_form_interactivity()
|
self._sync_form_interactivity()
|
||||||
self._sync_save_button()
|
self._sync_save_button()
|
||||||
|
self._set_loading(False)
|
||||||
errors = get_value(result, "dictionary_errors", []) or []
|
errors = get_value(result, "dictionary_errors", []) or []
|
||||||
if errors:
|
if errors:
|
||||||
self._show_message(";".join(str(item) for item in errors), "warning")
|
self._show_message(";".join(str(item) for item in errors), "warning")
|
||||||
@@ -2145,7 +2261,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
if generation == self._generation:
|
if generation == self._generation:
|
||||||
self._set_loading(False)
|
self._set_loading(False)
|
||||||
|
|
||||||
def _apply_dictionary_choices(self, dictionaries: Mapping[str, Any]) -> None:
|
def _apply_dictionary_choices(
|
||||||
|
self, dictionaries: Mapping[str, Any], *, preserve: bool = False
|
||||||
|
) -> None:
|
||||||
diagnosis_type_editor = self.edit_fields.get("diagnosis_type")
|
diagnosis_type_editor = self.edit_fields.get("diagnosis_type")
|
||||||
if isinstance(diagnosis_type_editor, DiagnosisComboBox):
|
if isinstance(diagnosis_type_editor, DiagnosisComboBox):
|
||||||
labels = {
|
labels = {
|
||||||
@@ -2162,15 +2280,17 @@ class DiagnosisDialog(QDialog):
|
|||||||
("会诊", "consultation"),
|
("会诊", "consultation"),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
preserve=False,
|
preserve=preserve,
|
||||||
)
|
)
|
||||||
for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items():
|
for key, (dictionary_type, _multiple) in _CHOICE_DICTIONARIES.items():
|
||||||
|
if dictionary_type not in dictionaries:
|
||||||
|
continue
|
||||||
editor = self._choice_fields.get(key)
|
editor = self._choice_fields.get(key)
|
||||||
choices = list(dictionaries.get(dictionary_type, []))
|
choices = list(dictionaries.get(dictionary_type, []))
|
||||||
if not _multiple:
|
if not _multiple:
|
||||||
choices.insert(0, ("无", ""))
|
choices.insert(0, ("无", ""))
|
||||||
if editor is not None:
|
if editor is not None:
|
||||||
editor.set_choices(choices, preserve=False)
|
editor.set_choices(choices, preserve=preserve)
|
||||||
# Defer until the drawer has a real width so wrapped chips get height.
|
# Defer until the drawer has a real width so wrapped chips get height.
|
||||||
QTimer.singleShot(0, self._sync_choice_field_heights)
|
QTimer.singleShot(0, self._sync_choice_field_heights)
|
||||||
|
|
||||||
@@ -2249,14 +2369,20 @@ class DiagnosisDialog(QDialog):
|
|||||||
if not self._authoritative_detail_loaded:
|
if not self._authoritative_detail_loaded:
|
||||||
self._retry_detail()
|
self._retry_detail()
|
||||||
return
|
return
|
||||||
|
if (self._dictionary_error_message
|
||||||
|
and self.drawer_banner.label.text() == self._dictionary_error_message):
|
||||||
|
self._ensure_dictionary_loaded(force=True)
|
||||||
|
return
|
||||||
self._ensure_tab_loaded(self._current_tab_key(), force=True)
|
self._ensure_tab_loaded(self._current_tab_key(), force=True)
|
||||||
|
|
||||||
def _invalidate_requests(self) -> None:
|
def _invalidate_requests(self) -> None:
|
||||||
|
self._close_image_preview()
|
||||||
self._stop_watching_local_audio_uploads()
|
self._stop_watching_local_audio_uploads()
|
||||||
self._video_reload_pending = False
|
self._video_reload_pending = False
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
self._save_generation += 1
|
self._save_generation += 1
|
||||||
self._orders_generation += 1
|
self._orders_generation += 1
|
||||||
|
self.orders_list.invalidate()
|
||||||
self._order_detail_generation += 1
|
self._order_detail_generation += 1
|
||||||
self._daily_mutation_generation += 1
|
self._daily_mutation_generation += 1
|
||||||
self._notes_mutation_generation += 1
|
self._notes_mutation_generation += 1
|
||||||
@@ -2569,7 +2695,10 @@ class DiagnosisDialog(QDialog):
|
|||||||
table.set_empty_text(message)
|
table.set_empty_text(message)
|
||||||
|
|
||||||
def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None:
|
def _ensure_tab_loaded(self, key: str, *, force: bool = False) -> None:
|
||||||
if key == "basic" or not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
|
if not self._authoritative_detail_loaded or self._diagnosis_id <= 0:
|
||||||
|
return
|
||||||
|
if key == "basic":
|
||||||
|
self._ensure_dictionary_loaded(force=force)
|
||||||
return
|
return
|
||||||
if key == "daily":
|
if key == "daily":
|
||||||
source = next(
|
source = next(
|
||||||
@@ -2581,6 +2710,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
return
|
return
|
||||||
if not force and (key in self._loaded_tabs or key in self._loading_tabs):
|
if not force and (key in self._loaded_tabs or key in self._loading_tabs):
|
||||||
return
|
return
|
||||||
|
if key == "orders":
|
||||||
|
self._load_orders()
|
||||||
|
return
|
||||||
self._tab_generations[key] += 1
|
self._tab_generations[key] += 1
|
||||||
generation = self._tab_generations[key]
|
generation = self._tab_generations[key]
|
||||||
diagnosis_id = self._diagnosis_id
|
diagnosis_id = self._diagnosis_id
|
||||||
@@ -2674,10 +2806,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._fill_prescriptions(page_items(result))
|
self._fill_prescriptions(page_items(result))
|
||||||
elif key == "orders":
|
elif key == "orders":
|
||||||
rows = page_items(result)
|
rows = page_items(result)
|
||||||
self._orders_page = 1
|
self._orders_page = self.orders_list.page
|
||||||
self._orders_total = page_total(result, len(rows))
|
self._orders_total = page_total(result, len(rows))
|
||||||
self._fill_orders(rows)
|
self._fill_orders(rows)
|
||||||
self._update_orders_pager()
|
|
||||||
elif key == "assign":
|
elif key == "assign":
|
||||||
self._fill_assignments(page_items(result))
|
self._fill_assignments(page_items(result))
|
||||||
elif key == "appointment":
|
elif key == "appointment":
|
||||||
@@ -3361,6 +3492,27 @@ class DiagnosisDialog(QDialog):
|
|||||||
self._sync_order_offset_actions()
|
self._sync_order_offset_actions()
|
||||||
self._mutation_error(error, diagnosis_id, generation, "offset")
|
self._mutation_error(error, diagnosis_id, generation, "offset")
|
||||||
|
|
||||||
|
def _close_image_preview(self) -> None:
|
||||||
|
preview, self._image_preview = self._image_preview, None
|
||||||
|
if preview is not None:
|
||||||
|
with suppress(RuntimeError):
|
||||||
|
preview.close()
|
||||||
|
|
||||||
|
def _image_preview_finished(self, _result: int) -> None:
|
||||||
|
if self.sender() is self._image_preview:
|
||||||
|
self._image_preview = None
|
||||||
|
|
||||||
|
def _preview_note_images(self, sources: Sequence[str], index: int) -> None:
|
||||||
|
"""Keep the diagnosis open while viewing the clicked note's image group."""
|
||||||
|
if not safe_image_sources(sources):
|
||||||
|
QMessageBox.warning(self, "无法预览", "仅支持 HTTP(S) 服务端图片。")
|
||||||
|
return
|
||||||
|
self._close_image_preview()
|
||||||
|
preview = ImagePreviewDialog(sources, index=index, title="舌象图片预览", parent=self)
|
||||||
|
self._image_preview = preview
|
||||||
|
preview.finished.connect(self._image_preview_finished)
|
||||||
|
preview.open()
|
||||||
|
|
||||||
def _open_safe_resource(self, target: str) -> None:
|
def _open_safe_resource(self, target: str) -> None:
|
||||||
url = QUrl(str(target).strip())
|
url = QUrl(str(target).strip())
|
||||||
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
|
if not url.isValid() or url.scheme().lower() not in {"https", "http"} or not url.host():
|
||||||
@@ -3390,8 +3542,7 @@ class DiagnosisDialog(QDialog):
|
|||||||
panel.set_unavailable("切换到聊天记录后加载归档数据。")
|
panel.set_unavailable("切换到聊天记录后加载归档数据。")
|
||||||
for panel in self._daily_panels:
|
for panel in self._daily_panels:
|
||||||
panel.clear()
|
panel.clear()
|
||||||
self.orders_summary.setText("共 0 条")
|
self.orders_list.update_state(1, 0)
|
||||||
self.orders_page_label.setText("1 / 1")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
|
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
|
||||||
@@ -4009,6 +4160,9 @@ class DiagnosisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
for row_index, row in enumerate(rows):
|
for row_index, row in enumerate(rows):
|
||||||
order_id = _int(first_value(row, "id", "order_id"), 0)
|
order_id = _int(first_value(row, "id", "order_id"), 0)
|
||||||
|
item = table.item(row_index, 0)
|
||||||
|
if item is not None:
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||||
if (
|
if (
|
||||||
self._can_order_detail
|
self._can_order_detail
|
||||||
and order_id > 0
|
and order_id > 0
|
||||||
@@ -4680,52 +4834,44 @@ class DiagnosisDialog(QDialog):
|
|||||||
if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id:
|
if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id:
|
||||||
self._show_message(friendly_error(error), "danger")
|
self._show_message(friendly_error(error), "danger")
|
||||||
|
|
||||||
def _update_orders_pager(self) -> None:
|
def _load_orders(self) -> None:
|
||||||
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
|
|
||||||
self.orders_summary.setText(f"共 {self._orders_total} 条")
|
|
||||||
self.orders_page_label.setText(f"{self._orders_page} / {pages}")
|
|
||||||
self.orders_previous.setEnabled(self._orders_page > 1)
|
|
||||||
self.orders_next.setEnabled(self._orders_page < pages)
|
|
||||||
|
|
||||||
def _change_orders_page(self, offset: int) -> None:
|
|
||||||
if not self._can_patient_orders or self._diagnosis_id <= 0:
|
if not self._can_patient_orders or self._diagnosis_id <= 0:
|
||||||
return
|
return
|
||||||
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
|
self._tab_generations["orders"] += 1
|
||||||
target_page = self._orders_page + offset
|
generation = self._tab_generations["orders"]
|
||||||
if target_page < 1 or target_page > pages:
|
|
||||||
return
|
|
||||||
self._orders_generation += 1
|
|
||||||
generation = self._orders_generation
|
|
||||||
diagnosis_id = self._diagnosis_id
|
diagnosis_id = self._diagnosis_id
|
||||||
patient_id = self._patient_id
|
patient_id = self._patient_id
|
||||||
self._show_message("正在加载患者订单…", "info")
|
self._loading_tabs.add("orders")
|
||||||
run_async(
|
if not self.orders_list.rows:
|
||||||
lambda: self._query_orders(diagnosis_id, patient_id, target_page),
|
self._set_tab_loading("orders")
|
||||||
on_success=lambda result: self._apply_orders_page(
|
self.orders_list.reload(
|
||||||
result, diagnosis_id, target_page, generation
|
lambda page: self._query_orders(diagnosis_id, patient_id, page),
|
||||||
|
apply=lambda result: self._apply_orders_result(
|
||||||
|
result, diagnosis_id, generation
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._orders_error(error, diagnosis_id, generation),
|
on_error=lambda error: self._tab_load_error(
|
||||||
|
"orders", error, diagnosis_id, generation
|
||||||
|
),
|
||||||
|
runner=run_async,
|
||||||
|
query_key=(diagnosis_id, patient_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_orders_page(
|
def _apply_orders_result(
|
||||||
self,
|
self,
|
||||||
result: Any,
|
result: Any,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
page: int,
|
|
||||||
generation: int,
|
generation: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
if generation != self._orders_generation or diagnosis_id != self._diagnosis_id:
|
if (
|
||||||
|
generation != self._tab_generations["orders"]
|
||||||
|
or diagnosis_id != self._diagnosis_id
|
||||||
|
or not self._authoritative_detail_loaded
|
||||||
|
):
|
||||||
return
|
return
|
||||||
rows = page_items(result)
|
if self.orders_list.page == 0:
|
||||||
self._orders_page = page
|
self._fill_orders([])
|
||||||
self._orders_total = page_total(result, len(rows))
|
return
|
||||||
self._fill_orders(rows)
|
self._apply_tab_result("orders", result, diagnosis_id, generation)
|
||||||
self._update_orders_pager()
|
|
||||||
self._clear_message()
|
|
||||||
|
|
||||||
def _orders_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
|
|
||||||
if generation == self._orders_generation and diagnosis_id == self._diagnosis_id:
|
|
||||||
self._show_message(friendly_error(error), "danger")
|
|
||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -40,76 +40,76 @@ _STATUS_LABELS = {
|
|||||||
"invalid": "无效录音",
|
"invalid": "无效录音",
|
||||||
}
|
}
|
||||||
_STATUS_COLORS = {
|
_STATUS_COLORS = {
|
||||||
"recording": "#5364F5",
|
"recording": "#1A1C1F",
|
||||||
"pending": "#B26A00",
|
"pending": "#A9691D",
|
||||||
"uploading": "#2F6FEB",
|
"uploading": "#1A1C1F",
|
||||||
"uploaded": "#07966B",
|
"uploaded": "#287B65",
|
||||||
"failed": "#DC4054",
|
"failed": "#BE4B58",
|
||||||
"invalid": "#7886AA",
|
"invalid": "#606163",
|
||||||
}
|
}
|
||||||
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
|
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
_LOCAL_AUDIO_QSS = """
|
_LOCAL_AUDIO_QSS = """
|
||||||
QDialog#LocalAudioQueueDialog {
|
QDialog#LocalAudioQueueDialog {
|
||||||
background: #F6F8FD;
|
background: #F7F7F7;
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
}
|
}
|
||||||
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
|
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
|
||||||
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
|
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid #E2E7F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
QLabel#LocalAudioQueueTitle {
|
QLabel#LocalAudioQueueTitle {
|
||||||
color: #111F46;
|
color: #1A1C1F;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
|
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
|
||||||
color: #6E7C9F;
|
color: #6A6B6D;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
QLabel[queueSummary="true"] {
|
QLabel[queueSummary="true"] {
|
||||||
background: #F3F5FB;
|
background: #F7F7F7;
|
||||||
border: 1px solid #E6EAF5;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #3F4E75;
|
color: #1A1C1F;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
QPushButton {
|
QPushButton {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
border: 1px solid #D9E0F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
color: #354365;
|
color: #1A1C1F;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; }
|
QPushButton:hover { background: #EEF1FA; border-color: #8B9AD9; }
|
||||||
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; }
|
QPushButton:disabled { color: #8E8F90; background: #F7F7F7; }
|
||||||
QPushButton[variant="primary"] {
|
QPushButton[variant="primary"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background: #5661F4;
|
background: #4F63D9;
|
||||||
border-color: #5661F4;
|
border-color: #4F63D9;
|
||||||
}
|
}
|
||||||
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; }
|
QPushButton[variant="danger"] { color: #BE4B58; background: #FFF6F7; }
|
||||||
QTableWidget#LocalAudioQueueTable {
|
QTableWidget#LocalAudioQueueTable {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
alternate-background-color: #FAFBFE;
|
alternate-background-color: #F7F7F7;
|
||||||
border: 0;
|
border: 0;
|
||||||
gridline-color: #E8ECF5;
|
gridline-color: #EDEDEE;
|
||||||
color: #263452;
|
color: #1A1C1F;
|
||||||
selection-background-color: #EEF1FF;
|
selection-background-color: #EEF1FA;
|
||||||
selection-color: #111F46;
|
selection-color: #1A1C1F;
|
||||||
}
|
}
|
||||||
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
|
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
|
||||||
QHeaderView::section {
|
QHeaderView::section {
|
||||||
background: #F5F7FC;
|
background: #F7F7F7;
|
||||||
color: #53617F;
|
color: #6A6B6D;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #E1E6F1;
|
border-bottom: 1px solid #EDEDEE;
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -371,7 +371,7 @@ class LocalAudioQueueDialog(QDialog):
|
|||||||
self._status_column,
|
self._status_column,
|
||||||
_STATUS_LABELS.get(record.status, record.status),
|
_STATUS_LABELS.get(record.status, record.status),
|
||||||
)
|
)
|
||||||
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#53617F")))
|
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#606163")))
|
||||||
status_item.setToolTip(
|
status_item.setToolTip(
|
||||||
f"已尝试 {record.attempts} 次"
|
f"已尝试 {record.attempts} 次"
|
||||||
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "")
|
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -143,75 +143,75 @@ def diagnosis_ai_task(prompt: str) -> str:
|
|||||||
|
|
||||||
PRESCRIPTION_AI_QSS = """
|
PRESCRIPTION_AI_QSS = """
|
||||||
QDialog#PrescriptionAiDialog {
|
QDialog#PrescriptionAiDialog {
|
||||||
color: #17203F;
|
color: #1A1C1F;
|
||||||
background-color: #F7F9FE;
|
background-color: #F7F7F7;
|
||||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton {
|
QDialog#PrescriptionAiDialog QPushButton {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
color: #4F5B75;
|
color: #1A1C1F;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton:hover {
|
QDialog#PrescriptionAiDialog QPushButton:hover {
|
||||||
color: #4D57D8;
|
color: #4156C4;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
border-color: #D8DCFF;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton:pressed {
|
QDialog#PrescriptionAiDialog QPushButton:pressed {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background-color: #4D57D8;
|
background-color: #354BB4;
|
||||||
border-color: #4D57D8;
|
border-color: #354BB4;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton[variant="primary"] {
|
QDialog#PrescriptionAiDialog QPushButton[variant="primary"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background-color: #5761F4;
|
background-color: #4F63D9;
|
||||||
border-color: #5761F4;
|
border-color: #4F63D9;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton[variant="primary"]:hover {
|
QDialog#PrescriptionAiDialog QPushButton[variant="primary"]:hover {
|
||||||
background-color: #6871F6;
|
background-color: #4156C4;
|
||||||
border-color: #6871F6;
|
border-color: #4156C4;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton[variant="link"] {
|
QDialog#PrescriptionAiDialog QPushButton[variant="link"] {
|
||||||
color: #4D57D8;
|
color: #4F63D9;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QPushButton[variant="link"]:hover {
|
QDialog#PrescriptionAiDialog QPushButton[variant="link"]:hover {
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiTitle {
|
QLabel#PrescriptionAiTitle {
|
||||||
color: #17203F;
|
color: #1A1C1F;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiSubtitle {
|
QLabel#PrescriptionAiSubtitle {
|
||||||
color: #78849D;
|
color: #606163;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
QFrame#PrescriptionAiSnapshot {
|
QFrame#PrescriptionAiSnapshot {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiSnapshotLabel {
|
QLabel#PrescriptionAiSnapshotLabel {
|
||||||
color: #4D57D8;
|
color: #1A1C1F;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiSnapshotBody {
|
QLabel#PrescriptionAiSnapshotBody {
|
||||||
color: #26304F;
|
color: #1A1C1F;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
QFrame#PrescriptionAiSummary {
|
QFrame#PrescriptionAiSummary {
|
||||||
background-color: #F0F2FF;
|
background-color: #F0F0F0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-left: 3px solid #5761F4;
|
border-left: 3px solid #4F63D9;
|
||||||
border-radius: 0 9px 9px 0;
|
border-radius: 0 9px 9px 0;
|
||||||
}
|
}
|
||||||
QFrame#PrescriptionAiCaution {
|
QFrame#PrescriptionAiCaution {
|
||||||
@@ -220,56 +220,56 @@ QFrame#PrescriptionAiCaution {
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiSectionTitle {
|
QLabel#PrescriptionAiSectionTitle {
|
||||||
color: #17203F;
|
color: #1A1C1F;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiBody {
|
QLabel#PrescriptionAiBody {
|
||||||
color: #37415E;
|
color: #1A1C1F;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
QTextBrowser#PrescriptionAiAnswer {
|
QTextBrowser#PrescriptionAiAnswer {
|
||||||
color: #34436B;
|
color: #1A1C1F;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #E3E8F4;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
selection-color: #17203F;
|
selection-color: #1A1C1F;
|
||||||
selection-background-color: #DDE2FF;
|
selection-background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
QLabel#PrescriptionAiMuted {
|
QLabel#PrescriptionAiMuted {
|
||||||
color: #8A94AA;
|
color: #6A6B6D;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
QFrame#PrescriptionAiMeta {
|
QFrame#PrescriptionAiMeta {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid #E3E8F4;
|
border-bottom: 1px solid #EDEDEE;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTabWidget::pane {
|
QDialog#PrescriptionAiDialog QTabWidget::pane {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
top: -1px;
|
top: -1px;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTabBar::tab {
|
QDialog#PrescriptionAiDialog QTabBar::tab {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
color: #78849D;
|
color: #606163;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTabBar::tab:hover {
|
QDialog#PrescriptionAiDialog QTabBar::tab:hover {
|
||||||
color: #4D57D8;
|
color: #4156C4;
|
||||||
background-color: #F5F7FC;
|
background-color: #F7F7F7;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTabBar::tab:selected {
|
QDialog#PrescriptionAiDialog QTabBar::tab:selected {
|
||||||
color: #4D57D8;
|
color: #4F63D9;
|
||||||
background-color: #F0F2FF;
|
background-color: #EEF1FA;
|
||||||
border-bottom: 2px solid #5761F4;
|
border-bottom: 2px solid #4F63D9;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QScrollArea,
|
QDialog#PrescriptionAiDialog QScrollArea,
|
||||||
@@ -278,15 +278,15 @@ QDialog#PrescriptionAiDialog QScrollArea > QWidget > QWidget {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTextEdit {
|
QDialog#PrescriptionAiDialog QTextEdit {
|
||||||
color: #17203F;
|
color: #1A1C1F;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE3F2;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
selection-background-color: #E3E6FF;
|
selection-background-color: #EEF1FA;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QTextEdit:focus {
|
QDialog#PrescriptionAiDialog QTextEdit:focus {
|
||||||
border: 1px solid #5761F4;
|
border: 1px solid #8B9AD9;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
@@ -295,7 +295,7 @@ QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
|||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QScrollBar::handle:vertical {
|
QDialog#PrescriptionAiDialog QScrollBar::handle:vertical {
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
background-color: #C8D0E0;
|
background-color: #D2D2D3;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
|
QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
|
||||||
@@ -305,20 +305,20 @@ QDialog#PrescriptionAiDialog QScrollBar::sub-line:vertical {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_AI_ANSWER_DOCUMENT_CSS = (
|
_AI_ANSWER_DOCUMENT_CSS = (
|
||||||
"body { color:#34436B; font-size:14px; line-height:1.72; } "
|
"body { color:#1A1C1F; font-size:14px; line-height:1.72; } "
|
||||||
"h1 { color:#15224A; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
|
"h1 { color:#1A1C1F; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
|
||||||
"h2 { color:#15224A; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
|
"h2 { color:#1A1C1F; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
|
||||||
"h3,h4 { color:#15224A; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
"h3,h4 { color:#1A1C1F; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
||||||
"p { margin:6px 0; line-height:1.72; } "
|
"p { margin:6px 0; line-height:1.72; } "
|
||||||
"ul,ol { margin:7px 0 8px 22px; } li { margin:4px 0; line-height:1.65; } "
|
"ul,ol { margin:7px 0 8px 22px; } li { margin:4px 0; line-height:1.65; } "
|
||||||
"strong { color:#15224A; font-weight:700; } "
|
"strong { color:#1A1C1F; font-weight:700; } "
|
||||||
"blockquote { color:#596788; background:#F5F7FC; border-left:3px solid #7B84F7; "
|
"blockquote { color:#606163; background:#F7F7F7; border-left:3px solid #8B9AD9; "
|
||||||
"margin:9px 0; padding:7px 10px; } "
|
"margin:9px 0; padding:7px 10px; } "
|
||||||
"code { color:#33406B; background:#EEF1FF; } "
|
"code { color:#1A1C1F; background:#F0F0F0; } "
|
||||||
"pre { color:#33406B; background:#F0F3FA; margin:8px 0; padding:9px; } "
|
"pre { color:#1A1C1F; background:#F7F7F7; margin:8px 0; padding:9px; } "
|
||||||
"table { border-collapse:collapse; margin:8px 0; } "
|
"table { border-collapse:collapse; margin:8px 0; } "
|
||||||
"th,td { border:1px solid #DCE3F2; padding:6px 8px; } "
|
"th,td { border:1px solid #EDEDEE; padding:6px 8px; } "
|
||||||
"th { color:#15224A; background:#F5F7FC; font-weight:700; }"
|
"th { color:#1A1C1F; background:#F7F7F7; font-weight:700; }"
|
||||||
)
|
)
|
||||||
_AI_ANSWER_MARKDOWN_FEATURES = (
|
_AI_ANSWER_MARKDOWN_FEATURES = (
|
||||||
QTextDocument.MarkdownFeature.MarkdownDialectGitHub
|
QTextDocument.MarkdownFeature.MarkdownDialectGitHub
|
||||||
@@ -1181,7 +1181,7 @@ class PrescriptionAiReportDialog(QDialog):
|
|||||||
def _list_html(self, items: Any, empty: str) -> str:
|
def _list_html(self, items: Any, empty: str) -> str:
|
||||||
values = [str(item).strip() for item in (items or []) if str(item).strip()]
|
values = [str(item).strip() for item in (items or []) if str(item).strip()]
|
||||||
if not values:
|
if not values:
|
||||||
return f'<span style="color:#8A94AA;">{html.escape(empty)}</span>'
|
return f'<span style="color:#6A6B6D;">{html.escape(empty)}</span>'
|
||||||
bullets = "".join(f"<li>{html.escape(item)}</li>" for item in values)
|
bullets = "".join(f"<li>{html.escape(item)}</li>" for item in values)
|
||||||
return f'<ul style="margin:0;padding-left:18px;">{bullets}</ul>'
|
return f'<ul style="margin:0;padding-left:18px;">{bullets}</ul>'
|
||||||
|
|
||||||
@@ -1398,7 +1398,7 @@ class PrescriptionAiReportDialog(QDialog):
|
|||||||
content.setTextFormat(Qt.TextFormat.RichText)
|
content.setTextFormat(Qt.TextFormat.RichText)
|
||||||
cell_layout.addWidget(heading)
|
cell_layout.addWidget(heading)
|
||||||
cell_layout.addWidget(content)
|
cell_layout.addWidget(content)
|
||||||
grid.addWidget(cell, index // 2, index % 2)
|
grid.addWidget(cell, index // 2, index % 2, Qt.AlignmentFlag.AlignTop)
|
||||||
self.host_layout.addWidget(grid_host)
|
self.host_layout.addWidget(grid_host)
|
||||||
if report.get("compatibility_analysis"):
|
if report.get("compatibility_analysis"):
|
||||||
self._section("配伍分析", str(report.get("compatibility_analysis") or ""))
|
self._section("配伍分析", str(report.get("compatibility_analysis") or ""))
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Compact, presentation-only disclosure for page search and overview regions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QSize, Qt, Signal
|
||||||
|
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||||
|
|
||||||
|
from . import icons
|
||||||
|
|
||||||
|
|
||||||
|
class FilterDisclosure(QObject):
|
||||||
|
"""Keep query values and loading state intact while reclaiming list space.
|
||||||
|
|
||||||
|
Targets should be region containers, not individual permission-controlled
|
||||||
|
controls. Showing a container preserves its children's explicit visibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
expanded_changed = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: QWidget,
|
||||||
|
targets: Sequence[QWidget],
|
||||||
|
*,
|
||||||
|
expanded: bool = False,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._targets = tuple(targets)
|
||||||
|
self._expanded = bool(expanded)
|
||||||
|
self.button = QPushButton(parent)
|
||||||
|
self.button.setObjectName("FilterDisclosureButton")
|
||||||
|
self.button.setCheckable(True)
|
||||||
|
self.button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.button.setFixedHeight(32)
|
||||||
|
self.button.setIconSize(QSize(14, 14))
|
||||||
|
self.button.setStyleSheet("""
|
||||||
|
QPushButton#FilterDisclosureButton {
|
||||||
|
color: #1769E8; background: #FFFFFF; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 6px; padding: 0 11px; min-height: 30px; max-height: 30px;
|
||||||
|
min-width: 92px; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
QPushButton#FilterDisclosureButton:hover { background: #F3F7FD; border-color: #ADC8F2; }
|
||||||
|
QPushButton#FilterDisclosureButton:checked { background: #EAF2FF; border-color: #ADC8F2; }
|
||||||
|
QPushButton#FilterDisclosureButton:focus { border-color: #1769E8; }
|
||||||
|
QPushButton#FilterDisclosureButton:disabled { color: #8B97A8; border-color: #E3E9F1; }
|
||||||
|
""")
|
||||||
|
self.button.toggled.connect(self.set_expanded)
|
||||||
|
self._apply()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expanded(self) -> bool:
|
||||||
|
return self._expanded
|
||||||
|
|
||||||
|
def set_expanded(self, expanded: bool) -> None:
|
||||||
|
expanded = bool(expanded)
|
||||||
|
changed = expanded != self._expanded
|
||||||
|
self._expanded = expanded
|
||||||
|
self._apply()
|
||||||
|
if changed:
|
||||||
|
self.expanded_changed.emit(expanded)
|
||||||
|
|
||||||
|
def _apply(self) -> None:
|
||||||
|
# Keep keyboard focus on a visible control when folding a focused form.
|
||||||
|
focused = QApplication.focusWidget()
|
||||||
|
if not self._expanded and focused is not None and any(
|
||||||
|
target is focused or target.isAncestorOf(focused) for target in self._targets
|
||||||
|
):
|
||||||
|
self.button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
for target in self._targets:
|
||||||
|
target.setVisible(self._expanded)
|
||||||
|
blocked = self.button.blockSignals(True)
|
||||||
|
self.button.setChecked(self._expanded)
|
||||||
|
self.button.blockSignals(blocked)
|
||||||
|
label = "收起筛选" if self._expanded else "展开筛选"
|
||||||
|
self.button.setText(label)
|
||||||
|
self.button.setAccessibleName(label)
|
||||||
|
self.button.setAccessibleDescription("显示或收起检索条件和统计信息;收起保留当前筛选条件")
|
||||||
|
self.button.setToolTip("收起保留当前筛选条件" if self._expanded else "展开检索条件和统计信息,当前筛选条件保持不变")
|
||||||
|
self.button.setIcon(icons.icon("chevron_up" if self._expanded else "chevron_down", "#1769E8", 14))
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
"""Single source of truth for every line icon in the workstation.
|
||||||
|
|
||||||
|
Before this module the application drew its icons from nine independent
|
||||||
|
painters (``ui/shell.py`` had two, ``ui/login.py`` two,
|
||||||
|
``ui/pages/reception.py`` four, and ``ui/pages/prescriptions.py``,
|
||||||
|
``ui/pages/patients.py`` and ``ui/diagnosis_index_widgets.py`` one each). They disagreed on everything that
|
||||||
|
makes an icon set read as one family:
|
||||||
|
|
||||||
|
* seven stroke weights - 1.4, 1.5, 1.55, 1.6, 1.7, 2.0 and ``size / 11.5`` px;
|
||||||
|
* four design grids - geometry authored against 14, 16, 18 and 24 px boxes, so
|
||||||
|
the same glyph asked for at another size came out off-centre or clipped;
|
||||||
|
* mixed fills and strokes inside one row of icons (a stroked ``search`` beside a
|
||||||
|
solid ``down`` triangle);
|
||||||
|
* integer ``QRect`` coordinates in the menu painter, which put a 1.6 px stroke
|
||||||
|
across a pixel boundary and rendered visibly softer than its neighbours;
|
||||||
|
* six near-identical indigos and two near-identical reds picked per call site
|
||||||
|
instead of from the palette.
|
||||||
|
|
||||||
|
Everything here is authored once on a 24-unit grid with a 20-unit optical safe
|
||||||
|
area, stroked with one weight formula, and scaled to the requested size by the
|
||||||
|
painter transform. Glyphs are pure stroke unless a filled counter is part of
|
||||||
|
the mark (a list bullet, the dot on an "i"), which keeps the whole set at a
|
||||||
|
single apparent weight.
|
||||||
|
|
||||||
|
Icons are cached as well. List pages build one icon per action button per row,
|
||||||
|
so the previous code re-ran a ``QPainter`` for every visible row on every
|
||||||
|
refresh; the cache turns that into one paint per (kind, colour, size).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||||
|
from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from .theme import COLORS, crisp_pixmap
|
||||||
|
|
||||||
|
#: Every glyph is drawn inside this box. Nothing is authored against the pixel
|
||||||
|
#: size the caller asks for, which is what keeps a 14 px and a 24 px request
|
||||||
|
#: optically identical instead of merely proportional.
|
||||||
|
GRID = 24.0
|
||||||
|
|
||||||
|
#: Ideal stroke at the reference grid. ``2 / 24`` is the Feather/Lucide ratio;
|
||||||
|
#: the clamp keeps the line from vanishing at 12 px or turning into a slab at
|
||||||
|
#: 36 px, which is the range the shell actually asks for.
|
||||||
|
_STROKE_RATIO = 2.0 / GRID
|
||||||
|
_STROKE_MIN_PX = 1.25
|
||||||
|
_STROKE_MAX_PX = 2.25
|
||||||
|
|
||||||
|
|
||||||
|
def stroke_px(size: float) -> float:
|
||||||
|
"""Return the on-screen stroke width used for an icon of ``size`` px."""
|
||||||
|
|
||||||
|
return max(_STROKE_MIN_PX, min(_STROKE_MAX_PX, size * _STROKE_RATIO))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Semantic colour roles ------------------------------------------------
|
||||||
|
# Call sites name a role instead of a hex value. The seven painters replaced
|
||||||
|
# here between them hardcoded #5265F6, #5761F4, #5469F0, #5E69F6, #5365F5,
|
||||||
|
# #4965F5 and #6675F5 for what was always meant to be one accent.
|
||||||
|
ROLES = {
|
||||||
|
"default": COLORS["muted"],
|
||||||
|
"muted": COLORS["muted"],
|
||||||
|
"soft": COLORS["text_soft"],
|
||||||
|
"strong": COLORS["text"],
|
||||||
|
"accent": COLORS["indigo"],
|
||||||
|
"on_accent": "#FFFFFF",
|
||||||
|
"success": COLORS["success"],
|
||||||
|
"warning": COLORS["warning"],
|
||||||
|
"danger": COLORS["danger"],
|
||||||
|
"info": COLORS["info"],
|
||||||
|
"disabled": COLORS["disabled_text"],
|
||||||
|
"inverse": "#FFFFFF",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_color(color: str) -> str:
|
||||||
|
"""Accept either a semantic role name or a literal colour string."""
|
||||||
|
|
||||||
|
return ROLES.get(color, color)
|
||||||
|
|
||||||
|
|
||||||
|
_GLYPHS: dict[str, Callable[[QPainter, float], None]] = {}
|
||||||
|
|
||||||
|
_Glyph = Callable[[QPainter, float], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _glyph(*names: str) -> Callable[[_Glyph], _Glyph]:
|
||||||
|
def register(fn: _Glyph) -> _Glyph:
|
||||||
|
for name in names:
|
||||||
|
_GLYPHS[name] = fn
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return register
|
||||||
|
|
||||||
|
|
||||||
|
def _line(p: QPainter, x1: float, y1: float, x2: float, y2: float) -> None:
|
||||||
|
p.drawLine(QPointF(x1, y1), QPointF(x2, y2))
|
||||||
|
|
||||||
|
|
||||||
|
def _polyline(p: QPainter, *points: tuple[float, float]) -> None:
|
||||||
|
path = QPainterPath(QPointF(*points[0]))
|
||||||
|
for point in points[1:]:
|
||||||
|
path.lineTo(QPointF(*point))
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _circle(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||||
|
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||||
|
|
||||||
|
|
||||||
|
def _dot(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||||
|
"""Filled counter - used only where the mark itself is solid."""
|
||||||
|
|
||||||
|
pen = p.pen()
|
||||||
|
p.setPen(Qt.PenStyle.NoPen)
|
||||||
|
p.setBrush(pen.color())
|
||||||
|
p.drawEllipse(QPointF(cx, cy), r, r)
|
||||||
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
p.setPen(pen)
|
||||||
|
|
||||||
|
|
||||||
|
def _page(p: QPainter, *, fold: bool = True) -> None:
|
||||||
|
"""Shared document silhouette so every file-like glyph has one outline."""
|
||||||
|
|
||||||
|
path = QPainterPath(QPointF(14.0, 2.5))
|
||||||
|
path.lineTo(QPointF(6.5, 2.5))
|
||||||
|
path.quadTo(QPointF(5.0, 2.5), QPointF(5.0, 4.0))
|
||||||
|
path.lineTo(QPointF(5.0, 20.0))
|
||||||
|
path.quadTo(QPointF(5.0, 21.5), QPointF(6.5, 21.5))
|
||||||
|
path.lineTo(QPointF(17.5, 21.5))
|
||||||
|
path.quadTo(QPointF(19.0, 21.5), QPointF(19.0, 20.0))
|
||||||
|
path.lineTo(QPointF(19.0, 7.5))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
if fold:
|
||||||
|
_polyline(p, (14.0, 2.5), (14.0, 7.5), (19.0, 7.5))
|
||||||
|
|
||||||
|
|
||||||
|
def _sparkle(p: QPainter, cx: float, cy: float, r: float) -> None:
|
||||||
|
"""Four-point concave star - the one AI mark used across the product."""
|
||||||
|
|
||||||
|
path = QPainterPath(QPointF(cx, cy - r))
|
||||||
|
path.quadTo(QPointF(cx, cy), QPointF(cx + r, cy))
|
||||||
|
path.quadTo(QPointF(cx, cy), QPointF(cx, cy + r))
|
||||||
|
path.quadTo(QPointF(cx, cy), QPointF(cx - r, cy))
|
||||||
|
path.quadTo(QPointF(cx, cy), QPointF(cx, cy - r))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _panel(p: QPainter) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 4.0, 19.0, 16.0), 3.0, 3.0)
|
||||||
|
_line(p, 9.5, 4.0, 9.5, 20.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Navigation -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("reception", "workbench", "monitor")
|
||||||
|
def _reception(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 3.5, 19.0, 13.5), 3.0, 3.0)
|
||||||
|
_polyline(p, (6.0, 10.5), (8.8, 10.5), (10.6, 7.5), (13.4, 13.5), (15.2, 10.5), (18.0, 10.5))
|
||||||
|
_line(p, 12.0, 17.0, 12.0, 20.5)
|
||||||
|
_line(p, 8.0, 20.5, 16.0, 20.5)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("appointments", "calendar")
|
||||||
|
def _calendar(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 16.5), 3.0, 3.0)
|
||||||
|
_line(p, 3.0, 10.0, 21.0, 10.0)
|
||||||
|
_line(p, 8.0, 2.75, 8.0, 7.0)
|
||||||
|
_line(p, 16.0, 2.75, 16.0, 7.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("prescription_library", "library", "layers")
|
||||||
|
def _layers(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(12.0, 2.5))
|
||||||
|
path.lineTo(QPointF(21.0, 7.0))
|
||||||
|
path.lineTo(QPointF(12.0, 11.5))
|
||||||
|
path.lineTo(QPointF(3.0, 7.0))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
_polyline(p, (3.0, 12.0), (12.0, 16.5), (21.0, 12.0))
|
||||||
|
_polyline(p, (3.0, 16.75), (12.0, 21.25), (21.0, 16.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("prescriptions", "file_check")
|
||||||
|
def _file_check(p: QPainter, w: float) -> None:
|
||||||
|
_page(p)
|
||||||
|
_polyline(p, (8.5, 15.0), (10.9, 17.4), (15.5, 12.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("patients", "users")
|
||||||
|
def _users(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 9.0, 8.0, 3.5)
|
||||||
|
path = QPainterPath(QPointF(2.5, 20.5))
|
||||||
|
path.quadTo(QPointF(2.5, 14.5), QPointF(9.0, 14.5))
|
||||||
|
path.quadTo(QPointF(15.5, 14.5), QPointF(15.5, 20.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
_circle(p, 17.6, 8.0, 2.8)
|
||||||
|
tail = QPainterPath(QPointF(17.0, 13.9))
|
||||||
|
tail.quadTo(QPointF(21.5, 14.6), QPointF(21.5, 20.5))
|
||||||
|
p.drawPath(tail)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("consultations", "consult", "message", "other")
|
||||||
|
def _message(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(6.5, 3.5))
|
||||||
|
path.lineTo(QPointF(17.5, 3.5))
|
||||||
|
path.quadTo(QPointF(20.5, 3.5), QPointF(20.5, 6.5))
|
||||||
|
path.lineTo(QPointF(20.5, 13.5))
|
||||||
|
path.quadTo(QPointF(20.5, 16.5), QPointF(17.5, 16.5))
|
||||||
|
path.lineTo(QPointF(11.5, 16.5))
|
||||||
|
path.lineTo(QPointF(7.0, 20.5))
|
||||||
|
path.lineTo(QPointF(7.0, 16.5))
|
||||||
|
path.quadTo(QPointF(3.5, 16.5), QPointF(3.5, 13.5))
|
||||||
|
path.lineTo(QPointF(3.5, 6.5))
|
||||||
|
path.quadTo(QPointF(3.5, 3.5), QPointF(6.5, 3.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 7.75, 8.25, 16.25, 8.25)
|
||||||
|
_line(p, 7.75, 11.75, 13.0, 11.75)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Shell chrome ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("fold", "panel_close")
|
||||||
|
def _fold(p: QPainter, w: float) -> None:
|
||||||
|
_panel(p)
|
||||||
|
_polyline(p, (17.0, 9.0), (14.0, 12.0), (17.0, 15.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("expand", "panel_open")
|
||||||
|
def _expand(p: QPainter, w: float) -> None:
|
||||||
|
_panel(p)
|
||||||
|
_polyline(p, (14.0, 9.0), (17.0, 12.0), (14.0, 15.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("search")
|
||||||
|
def _search(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 10.5, 10.5, 6.25)
|
||||||
|
_line(p, 15.15, 15.15, 19.75, 19.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("refresh", "rotate")
|
||||||
|
def _refresh(p: QPainter, w: float) -> None:
|
||||||
|
# The arc terminates exactly on the arrow corner so the mark reads as one
|
||||||
|
# continuous stroke. The painters replaced here left a detached triangle
|
||||||
|
# (shell) or two stray lines that never formed a head at all (prescriptions).
|
||||||
|
radius = math.hypot(8.5, 3.5)
|
||||||
|
start = math.degrees(math.atan2(3.5, 8.5))
|
||||||
|
p.drawArc(
|
||||||
|
QRectF(12.0 - radius, 12.0 - radius, radius * 2, radius * 2),
|
||||||
|
round(start * 16),
|
||||||
|
round((360.0 - start) * 16),
|
||||||
|
)
|
||||||
|
_polyline(p, (20.5, 3.5), (20.5, 8.5), (15.5, 8.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("ai", "spark", "sparkle", "assistant")
|
||||||
|
def _ai(p: QPainter, w: float) -> None:
|
||||||
|
_sparkle(p, 10.2, 11.8, 7.2)
|
||||||
|
_sparkle(p, 18.0, 6.0, 3.2)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("fullscreen", "expand-corners", "maximize")
|
||||||
|
def _fullscreen(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (9.0, 3.5), (3.5, 3.5), (3.5, 9.0))
|
||||||
|
_polyline(p, (15.0, 3.5), (20.5, 3.5), (20.5, 9.0))
|
||||||
|
_polyline(p, (3.5, 15.0), (3.5, 20.5), (9.0, 20.5))
|
||||||
|
_polyline(p, (20.5, 15.0), (20.5, 20.5), (15.0, 20.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("minimize")
|
||||||
|
def _minimize(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 5.0, 12.0, 19.0, 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("close", "cross")
|
||||||
|
def _close(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 5.75, 5.75, 18.25, 18.25)
|
||||||
|
_line(p, 18.25, 5.75, 5.75, 18.25)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("down", "chevron_down")
|
||||||
|
def _down(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (5.5, 9.0), (12.0, 15.5), (18.5, 9.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("up", "chevron_up")
|
||||||
|
def _up(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (5.5, 15.0), (12.0, 8.5), (18.5, 15.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("left", "chevron_left")
|
||||||
|
def _left(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (15.0, 5.5), (8.5, 12.0), (15.0, 18.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("right", "chevron_right")
|
||||||
|
def _right(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (9.0, 5.5), (15.5, 12.0), (9.0, 18.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("notification", "bell")
|
||||||
|
def _bell(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(6.75, 17.5))
|
||||||
|
path.lineTo(QPointF(6.75, 10.75))
|
||||||
|
path.arcTo(QRectF(6.75, 5.0, 10.5, 11.5), 180.0, -180.0)
|
||||||
|
path.lineTo(QPointF(17.25, 17.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 4.5, 17.5, 19.5, 17.5)
|
||||||
|
p.drawArc(QRectF(10.0, 17.4, 4.0, 3.6), 180 * 16, 180 * 16)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("settings", "sliders")
|
||||||
|
def _settings(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 3.5, 8.5, 20.5, 8.5)
|
||||||
|
_line(p, 3.5, 15.5, 20.5, 15.5)
|
||||||
|
_circle(p, 9.0, 8.5, 2.4)
|
||||||
|
_circle(p, 15.0, 15.5, 2.4)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Row and toolbar actions ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("eye", "view")
|
||||||
|
def _eye(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(2.5, 12.0))
|
||||||
|
path.quadTo(QPointF(12.0, 2.5), QPointF(21.5, 12.0))
|
||||||
|
path.quadTo(QPointF(12.0, 21.5), QPointF(2.5, 12.0))
|
||||||
|
p.drawPath(path)
|
||||||
|
_circle(p, 12.0, 12.0, 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("pencil", "edit")
|
||||||
|
def _pencil(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(16.25, 3.0))
|
||||||
|
path.lineTo(QPointF(20.75, 7.5))
|
||||||
|
path.lineTo(QPointF(8.5, 19.75))
|
||||||
|
path.lineTo(QPointF(3.0, 21.0))
|
||||||
|
path.lineTo(QPointF(4.25, 15.5))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 13.0, 6.25, 17.5, 10.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("trash", "delete")
|
||||||
|
def _trash(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 3.5, 6.25, 20.5, 6.25)
|
||||||
|
_polyline(p, (9.0, 6.25), (9.0, 3.5), (15.0, 3.5), (15.0, 6.25))
|
||||||
|
path = QPainterPath(QPointF(5.75, 6.25))
|
||||||
|
path.lineTo(QPointF(6.6, 19.4))
|
||||||
|
path.quadTo(QPointF(6.7, 20.5), QPointF(7.8, 20.5))
|
||||||
|
path.lineTo(QPointF(16.2, 20.5))
|
||||||
|
path.quadTo(QPointF(17.3, 20.5), QPointF(17.4, 19.4))
|
||||||
|
path.lineTo(QPointF(18.25, 6.25))
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 10.0, 10.0, 10.0, 16.75)
|
||||||
|
_line(p, 14.0, 10.0, 14.0, 16.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("plus", "add")
|
||||||
|
def _plus(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 12.0, 4.75, 12.0, 19.25)
|
||||||
|
_line(p, 4.75, 12.0, 19.25, 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("check")
|
||||||
|
def _check(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (4.75, 12.5), (9.75, 17.5), (19.25, 7.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("check_circle", "health")
|
||||||
|
def _check_circle(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_polyline(p, (7.75, 12.25), (10.75, 15.25), (16.25, 9.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("checkbox")
|
||||||
|
def _checkbox(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(3.75, 3.75, 16.5, 16.5), 3.5, 3.5)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("lock")
|
||||||
|
def _lock(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(4.5, 10.5, 15.0, 10.0), 2.75, 2.75)
|
||||||
|
p.drawArc(QRectF(8.0, 3.75, 8.0, 13.5), 0, 180 * 16)
|
||||||
|
_dot(p, 12.0, 15.5, 1.35)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("document", "file")
|
||||||
|
def _document(p: QPainter, w: float) -> None:
|
||||||
|
_page(p)
|
||||||
|
_line(p, 8.5, 12.75, 15.5, 12.75)
|
||||||
|
_line(p, 8.5, 16.75, 13.25, 16.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("report")
|
||||||
|
def _report(p: QPainter, w: float) -> None:
|
||||||
|
_page(p)
|
||||||
|
_line(p, 8.75, 17.75, 8.75, 14.25)
|
||||||
|
_line(p, 12.0, 17.75, 12.0, 10.5)
|
||||||
|
_line(p, 15.25, 17.75, 15.25, 12.75)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("list")
|
||||||
|
def _list(p: QPainter, w: float) -> None:
|
||||||
|
for y in (6.5, 12.0, 17.5):
|
||||||
|
_dot(p, 4.5, y, 1.2)
|
||||||
|
_line(p, 8.75, y, 19.5, y)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("user", "person")
|
||||||
|
def _user(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 8.0, 4.0)
|
||||||
|
path = QPainterPath(QPointF(4.25, 20.5))
|
||||||
|
path.quadTo(QPointF(4.25, 14.5), QPointF(12.0, 14.5))
|
||||||
|
path.quadTo(QPointF(19.75, 14.5), QPointF(19.75, 20.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("remove", "minus_circle")
|
||||||
|
def _remove(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_line(p, 8.0, 12.0, 16.0, 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("stop", "close_circle")
|
||||||
|
def _stop(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_line(p, 9.0, 9.0, 15.0, 15.0)
|
||||||
|
_line(p, 15.0, 9.0, 9.0, 15.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("info")
|
||||||
|
def _info(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_line(p, 12.0, 11.25, 12.0, 16.5)
|
||||||
|
_dot(p, 12.0, 7.75, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("picture", "image")
|
||||||
|
def _picture(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(3.0, 4.5, 18.0, 15.0), 3.0, 3.0)
|
||||||
|
_circle(p, 7.75, 8.75, 1.6)
|
||||||
|
_polyline(p, (3.5, 17.75), (9.75, 12.25), (13.25, 15.5), (15.75, 13.25), (20.5, 17.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("meds", "pill")
|
||||||
|
def _meds(p: QPainter, w: float) -> None:
|
||||||
|
p.save()
|
||||||
|
p.translate(12.0, 12.0)
|
||||||
|
p.rotate(-45.0)
|
||||||
|
p.drawRoundedRect(QRectF(-9.25, -4.5, 18.5, 9.0), 4.5, 4.5)
|
||||||
|
_line(p, 0.0, -4.5, 0.0, 4.5)
|
||||||
|
p.restore()
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("daily", "clipboard")
|
||||||
|
def _clipboard(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(8.5, 4.5))
|
||||||
|
path.lineTo(QPointF(6.75, 4.5))
|
||||||
|
path.quadTo(QPointF(4.25, 4.5), QPointF(4.25, 7.0))
|
||||||
|
path.lineTo(QPointF(4.25, 19.0))
|
||||||
|
path.quadTo(QPointF(4.25, 21.5), QPointF(6.75, 21.5))
|
||||||
|
path.lineTo(QPointF(17.25, 21.5))
|
||||||
|
path.quadTo(QPointF(19.75, 21.5), QPointF(19.75, 19.0))
|
||||||
|
path.lineTo(QPointF(19.75, 7.0))
|
||||||
|
path.quadTo(QPointF(19.75, 4.5), QPointF(17.25, 4.5))
|
||||||
|
path.lineTo(QPointF(15.5, 4.5))
|
||||||
|
p.drawPath(path)
|
||||||
|
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 4.0), 1.5, 1.5)
|
||||||
|
_line(p, 8.0, 12.0, 16.0, 12.0)
|
||||||
|
_line(p, 8.0, 16.25, 13.5, 16.25)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("followup", "calendar_clock")
|
||||||
|
def _followup(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(13.0, 20.0))
|
||||||
|
path.lineTo(QPointF(5.5, 20.0))
|
||||||
|
path.quadTo(QPointF(3.0, 20.0), QPointF(3.0, 17.5))
|
||||||
|
path.lineTo(QPointF(3.0, 7.5))
|
||||||
|
path.quadTo(QPointF(3.0, 5.0), QPointF(5.5, 5.0))
|
||||||
|
path.lineTo(QPointF(14.5, 5.0))
|
||||||
|
path.quadTo(QPointF(17.0, 5.0), QPointF(17.0, 7.5))
|
||||||
|
path.lineTo(QPointF(17.0, 9.0))
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 3.0, 9.5, 17.0, 9.5)
|
||||||
|
_line(p, 7.0, 2.75, 7.0, 6.75)
|
||||||
|
_line(p, 13.0, 2.75, 13.0, 6.75)
|
||||||
|
_circle(p, 16.75, 16.75, 4.5)
|
||||||
|
_polyline(p, (16.75, 14.25), (16.75, 16.75), (18.9, 16.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("brand", "logo")
|
||||||
|
def _brand(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 12.0, 8.75)
|
||||||
|
_polyline(p, (7.0, 12.0), (10.0, 12.0), (11.5, 8.5), (13.5, 15.5), (15.0, 12.0), (17.0, 12.0))
|
||||||
|
|
||||||
|
|
||||||
|
# --- AI consultation ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("chart", "analytics")
|
||||||
|
def _analytics(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (3.5, 3.0), (3.5, 20.5), (21.0, 20.5))
|
||||||
|
_polyline(p, (7.0, 16.5), (10.5, 8.5), (14.0, 13.0), (20.0, 5.5))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("trend")
|
||||||
|
def _trend(p: QPainter, w: float) -> None:
|
||||||
|
_polyline(p, (3.0, 18.0), (8.5, 9.5), (12.5, 13.5), (21.0, 5.5))
|
||||||
|
_polyline(p, (15.5, 5.5), (21.0, 5.5), (21.0, 11.0))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("alert", "warning")
|
||||||
|
def _alert(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(12.0, 3.0))
|
||||||
|
path.lineTo(QPointF(21.5, 20.0))
|
||||||
|
path.lineTo(QPointF(2.5, 20.0))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 12.0, 9.5, 12.0, 14.5)
|
||||||
|
_dot(p, 12.0, 17.4, 1.05)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("mic", "microphone")
|
||||||
|
def _mic(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(8.5, 2.5, 7.0, 12.0), 3.5, 3.5)
|
||||||
|
p.drawArc(QRectF(5.0, 6.0, 14.0, 14.0), 0, -180 * 16)
|
||||||
|
_line(p, 12.0, 17.5, 12.0, 21.0)
|
||||||
|
_line(p, 8.25, 21.0, 15.75, 21.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("send")
|
||||||
|
def _send(p: QPainter, w: float) -> None:
|
||||||
|
path = QPainterPath(QPointF(21.0, 3.0))
|
||||||
|
path.lineTo(QPointF(2.5, 10.5))
|
||||||
|
path.lineTo(QPointF(10.25, 13.75))
|
||||||
|
path.lineTo(QPointF(13.5, 21.5))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
_line(p, 10.25, 13.75, 21.0, 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("qr", "qrcode")
|
||||||
|
def _qr(p: QPainter, w: float) -> None:
|
||||||
|
"""Three finder squares plus a few modules - the shape people scan for."""
|
||||||
|
|
||||||
|
for x, y in ((3.0, 3.0), (14.0, 3.0), (3.0, 14.0)):
|
||||||
|
p.drawRoundedRect(QRectF(x, y, 7.0, 7.0), 1.5, 1.5)
|
||||||
|
_dot(p, x + 3.5, y + 3.5, 1.15)
|
||||||
|
_line(p, 14.5, 14.5, 14.5, 17.0)
|
||||||
|
_line(p, 18.0, 14.5, 21.0, 14.5)
|
||||||
|
_line(p, 17.5, 18.0, 17.5, 21.0)
|
||||||
|
_dot(p, 20.75, 20.75, 1.15)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("video", "call")
|
||||||
|
def _video(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 6.0, 13.5, 12.0), 3.0, 3.0)
|
||||||
|
path = QPainterPath(QPointF(16.0, 10.5))
|
||||||
|
path.lineTo(QPointF(21.5, 7.25))
|
||||||
|
path.lineTo(QPointF(21.5, 16.75))
|
||||||
|
path.lineTo(QPointF(16.0, 13.5))
|
||||||
|
path.closeSubpath()
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Clinical measures ----------------------------------------------------
|
||||||
|
# The vital-sign tiles and the lifestyle row used to carry two more bespoke
|
||||||
|
# painters (22 px / 1.35 px stroke and 16 px / 1.3 px stroke). Beyond the extra
|
||||||
|
# weights, two of their glyphs were simply wrong: "weight" read as a padlock and
|
||||||
|
# "BMI" as the Venus symbol.
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("height")
|
||||||
|
def _height(p: QPainter, w: float) -> None:
|
||||||
|
_line(p, 6.0, 3.5, 18.0, 3.5)
|
||||||
|
_line(p, 6.0, 20.5, 18.0, 20.5)
|
||||||
|
_line(p, 12.0, 5.75, 12.0, 18.25)
|
||||||
|
_polyline(p, (9.5, 8.25), (12.0, 5.75), (14.5, 8.25))
|
||||||
|
_polyline(p, (9.5, 15.75), (12.0, 18.25), (14.5, 15.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("weight", "scale")
|
||||||
|
def _weight(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(3.0, 5.0, 18.0, 14.5), 3.5, 3.5)
|
||||||
|
p.drawArc(QRectF(7.0, 10.0, 10.0, 10.0), 25 * 16, 130 * 16)
|
||||||
|
_line(p, 12.0, 15.0, 9.9, 10.9)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("bmi", "body")
|
||||||
|
def _bmi(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 12.0, 5.0, 2.75)
|
||||||
|
_line(p, 12.0, 7.75, 12.0, 15.0)
|
||||||
|
_line(p, 7.25, 11.0, 16.75, 11.0)
|
||||||
|
_polyline(p, (8.5, 20.75), (12.0, 15.0), (15.5, 20.75))
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("blood_pressure", "gauge", "bp")
|
||||||
|
def _blood_pressure(p: QPainter, w: float) -> None:
|
||||||
|
p.drawArc(QRectF(3.0, 5.5, 18.0, 18.0), 0, 180 * 16)
|
||||||
|
_line(p, 3.0, 14.5, 21.0, 14.5)
|
||||||
|
_line(p, 12.0, 14.5, 16.4, 9.4)
|
||||||
|
_dot(p, 12.0, 14.5, 1.15)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("pulse", "heart")
|
||||||
|
def _pulse(p: QPainter, w: float) -> None:
|
||||||
|
heart = QPainterPath(QPointF(12.0, 20.25))
|
||||||
|
heart.cubicTo(QPointF(3.2, 13.6), QPointF(2.2, 9.6), QPointF(4.7, 6.7))
|
||||||
|
heart.cubicTo(QPointF(7.2, 4.0), QPointF(10.5, 4.8), QPointF(12.0, 7.7))
|
||||||
|
heart.cubicTo(QPointF(13.5, 4.8), QPointF(16.8, 4.0), QPointF(19.3, 6.7))
|
||||||
|
heart.cubicTo(QPointF(21.8, 9.6), QPointF(20.8, 13.6), QPointF(12.0, 20.25))
|
||||||
|
heart.closeSubpath()
|
||||||
|
p.drawPath(heart)
|
||||||
|
_polyline(
|
||||||
|
p, (5.6, 12.4), (9.0, 12.4), (10.6, 9.7), (13.2, 15.1), (14.7, 12.4), (18.4, 12.4)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("smoke", "cigarette")
|
||||||
|
def _smoke(p: QPainter, w: float) -> None:
|
||||||
|
p.drawRoundedRect(QRectF(2.5, 14.0, 14.0, 5.0), 1.75, 1.75)
|
||||||
|
_line(p, 13.0, 14.0, 13.0, 19.0)
|
||||||
|
curl = QPainterPath(QPointF(19.0, 12.0))
|
||||||
|
curl.quadTo(QPointF(21.5, 9.5), QPointF(19.0, 7.5))
|
||||||
|
curl.quadTo(QPointF(16.5, 5.5), QPointF(19.0, 3.5))
|
||||||
|
p.drawPath(curl)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("drink", "glass")
|
||||||
|
def _drink(p: QPainter, w: float) -> None:
|
||||||
|
bowl = QPainterPath(QPointF(6.5, 3.5))
|
||||||
|
bowl.lineTo(QPointF(17.5, 3.5))
|
||||||
|
bowl.lineTo(QPointF(13.75, 12.5))
|
||||||
|
bowl.lineTo(QPointF(10.25, 12.5))
|
||||||
|
bowl.closeSubpath()
|
||||||
|
p.drawPath(bowl)
|
||||||
|
_line(p, 7.75, 7.5, 16.25, 7.5)
|
||||||
|
_line(p, 12.0, 12.5, 12.0, 20.0)
|
||||||
|
_line(p, 8.0, 20.0, 16.0, 20.0)
|
||||||
|
|
||||||
|
|
||||||
|
@_glyph("exercise", "run")
|
||||||
|
def _exercise(p: QPainter, w: float) -> None:
|
||||||
|
_circle(p, 15.75, 4.75, 2.5)
|
||||||
|
_polyline(p, (14.5, 9.0), (9.75, 12.5), (6.0, 20.5))
|
||||||
|
_polyline(p, (14.5, 9.0), (18.75, 12.75), (21.0, 10.75))
|
||||||
|
_polyline(p, (11.75, 11.0), (15.25, 16.0), (13.25, 20.75))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Painting -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _device_ratio() -> float:
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return 1.0
|
||||||
|
screen = app.primaryScreen()
|
||||||
|
if screen is None:
|
||||||
|
return 1.0
|
||||||
|
return max(1.0, float(screen.devicePixelRatio()))
|
||||||
|
|
||||||
|
|
||||||
|
def _render(kind: str, color: str, size: int) -> QPixmap:
|
||||||
|
canvas = crisp_pixmap(size)
|
||||||
|
draw = _GLYPHS.get(kind)
|
||||||
|
if draw is None:
|
||||||
|
return canvas
|
||||||
|
painter = QPainter(canvas)
|
||||||
|
try:
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
# Inset the grid by half a stroke on every edge. Without it a glyph that
|
||||||
|
# legitimately reaches grid unit 24 loses the outer half of its line to
|
||||||
|
# the pixmap boundary at the smaller sizes - which is exactly how the old
|
||||||
|
# painters lost the shell star's companion dot and flattened the top of
|
||||||
|
# the calendar. Insetting here means every glyph can use the full grid.
|
||||||
|
weight = stroke_px(size)
|
||||||
|
scale = (size - weight) / GRID
|
||||||
|
painter.translate(weight / 2.0, weight / 2.0)
|
||||||
|
painter.scale(scale, scale)
|
||||||
|
# The pen width is expressed on the design grid, so the on-screen weight
|
||||||
|
# stays the same fraction of the box at every size the shell asks for.
|
||||||
|
width = weight / scale
|
||||||
|
pen = QPen(QColor(color), width)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
draw(painter, width)
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1024)
|
||||||
|
def _cached_pixmap(kind: str, color: str, size: int, ratio: float) -> QPixmap:
|
||||||
|
del ratio # part of the cache key only; crisp_pixmap reads it back itself
|
||||||
|
return _render(kind, color, size)
|
||||||
|
|
||||||
|
|
||||||
|
def pixmap(kind: str, color: str = "default", size: int = 18) -> QPixmap:
|
||||||
|
"""Return a cached, device-pixel-correct pixmap for ``kind``."""
|
||||||
|
|
||||||
|
return _cached_pixmap(kind, resolve_color(color), int(size), _device_ratio())
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1024)
|
||||||
|
def _cached_icon(kind: str, color: str, size: int, ratio: float) -> QIcon:
|
||||||
|
result = QIcon(_cached_pixmap(kind, color, size, ratio))
|
||||||
|
result.addPixmap(
|
||||||
|
_cached_pixmap(kind, ROLES["disabled"], size, ratio),
|
||||||
|
QIcon.Mode.Disabled,
|
||||||
|
QIcon.State.Off,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def icon(kind: str, color: str = "default", size: int = 18) -> QIcon:
|
||||||
|
"""Return a cached icon with a matching disabled variant already attached.
|
||||||
|
|
||||||
|
``color`` accepts a role name from :data:`ROLES` or a literal colour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _cached_icon(kind, resolve_color(color), int(size), _device_ratio())
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def _cached_state_icon(
|
||||||
|
kind: str,
|
||||||
|
size: int,
|
||||||
|
normal: str,
|
||||||
|
active: str,
|
||||||
|
checked: str,
|
||||||
|
disabled: str,
|
||||||
|
ratio: float,
|
||||||
|
) -> QIcon:
|
||||||
|
result = QIcon()
|
||||||
|
result.addPixmap(_cached_pixmap(kind, normal, size, ratio), QIcon.Mode.Normal, QIcon.State.Off)
|
||||||
|
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Normal, QIcon.State.On)
|
||||||
|
result.addPixmap(_cached_pixmap(kind, active, size, ratio), QIcon.Mode.Active, QIcon.State.Off)
|
||||||
|
result.addPixmap(_cached_pixmap(kind, checked, size, ratio), QIcon.Mode.Active, QIcon.State.On)
|
||||||
|
result.addPixmap(
|
||||||
|
_cached_pixmap(kind, disabled, size, ratio), QIcon.Mode.Disabled, QIcon.State.Off
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def state_icon(
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
size: int = 18,
|
||||||
|
normal: str = "muted",
|
||||||
|
active: str = "strong",
|
||||||
|
checked: str = "inverse",
|
||||||
|
disabled: str = "disabled",
|
||||||
|
) -> QIcon:
|
||||||
|
"""Return an icon carrying its own hover / selected / disabled colours.
|
||||||
|
|
||||||
|
Qt only tints an icon when a widget asks it to, so a single-pixmap icon on a
|
||||||
|
selected navigation row keeps its resting grey and reads as switched off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return _cached_state_icon(
|
||||||
|
kind,
|
||||||
|
int(size),
|
||||||
|
resolve_color(normal),
|
||||||
|
resolve_color(active),
|
||||||
|
resolve_color(checked),
|
||||||
|
resolve_color(disabled),
|
||||||
|
_device_ratio(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def available_kinds() -> tuple[str, ...]:
|
||||||
|
"""Every glyph name this module answers to, aliases included."""
|
||||||
|
|
||||||
|
return tuple(sorted(_GLYPHS))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache() -> None:
|
||||||
|
"""Drop cached pixmaps - used when the display scale factor changes."""
|
||||||
|
|
||||||
|
_cached_pixmap.cache_clear()
|
||||||
|
_cached_icon.cache_clear()
|
||||||
|
_cached_state_icon.cache_clear()
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""Incremental server lists with a compact status footer and stable view state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QEvent, QSignalBlocker, Qt, QTimer
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractScrollArea,
|
||||||
|
QCheckBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .widgets import first_value, get_value, page_items, page_total, run_async
|
||||||
|
|
||||||
|
|
||||||
|
def record_key(row: Any) -> str:
|
||||||
|
value = first_value(
|
||||||
|
row, "id", "prescription_id", "appointment_id", "diagnosis_id", "order_id", "patient_id"
|
||||||
|
)
|
||||||
|
return str(value) if value is not None else repr(row)
|
||||||
|
|
||||||
|
|
||||||
|
class ListSnapshot:
|
||||||
|
"""Keep repository metadata available while replacing only the list payload."""
|
||||||
|
|
||||||
|
def __init__(self, rows: list[Any], total: int, source: Any) -> None:
|
||||||
|
self.items = rows
|
||||||
|
self.total = total
|
||||||
|
self.source = source
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
return get_value(self.source, name, None)
|
||||||
|
|
||||||
|
|
||||||
|
class InfiniteList(QWidget):
|
||||||
|
"""Bind to a scrolling view; fetch pages only as the visible list needs them.
|
||||||
|
|
||||||
|
Reloads use captured query arguments. Refreshing the same query rebuilds the
|
||||||
|
loaded prefix atomically, so polling neither drops appended rows nor mixes
|
||||||
|
an updated first page with an old tail. Failed appends retain the prior page
|
||||||
|
and can be retried explicitly without an automatic request loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("InfiniteList")
|
||||||
|
self.setFixedHeight(24)
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(12, 0, 12, 0)
|
||||||
|
self.summary_label = QLabel("", self)
|
||||||
|
self.summary_label.setStyleSheet(
|
||||||
|
"color: #5D6B80; font-size: 12px; background: transparent;"
|
||||||
|
)
|
||||||
|
layout.addWidget(self.summary_label)
|
||||||
|
layout.addStretch(1)
|
||||||
|
self.retry_button = QPushButton("加载失败,点击重试", self)
|
||||||
|
self.retry_button.setFlat(True)
|
||||||
|
self.retry_button.setStyleSheet(
|
||||||
|
"color: #1769E8; font-size: 12px; padding: 0 4px; border: none; background: transparent; min-height: 20px; max-height: 20px; min-width: 0;"
|
||||||
|
)
|
||||||
|
self.retry_button.hide()
|
||||||
|
self.retry_button.clicked.connect(self.retry)
|
||||||
|
layout.addWidget(self.retry_button)
|
||||||
|
self.page_size = page_size
|
||||||
|
self.page = 0
|
||||||
|
self.total = 0
|
||||||
|
self.rows: list[Any] = []
|
||||||
|
self.loading = False
|
||||||
|
self.has_more = False
|
||||||
|
self._generation = 0
|
||||||
|
self._query_key: Any = object()
|
||||||
|
self._view: QAbstractScrollArea | None = None
|
||||||
|
self._views: list[QAbstractScrollArea] = []
|
||||||
|
self._error = False
|
||||||
|
self._configured = False
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setSingleShot(True)
|
||||||
|
self._timer.timeout.connect(self._maybe_load_more)
|
||||||
|
|
||||||
|
def bind(self, view: QAbstractScrollArea) -> None:
|
||||||
|
if view in self._views:
|
||||||
|
return
|
||||||
|
self._views.append(view)
|
||||||
|
self._view = view
|
||||||
|
view.verticalScrollBar().valueChanged.connect(self._schedule_check)
|
||||||
|
view.verticalScrollBar().rangeChanged.connect(self._schedule_check)
|
||||||
|
view.viewport().installEventFilter(self)
|
||||||
|
|
||||||
|
def eventFilter(self, watched: Any, event: Any) -> bool:
|
||||||
|
if event.type() in (QEvent.Type.Show, QEvent.Type.Resize):
|
||||||
|
self._schedule_check()
|
||||||
|
return super().eventFilter(watched, event)
|
||||||
|
|
||||||
|
def _schedule_check(self, *_: Any) -> None:
|
||||||
|
self._timer.start(30)
|
||||||
|
|
||||||
|
def _maybe_load_more(self) -> None:
|
||||||
|
view = next((v for v in self._views if v.isVisible()), None)
|
||||||
|
if view is None or self.loading or self._error or not self.has_more:
|
||||||
|
return
|
||||||
|
bar = view.verticalScrollBar()
|
||||||
|
# pageStep respects both per-item and per-pixel Qt scrolling modes.
|
||||||
|
if bar.maximum() - bar.value() <= max(1, bar.pageStep() // 4):
|
||||||
|
self.load_more()
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
"""Disarm callbacks when a reusable dialog switches to another record."""
|
||||||
|
self._generation += 1
|
||||||
|
self._configured = False
|
||||||
|
self.loading = self.has_more = self._error = False
|
||||||
|
self.rows, self.page, self.total = [], 0, 0
|
||||||
|
self._timer.stop()
|
||||||
|
self.retry_button.hide()
|
||||||
|
self._status()
|
||||||
|
|
||||||
|
reset = invalidate
|
||||||
|
|
||||||
|
def reload(
|
||||||
|
self,
|
||||||
|
fetch: Callable[[int], Any],
|
||||||
|
apply: Callable[[Any], None],
|
||||||
|
on_error: Callable[[Exception], None],
|
||||||
|
*,
|
||||||
|
runner: Callable[..., Any] = run_async,
|
||||||
|
query_key: Any = None,
|
||||||
|
on_finished: Callable[[], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
same_query = self._configured and query_key == self._query_key
|
||||||
|
if same_query and self.loading:
|
||||||
|
# Polling must not restart a slow prefix refresh indefinitely. The
|
||||||
|
# caller may have advanced its own generation, so use its latest
|
||||||
|
# render/error closures while the captured request finishes.
|
||||||
|
self._apply, self._on_error = apply, on_error
|
||||||
|
self._on_finished = on_finished
|
||||||
|
return
|
||||||
|
self._generation += 1
|
||||||
|
self._query_key = query_key
|
||||||
|
self._configured = True
|
||||||
|
self._fetch, self._apply, self._on_error = fetch, apply, on_error
|
||||||
|
self._runner, self._on_finished = runner, on_finished
|
||||||
|
self._target = max(1, self.page) if same_query else 1
|
||||||
|
self._reset_view = not same_query
|
||||||
|
if not same_query:
|
||||||
|
self.rows, self.page, self.total = [], 0, 0
|
||||||
|
self.has_more = False
|
||||||
|
self._render(ListSnapshot([], 0, None), preserve=False)
|
||||||
|
self._begin(1, [], refresh=True)
|
||||||
|
|
||||||
|
def load_more(self) -> None:
|
||||||
|
if not self._configured or self.loading or self._error or not self.has_more:
|
||||||
|
return
|
||||||
|
self._reset_view = False
|
||||||
|
self._target = self.page + 1
|
||||||
|
self._begin(self.page + 1, list(self.rows), refresh=False)
|
||||||
|
|
||||||
|
def retry(self) -> None:
|
||||||
|
if self.loading or not self._error:
|
||||||
|
return
|
||||||
|
self._begin(self._failed_page, list(self._failed_rows), refresh=self._failed_refresh)
|
||||||
|
|
||||||
|
def _begin(self, page: int, rows: list[Any], *, refresh: bool) -> None:
|
||||||
|
self.loading = True
|
||||||
|
self._error = False
|
||||||
|
self.retry_button.hide()
|
||||||
|
self.summary_label.setText(
|
||||||
|
f"已加载 {len(self.rows)} 条 · 正在加载…" if self.rows else "正在加载…"
|
||||||
|
)
|
||||||
|
generation = self._generation
|
||||||
|
fetch = self._fetch
|
||||||
|
self._runner(
|
||||||
|
lambda: fetch(page),
|
||||||
|
on_success=lambda result: self._received(result, generation, page, rows, refresh),
|
||||||
|
on_error=lambda error: self._failed(error, generation, page, rows, refresh),
|
||||||
|
on_finished=lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _received(
|
||||||
|
self, result: Any, generation: int, page: int, prior: list[Any], refresh: bool
|
||||||
|
) -> None:
|
||||||
|
if generation != self._generation:
|
||||||
|
return
|
||||||
|
incoming = page_items(result)
|
||||||
|
merged = {record_key(row): row for row in prior}
|
||||||
|
before = len(merged)
|
||||||
|
for row in incoming:
|
||||||
|
merged[record_key(row)] = row
|
||||||
|
rows = list(merged.values())
|
||||||
|
total = page_total(result, -1)
|
||||||
|
more = (
|
||||||
|
bool(incoming)
|
||||||
|
and len(rows) > before
|
||||||
|
and (len(rows) < total if total >= 0 else len(incoming) >= self.page_size)
|
||||||
|
)
|
||||||
|
# Retain first-page metadata (scope, counts, filter choices) on refresh.
|
||||||
|
if page == 1:
|
||||||
|
self._refresh_source = result
|
||||||
|
source = self._refresh_source
|
||||||
|
if refresh and page < self._target and more:
|
||||||
|
self._begin(page + 1, rows, refresh=True)
|
||||||
|
return
|
||||||
|
self.rows, self.page = rows, page
|
||||||
|
self.total = max(len(rows), total)
|
||||||
|
self.has_more = more
|
||||||
|
self._render(ListSnapshot(rows, self.total, source), preserve=not self._reset_view)
|
||||||
|
self.loading = False
|
||||||
|
self._status()
|
||||||
|
if self._on_finished is not None:
|
||||||
|
self._on_finished()
|
||||||
|
self._schedule_check()
|
||||||
|
|
||||||
|
def _failed(
|
||||||
|
self, error: Exception, generation: int, page: int, rows: list[Any], refresh: bool
|
||||||
|
) -> None:
|
||||||
|
if generation != self._generation:
|
||||||
|
return
|
||||||
|
self.loading = False
|
||||||
|
self._error = True
|
||||||
|
self._failed_page, self._failed_rows, self._failed_refresh = page, rows, refresh
|
||||||
|
self.summary_label.setText(f"已加载 {len(self.rows)} 条" if self.rows else "暂未加载数据")
|
||||||
|
self.retry_button.show()
|
||||||
|
self._on_error(error)
|
||||||
|
if self._on_finished is not None:
|
||||||
|
self._on_finished()
|
||||||
|
|
||||||
|
def _status(self) -> None:
|
||||||
|
if self.has_more:
|
||||||
|
self.summary_label.setText(f"已加载 {len(self.rows)} / {self.total} 条 · 下拉加载更多")
|
||||||
|
else:
|
||||||
|
if self.total > len(self.rows):
|
||||||
|
self.summary_label.setText(
|
||||||
|
f"已加载 {len(self.rows)} / {self.total} 条 · 暂无更多数据"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.summary_label.setText(
|
||||||
|
f"共 {len(self.rows)} 条 · 已全部加载" if self.rows else "暂无数据"
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_state(self, page: int, total: int) -> None:
|
||||||
|
"""Compatibility for existing render callbacks; requests own the state."""
|
||||||
|
del page, total
|
||||||
|
self._status()
|
||||||
|
|
||||||
|
def _render(self, snapshot: ListSnapshot, *, preserve: bool) -> None:
|
||||||
|
view = next((v for v in self._views if v.isVisible()), self._view)
|
||||||
|
if view is None:
|
||||||
|
self._apply(snapshot)
|
||||||
|
return
|
||||||
|
bar = view.verticalScrollBar()
|
||||||
|
scroll = bar.value()
|
||||||
|
horizontal_scroll = view.horizontalScrollBar().value()
|
||||||
|
selected: set[str] = set()
|
||||||
|
checks: dict[tuple[str, int], Qt.CheckState] = {}
|
||||||
|
widget_checks: dict[tuple[str, int, int], bool] = {}
|
||||||
|
if preserve and isinstance(view, QTableWidget):
|
||||||
|
for row in range(view.rowCount()):
|
||||||
|
first = view.item(row, 0)
|
||||||
|
if first is None:
|
||||||
|
continue
|
||||||
|
key = record_key(first.data(Qt.ItemDataRole.UserRole))
|
||||||
|
if first.isSelected():
|
||||||
|
selected.add(key)
|
||||||
|
for column in range(view.columnCount()):
|
||||||
|
item = view.item(row, column)
|
||||||
|
if (
|
||||||
|
item is not None
|
||||||
|
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable
|
||||||
|
and item.data(Qt.ItemDataRole.CheckStateRole) is not None
|
||||||
|
):
|
||||||
|
checks[key, column] = item.checkState()
|
||||||
|
widget = view.cellWidget(row, column)
|
||||||
|
if widget is not None:
|
||||||
|
boxes = (
|
||||||
|
[widget]
|
||||||
|
if isinstance(widget, QCheckBox)
|
||||||
|
else widget.findChildren(QCheckBox)
|
||||||
|
)
|
||||||
|
for index, box in enumerate(boxes):
|
||||||
|
widget_checks[key, column, index] = box.isChecked()
|
||||||
|
blocker = QSignalBlocker(view)
|
||||||
|
try:
|
||||||
|
self._apply(snapshot)
|
||||||
|
if preserve and isinstance(view, QTableWidget):
|
||||||
|
if selected:
|
||||||
|
view.clearSelection()
|
||||||
|
for row in range(view.rowCount()):
|
||||||
|
first = view.item(row, 0)
|
||||||
|
if first is None:
|
||||||
|
continue
|
||||||
|
key = record_key(first.data(Qt.ItemDataRole.UserRole))
|
||||||
|
if key in selected:
|
||||||
|
view.selectRow(row)
|
||||||
|
for column in range(view.columnCount()):
|
||||||
|
item = view.item(row, column)
|
||||||
|
if item is not None and (key, column) in checks:
|
||||||
|
item.setCheckState(checks[key, column])
|
||||||
|
widget = view.cellWidget(row, column)
|
||||||
|
if widget is not None:
|
||||||
|
boxes = (
|
||||||
|
[widget]
|
||||||
|
if isinstance(widget, QCheckBox)
|
||||||
|
else widget.findChildren(QCheckBox)
|
||||||
|
)
|
||||||
|
for index, box in enumerate(boxes):
|
||||||
|
if (key, column, index) in widget_checks:
|
||||||
|
box.setChecked(widget_checks[key, column, index])
|
||||||
|
bar.setValue(min(scroll, bar.maximum()) if preserve else bar.minimum())
|
||||||
|
view.horizontalScrollBar().setValue(horizontal_scroll)
|
||||||
|
finally:
|
||||||
|
blocker.unblock()
|
||||||
|
if isinstance(view, QTableWidget):
|
||||||
|
view.itemSelectionChanged.emit()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["InfiniteList", "ListSnapshot"]
|
||||||
@@ -21,6 +21,7 @@ from PySide6.QtGui import (
|
|||||||
QResizeEvent,
|
QResizeEvent,
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
QGraphicsDropShadowEffect,
|
QGraphicsDropShadowEffect,
|
||||||
@@ -43,7 +44,7 @@ from PySide6.QtWidgets import (
|
|||||||
from doctor_workstation import __version__
|
from doctor_workstation import __version__
|
||||||
from doctor_workstation.resources import app_icon_path, brand_lockup_path
|
from doctor_workstation.resources import app_icon_path, brand_lockup_path
|
||||||
|
|
||||||
from .theme import crisp_pixmap
|
from . import icons
|
||||||
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
|
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ class _VisibleCheckBox(QCheckBox):
|
|||||||
)
|
)
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
color = QColor("#FFFFFF" if self.isEnabled() else "#98A2B3")
|
color = QColor("#FFFFFF" if self.isEnabled() else "#8E8F90")
|
||||||
painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||||||
painter.drawLine(
|
painter.drawLine(
|
||||||
QPoint(indicator.left() + 4, indicator.center().y()),
|
QPoint(indicator.left() + 4, indicator.center().y()),
|
||||||
@@ -90,7 +91,7 @@ class _AccountLineEdit(QLineEdit):
|
|||||||
super().paintEvent(event)
|
super().paintEvent(event)
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
painter.setPen(_round_pen("#8292B6", 1.5))
|
painter.setPen(_round_pen("#6A6B6D", 1.5))
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
painter.drawEllipse(QRectF(24, 14.5, 8, 8))
|
painter.drawEllipse(QRectF(24, 14.5, 8, 8))
|
||||||
painter.drawRoundedRect(QRectF(19, 27, 18, 9), 4.5, 4.5)
|
painter.drawRoundedRect(QRectF(19, 27, 18, 9), 4.5, 4.5)
|
||||||
@@ -104,7 +105,7 @@ class _DemoCheckBox(_VisibleCheckBox):
|
|||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
center = QPointF(self.width() - 9, self.height() / 2)
|
center = QPointF(self.width() - 9, self.height() / 2)
|
||||||
painter.setPen(_round_pen("#92A0BF", 1.4))
|
painter.setPen(_round_pen("#8E8F90", 1.4))
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
painter.drawEllipse(center, 7, 7)
|
painter.drawEllipse(center, 7, 7)
|
||||||
painter.drawLine(center + QPointF(0, -1), center + QPointF(0, 4))
|
painter.drawLine(center + QPointF(0, -1), center + QPointF(0, 4))
|
||||||
@@ -112,10 +113,9 @@ class _DemoCheckBox(_VisibleCheckBox):
|
|||||||
|
|
||||||
|
|
||||||
def _font(pixel_size: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
|
def _font(pixel_size: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
|
||||||
font = QFont("Microsoft YaHei UI")
|
font = QFont(QApplication.font())
|
||||||
font.setPixelSize(pixel_size)
|
font.setPixelSize(pixel_size)
|
||||||
font.setWeight(weight)
|
font.setWeight(weight)
|
||||||
font.setHintingPreference(QFont.HintingPreference.PreferFullHinting)
|
|
||||||
return font
|
return font
|
||||||
|
|
||||||
|
|
||||||
@@ -291,10 +291,10 @@ class _BrandPanel(QWidget):
|
|||||||
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||||
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
|
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
|
||||||
background.setColorAt(0.0, QColor("#FFFFFF"))
|
background.setColorAt(0.0, QColor("#FFFFFF"))
|
||||||
background.setColorAt(0.7, QColor("#FEFEFF"))
|
background.setColorAt(0.7, QColor("#FFFFFF"))
|
||||||
background.setColorAt(1.0, QColor("#F9FBFF"))
|
background.setColorAt(1.0, QColor("#F7F7F7"))
|
||||||
painter.setBrush(background)
|
painter.setBrush(background)
|
||||||
painter.setPen(QPen(QColor("#E4E9F4"), 1))
|
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||||||
painter.drawRoundedRect(bounds, 24, 24)
|
painter.drawRoundedRect(bounds, 24, 24)
|
||||||
|
|
||||||
width, height = float(self.width()), float(self.height())
|
width, height = float(self.width()), float(self.height())
|
||||||
@@ -315,15 +315,15 @@ class _BrandPanel(QWidget):
|
|||||||
|
|
||||||
tag_rect = QRectF(left, 238, 119, 40)
|
tag_rect = QRectF(left, 238, 119, 40)
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
painter.setBrush(QColor("#F0F2FF"))
|
painter.setBrush(QColor("#EEF1FA"))
|
||||||
painter.drawRoundedRect(tag_rect, 11, 11)
|
painter.drawRoundedRect(tag_rect, 11, 11)
|
||||||
painter.setFont(_font(17, QFont.Weight.DemiBold))
|
painter.setFont(_font(17, QFont.Weight.Medium))
|
||||||
painter.setPen(QColor("#5265F6"))
|
painter.setPen(QColor("#4F63D9"))
|
||||||
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
|
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
|
||||||
|
|
||||||
copy_left = left + 4
|
copy_left = left + 4
|
||||||
painter.setFont(_font(51, QFont.Weight.Bold))
|
painter.setFont(_font(48, QFont.Weight.Medium))
|
||||||
painter.setPen(QColor("#14224A"))
|
painter.setPen(QColor("#1A1C1F"))
|
||||||
painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
|
painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
|
||||||
painter.drawText(QPointF(copy_left, 424), "留在一个")
|
painter.drawText(QPointF(copy_left, 424), "留在一个")
|
||||||
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
|
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
|
||||||
@@ -333,8 +333,8 @@ class _BrandPanel(QWidget):
|
|||||||
copy_left + prefix_width + 264,
|
copy_left + prefix_width + 264,
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
highlight.setColorAt(0.0, QColor("#4258EC"))
|
highlight.setColorAt(0.0, QColor("#4F63D9"))
|
||||||
highlight.setColorAt(1.0, QColor("#6975FF"))
|
highlight.setColorAt(1.0, QColor("#4F63D9"))
|
||||||
painter.setPen(QPen(QBrush(highlight), 1))
|
painter.setPen(QPen(QBrush(highlight), 1))
|
||||||
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
|
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
|
||||||
suffix_x = (
|
suffix_x = (
|
||||||
@@ -342,19 +342,19 @@ class _BrandPanel(QWidget):
|
|||||||
+ prefix_width
|
+ prefix_width
|
||||||
+ painter.fontMetrics().horizontalAdvance("安静的界面里")
|
+ painter.fontMetrics().horizontalAdvance("安静的界面里")
|
||||||
)
|
)
|
||||||
painter.setPen(QColor("#14224A"))
|
painter.setPen(QColor("#1A1C1F"))
|
||||||
painter.drawText(QPointF(suffix_x, 424), "。")
|
painter.drawText(QPointF(suffix_x, 424), "。")
|
||||||
|
|
||||||
body_left = left + 6
|
body_left = left + 6
|
||||||
painter.setFont(_font(20))
|
painter.setFont(_font(20))
|
||||||
painter.setPen(QColor("#7181A7"))
|
painter.setPen(QColor("#606163"))
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
|
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
|
||||||
)
|
)
|
||||||
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
|
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
|
||||||
|
|
||||||
painter.setFont(_font(16))
|
painter.setFont(_font(16))
|
||||||
painter.setPen(QColor("#7484A9"))
|
painter.setPen(QColor("#6A6B6D"))
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
|
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
|
||||||
)
|
)
|
||||||
@@ -366,7 +366,7 @@ class _RevealButton(QToolButton):
|
|||||||
del event
|
del event
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
color = QColor("#8796B8" if self.isEnabled() else "#B8C0D1")
|
color = QColor("#6A6B6D" if self.isEnabled() else "#BDBDBE")
|
||||||
painter.setPen(_round_pen(color, 2))
|
painter.setPen(_round_pen(color, 2))
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
eye = QPainterPath(QPointF(7, self.height() / 2))
|
eye = QPainterPath(QPointF(7, self.height() / 2))
|
||||||
@@ -392,11 +392,11 @@ class _ServerButton(QPushButton):
|
|||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
|
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
|
||||||
painter.setBrush(QColor("#F8FAFF") if self.underMouse() else QColor("#FFFFFF"))
|
painter.setBrush(QColor("#FFFFFF") if self.underMouse() else QColor("#FFFFFF"))
|
||||||
painter.setPen(QPen(QColor("#D8DFEE"), 1.5))
|
painter.setPen(QPen(QColor("#E4E4E5"), 1.5))
|
||||||
painter.drawRoundedRect(rect, 12, 12)
|
painter.drawRoundedRect(rect, 12, 12)
|
||||||
color = QColor("#17264B" if self.isEnabled() else "#A2ABC0")
|
color = QColor("#1A1C1F" if self.isEnabled() else "#8E8F90")
|
||||||
painter.setPen(_round_pen("#7C8DB2", 1.8))
|
painter.setPen(_round_pen("#6A6B6D", 1.8))
|
||||||
center = QPointF(29, self.height() / 2)
|
center = QPointF(29, self.height() / 2)
|
||||||
painter.drawEllipse(center, 8, 8)
|
painter.drawEllipse(center, 8, 8)
|
||||||
painter.drawEllipse(center, 2.8, 2.8)
|
painter.drawEllipse(center, 2.8, 2.8)
|
||||||
@@ -420,7 +420,7 @@ class _ServerButton(QPushButton):
|
|||||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
|
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
|
||||||
"服务器设置",
|
"服务器设置",
|
||||||
)
|
)
|
||||||
painter.setPen(_round_pen("#94A1BC", 2))
|
painter.setPen(_round_pen("#8E8F90", 2))
|
||||||
x, y = self.width() - 28, self.height() / 2
|
x, y = self.width() - 28, self.height() / 2
|
||||||
if self.isChecked():
|
if self.isChecked():
|
||||||
painter.drawLine(QPointF(x - 5, y + 3), QPointF(x, y - 3))
|
painter.drawLine(QPointF(x - 5, y + 3), QPointF(x, y - 3))
|
||||||
@@ -482,12 +482,8 @@ class LoginWindow(QMainWindow):
|
|||||||
canvas.setStyleSheet(
|
canvas.setStyleSheet(
|
||||||
"""
|
"""
|
||||||
QWidget#LoginCanvas {
|
QWidget#LoginCanvas {
|
||||||
color: #17264B;
|
color: #1A1C1F;
|
||||||
background: qlineargradient(
|
background-color: #F4F6FA;
|
||||||
x1:0, y1:0, x2:1, y2:1,
|
|
||||||
stop:0 #F8FAFF, stop:0.58 #FBFCFF, stop:1 #F1F5FF
|
|
||||||
);
|
|
||||||
font-family: "Microsoft YaHei UI";
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
QWidget#LoginBrandPanel {
|
QWidget#LoginBrandPanel {
|
||||||
@@ -496,28 +492,28 @@ class LoginWindow(QMainWindow):
|
|||||||
}
|
}
|
||||||
QFrame#LoginCard {
|
QFrame#LoginCard {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #E1E6F0;
|
border: 1px solid #EDEDEE;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QFrame#SubtleCard {
|
QFrame#LoginCard QFrame#SubtleCard {
|
||||||
background-color: #F8FAFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #DCE2EF;
|
border: 1px solid #E4E4E5;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QLabel { color: #17264B; background: transparent; }
|
QFrame#LoginCard QLabel { color: #1A1C1F; background: transparent; }
|
||||||
QFrame#LoginCard QLabel[role="muted"] { color: #7382A5; }
|
QFrame#LoginCard QLabel[role="muted"] { color: #606163; }
|
||||||
QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; }
|
QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; }
|
||||||
QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; }
|
QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; }
|
||||||
QFrame#LoginCard QLineEdit,
|
QFrame#LoginCard QLineEdit,
|
||||||
QFrame#LoginCard QSpinBox {
|
QFrame#LoginCard QSpinBox {
|
||||||
color: #17264B;
|
color: #1A1C1F;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #D6DEED;
|
border: 1px solid #E4E4E5;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
selection-background-color: #E5E9FF;
|
selection-background-color: #EEF1FA;
|
||||||
selection-color: #17264B;
|
selection-color: #1A1C1F;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QLineEdit#AccountEdit {
|
QFrame#LoginCard QLineEdit#AccountEdit {
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
@@ -525,9 +521,9 @@ class LoginWindow(QMainWindow):
|
|||||||
padding-left: 52px;
|
padding-left: 52px;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QLineEdit:hover,
|
QFrame#LoginCard QLineEdit:hover,
|
||||||
QFrame#LoginCard QSpinBox:hover { border-color: #9AA8FF; }
|
QFrame#LoginCard QSpinBox:hover { border-color: #8B9AD9; }
|
||||||
QFrame#LoginCard QLineEdit:focus,
|
QFrame#LoginCard QLineEdit:focus,
|
||||||
QFrame#LoginCard QSpinBox:focus { border: 1.5px solid #7080F7; }
|
QFrame#LoginCard QSpinBox:focus { border: 1.5px solid #8B9AD9; }
|
||||||
QFrame#LoginCard QLineEdit#PasswordEdit {
|
QFrame#LoginCard QLineEdit#PasswordEdit {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -537,31 +533,31 @@ class LoginWindow(QMainWindow):
|
|||||||
QFrame#LoginCard QCheckBox#DemoModeCheck { spacing: 10px; }
|
QFrame#LoginCard QCheckBox#DemoModeCheck { spacing: 10px; }
|
||||||
QFrame#PasswordField {
|
QFrame#PasswordField {
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid #D6DEED;
|
border: 1px solid #E4E4E5;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
QFrame#PasswordField:focus-within { border-color: #7080F7; }
|
QFrame#PasswordField:focus-within { border-color: #8B9AD9; }
|
||||||
QFrame#LoginCard QCheckBox {
|
QFrame#LoginCard QCheckBox {
|
||||||
color: #6F7FA3;
|
color: #606163;
|
||||||
spacing: 13px;
|
spacing: 13px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QCheckBox::indicator {
|
QFrame#LoginCard QCheckBox::indicator {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border: 1px solid #CFD8EB;
|
border: 1px solid #E4E4E5;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QCheckBox::indicator:hover { border-color: #7A8AF8; }
|
QFrame#LoginCard QCheckBox::indicator:hover { border-color: #8B9AD9; }
|
||||||
QFrame#LoginCard QCheckBox::indicator:checked {
|
QFrame#LoginCard QCheckBox::indicator:checked {
|
||||||
border-color: #6475F5;
|
border-color: #4F63D9;
|
||||||
background-color: #6475F5;
|
background-color: #4F63D9;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QToolButton#PasswordReveal {
|
QFrame#LoginCard QToolButton#PasswordReveal {
|
||||||
min-width: 87px;
|
min-width: 87px;
|
||||||
max-width: 87px;
|
max-width: 87px;
|
||||||
color: #8290B0;
|
color: #6A6B6D;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -570,14 +566,11 @@ class LoginWindow(QMainWindow):
|
|||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
max-height: 58px;
|
max-height: 58px;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background: qlineargradient(
|
background-color: #4F63D9;
|
||||||
x1:0, y1:0, x2:1, y2:0,
|
|
||||||
stop:0 #5B6BF1, stop:0.55 #6675FA, stop:1 #5865F2
|
|
||||||
);
|
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QPushButton#ServerSettingsToggle {
|
QFrame#LoginCard QPushButton#ServerSettingsToggle {
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
@@ -585,17 +578,17 @@ class LoginWindow(QMainWindow):
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QPushButton[variant="primary"]:hover {
|
QFrame#LoginCard QPushButton[variant="primary"]:hover {
|
||||||
background-color: #5262ED;
|
background-color: #4156C4;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QPushButton[variant="secondary"] {
|
QFrame#LoginCard QPushButton[variant="secondary"] {
|
||||||
color: #4353BD;
|
color: #1A1C1F;
|
||||||
background-color: #EDF0FF;
|
background-color: #F0F0F0;
|
||||||
border: 1px solid #D3DAFC;
|
border: 1px solid #E4E4E5;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
QFrame#LoginCard QPushButton[variant="secondary"]:hover {
|
QFrame#LoginCard QPushButton[variant="secondary"]:hover {
|
||||||
background-color: #DCE3FF;
|
background-color: #E4E4E5;
|
||||||
border-color: #8B98F8;
|
border-color: #8B9AD9;
|
||||||
}
|
}
|
||||||
QScrollArea#LoginAreaScroll,
|
QScrollArea#LoginAreaScroll,
|
||||||
QScrollArea#LoginAreaScroll > QWidget > QWidget {
|
QScrollArea#LoginAreaScroll > QWidget > QWidget {
|
||||||
@@ -629,6 +622,12 @@ class LoginWindow(QMainWindow):
|
|||||||
spacing = 20
|
spacing = 20
|
||||||
self.login_root.setContentsMargins(*margins)
|
self.login_root.setContentsMargins(*margins)
|
||||||
self.login_root.setSpacing(spacing)
|
self.login_root.setSpacing(spacing)
|
||||||
|
# Keep the form fully visible when the 60/40 desktop split would
|
||||||
|
# otherwise crop its fixed-width card. Small windows focus on login.
|
||||||
|
self.brand_panel.setVisible(width >= 1120)
|
||||||
|
self.login_area_layout.setContentsMargins(
|
||||||
|
0, min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
|
||||||
|
)
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
|
|
||||||
def _build_brand_panel(self) -> QWidget:
|
def _build_brand_panel(self) -> QWidget:
|
||||||
@@ -647,20 +646,19 @@ class LoginWindow(QMainWindow):
|
|||||||
area.setFrameShape(QFrame.Shape.NoFrame)
|
area.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
|
area.setMinimumWidth(492)
|
||||||
content = QWidget()
|
content = QWidget()
|
||||||
content.setObjectName("LoginAreaContent")
|
content.setObjectName("LoginAreaContent")
|
||||||
area.setWidget(content)
|
area.setWidget(content)
|
||||||
self.login_scroll = area
|
self.login_scroll = area
|
||||||
outer = QVBoxLayout(content)
|
outer = QVBoxLayout(content)
|
||||||
# The supplied 1536×1024 capture contains a 60 px native title bar.
|
self.login_area_layout = outer
|
||||||
# Its card begins at y=198, i.e. y=138 in the 1536×964 client area.
|
outer.setContentsMargins(0, 40, 0, 12)
|
||||||
# The root starts at y=34, so the deterministic lead inset is 104 px.
|
|
||||||
outer.setContentsMargins(0, 104, 0, 0)
|
|
||||||
|
|
||||||
self.card = QFrame()
|
self.card = QFrame()
|
||||||
self.card.setObjectName("LoginCard")
|
self.card.setObjectName("LoginCard")
|
||||||
self.card.setFixedWidth(480)
|
self.card.setFixedWidth(480)
|
||||||
self.card.setMinimumHeight(694)
|
self.card.setMinimumHeight(620)
|
||||||
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||||
card_shadow = QGraphicsDropShadowEffect(self.card)
|
card_shadow = QGraphicsDropShadowEffect(self.card)
|
||||||
card_shadow.setBlurRadius(38)
|
card_shadow.setBlurRadius(38)
|
||||||
@@ -675,13 +673,13 @@ class LoginWindow(QMainWindow):
|
|||||||
|
|
||||||
title = QLabel("欢迎回来")
|
title = QLabel("欢迎回来")
|
||||||
title.setObjectName("LoginTitle")
|
title.setObjectName("LoginTitle")
|
||||||
title.setStyleSheet("color:#14224A; font-size:33px; font-weight:700;")
|
title.setStyleSheet("color:#1A1C1F; font-size:30px; font-weight:500;")
|
||||||
title.setContentsMargins(1, -3, 0, 3)
|
title.setContentsMargins(1, -3, 0, 3)
|
||||||
title.setFixedHeight(46)
|
title.setFixedHeight(46)
|
||||||
card_layout.addWidget(title)
|
card_layout.addWidget(title)
|
||||||
subtitle = QLabel("使用医生账号登录工作站")
|
subtitle = QLabel("使用医生账号登录工作站")
|
||||||
subtitle.setProperty("role", "muted")
|
subtitle.setProperty("role", "muted")
|
||||||
subtitle.setStyleSheet("color:#7382A5; font-size:18px;")
|
subtitle.setStyleSheet("color:#606163; font-size:18px;")
|
||||||
subtitle.setContentsMargins(1, 8, 0, 0)
|
subtitle.setContentsMargins(1, 8, 0, 0)
|
||||||
subtitle.setFixedHeight(27)
|
subtitle.setFixedHeight(27)
|
||||||
card_layout.addWidget(subtitle)
|
card_layout.addWidget(subtitle)
|
||||||
@@ -691,7 +689,7 @@ class LoginWindow(QMainWindow):
|
|||||||
card_layout.addWidget(self.error_banner)
|
card_layout.addWidget(self.error_banner)
|
||||||
|
|
||||||
account_label = QLabel("账号")
|
account_label = QLabel("账号")
|
||||||
account_label.setStyleSheet("color:#17264B; font-size:18px; font-weight:600;")
|
account_label.setStyleSheet("color:#1A1C1F; font-size:16px; font-weight:500;")
|
||||||
account_label.setContentsMargins(0, -2, 0, 2)
|
account_label.setContentsMargins(0, -2, 0, 2)
|
||||||
account_label.setFixedHeight(24)
|
account_label.setFixedHeight(24)
|
||||||
card_layout.addWidget(account_label)
|
card_layout.addWidget(account_label)
|
||||||
@@ -707,7 +705,7 @@ class LoginWindow(QMainWindow):
|
|||||||
card_layout.addSpacing(21)
|
card_layout.addSpacing(21)
|
||||||
|
|
||||||
password_label = QLabel("密码")
|
password_label = QLabel("密码")
|
||||||
password_label.setStyleSheet("color:#17264B; font-size:18px; font-weight:600;")
|
password_label.setStyleSheet("color:#1A1C1F; font-size:16px; font-weight:500;")
|
||||||
password_label.setContentsMargins(0, -3, 0, 3)
|
password_label.setContentsMargins(0, -3, 0, 3)
|
||||||
password_label.setFixedHeight(24)
|
password_label.setFixedHeight(24)
|
||||||
card_layout.addWidget(password_label)
|
card_layout.addWidget(password_label)
|
||||||
@@ -777,16 +775,16 @@ class LoginWindow(QMainWindow):
|
|||||||
divider.setSpacing(18)
|
divider.setSpacing(18)
|
||||||
line_left = QFrame()
|
line_left = QFrame()
|
||||||
line_left.setFrameShape(QFrame.Shape.HLine)
|
line_left.setFrameShape(QFrame.Shape.HLine)
|
||||||
line_left.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
line_left.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
|
||||||
divider.addWidget(line_left, 1)
|
divider.addWidget(line_left, 1)
|
||||||
divider_text = QLabel("或")
|
divider_text = QLabel("或")
|
||||||
divider_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
divider_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
divider_text.setStyleSheet("color:#7C89A8; font-size:16px;")
|
divider_text.setStyleSheet("color:#6A6B6D; font-size:16px;")
|
||||||
divider_text.setFixedSize(38, 22)
|
divider_text.setFixedSize(38, 22)
|
||||||
divider.addWidget(divider_text)
|
divider.addWidget(divider_text)
|
||||||
line_right = QFrame()
|
line_right = QFrame()
|
||||||
line_right.setFrameShape(QFrame.Shape.HLine)
|
line_right.setFrameShape(QFrame.Shape.HLine)
|
||||||
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
line_right.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
|
||||||
divider.addWidget(line_right, 1)
|
divider.addWidget(line_right, 1)
|
||||||
debug_settings_layout.addLayout(divider)
|
debug_settings_layout.addLayout(divider)
|
||||||
debug_settings_layout.addSpacing(20)
|
debug_settings_layout.addSpacing(20)
|
||||||
@@ -873,7 +871,7 @@ class LoginWindow(QMainWindow):
|
|||||||
footnote_row.addWidget(lock)
|
footnote_row.addWidget(lock)
|
||||||
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
|
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
|
||||||
footnote.setProperty("role", "muted")
|
footnote.setProperty("role", "muted")
|
||||||
footnote.setStyleSheet("color:#7A89AA; font-size:15px;")
|
footnote.setStyleSheet("color:#6A6B6D; font-size:15px;")
|
||||||
footnote.setContentsMargins(0, -4, 0, 4)
|
footnote.setContentsMargins(0, -4, 0, 4)
|
||||||
footnote.setWordWrap(True)
|
footnote.setWordWrap(True)
|
||||||
footnote.setFixedHeight(40)
|
footnote.setFixedHeight(40)
|
||||||
@@ -882,12 +880,12 @@ class LoginWindow(QMainWindow):
|
|||||||
self.version_label = QLabel(f"当前版本 {__version__}")
|
self.version_label = QLabel(f"当前版本 {__version__}")
|
||||||
self.version_label.setObjectName("LoginVersionLabel")
|
self.version_label.setObjectName("LoginVersionLabel")
|
||||||
self.version_label.setProperty("role", "muted")
|
self.version_label.setProperty("role", "muted")
|
||||||
self.version_label.setStyleSheet("color:#8B98B5; font-size:13px;")
|
self.version_label.setStyleSheet("color:#6A6B6D; font-size:13px;")
|
||||||
self.version_label.setContentsMargins(0, 8, 0, 0)
|
self.version_label.setContentsMargins(0, 8, 0, 0)
|
||||||
card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight)
|
card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight)
|
||||||
|
|
||||||
outer.addWidget(
|
outer.addWidget(
|
||||||
self.card, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
self.card, 0, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
|
||||||
)
|
)
|
||||||
outer.addStretch(1)
|
outer.addStretch(1)
|
||||||
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
|
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
|
||||||
@@ -899,28 +897,11 @@ class LoginWindow(QMainWindow):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _account_icon() -> QIcon:
|
def _account_icon() -> QIcon:
|
||||||
pixmap = crisp_pixmap(24)
|
return icons.icon("user", "muted", 24)
|
||||||
painter = QPainter(pixmap)
|
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
||||||
painter.setPen(_round_pen("#8292B6", 2))
|
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
||||||
painter.drawEllipse(QPointF(12, 7.5), 4.2, 4.2)
|
|
||||||
painter.drawRoundedRect(QRectF(4.5, 14, 15, 7), 3.5, 3.5)
|
|
||||||
painter.end()
|
|
||||||
return QIcon(pixmap)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _lock_icon() -> QIcon:
|
def _lock_icon() -> QIcon:
|
||||||
pixmap = crisp_pixmap(20)
|
return icons.icon("lock", "muted", 20)
|
||||||
painter = QPainter(pixmap)
|
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
||||||
painter.setPen(_round_pen("#8FA0C4", 1.6))
|
|
||||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
||||||
painter.drawRoundedRect(QRectF(5, 8, 10, 9), 2, 2)
|
|
||||||
painter.drawArc(QRectF(7, 3, 6, 9), 0, 180 * 16)
|
|
||||||
painter.drawLine(QPointF(10, 11), QPointF(10, 14))
|
|
||||||
painter.end()
|
|
||||||
return QIcon(pixmap)
|
|
||||||
|
|
||||||
def _restore_settings(self) -> None:
|
def _restore_settings(self) -> None:
|
||||||
configured_account = getattr(self.config, "remembered_account", "")
|
configured_account = getattr(self.config, "remembered_account", "")
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Motion tokens and helpers.
|
||||||
|
|
||||||
|
The product had two `QGraphicsOpacityEffect` uses and no `QPropertyAnimation`
|
||||||
|
at all, so every state change was an instant cut: pages replaced each other
|
||||||
|
between one frame and the next, drawers appeared fully formed, toasts blinked
|
||||||
|
in and out. Nothing was slow - it just gave the eye no continuity to follow,
|
||||||
|
which is what reads as "not smooth" however fast the code underneath is.
|
||||||
|
|
||||||
|
Everything here is short. A workstation is used all day, so transitions are
|
||||||
|
tuned to be felt rather than watched: 110-260 ms, ease-out on entry, and travel
|
||||||
|
measured in single-digit pixels. Anything longer starts costing the user time.
|
||||||
|
|
||||||
|
Qt stylesheets have no `transition` property, so this is `QPropertyAnimation`
|
||||||
|
throughout. Two rules keep that safe:
|
||||||
|
|
||||||
|
* an animation must be owned, or PySide garbage-collects it mid-flight and the
|
||||||
|
widget freezes half-faded - :func:`_own` parks it on the target;
|
||||||
|
* a `QGraphicsOpacityEffect` forces the whole widget subtree through an
|
||||||
|
offscreen render path, which would make a table scroll badly for the rest of
|
||||||
|
the session - every fade here removes its effect when it finishes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
QAbstractAnimation,
|
||||||
|
QEasingCurve,
|
||||||
|
QEvent,
|
||||||
|
QObject,
|
||||||
|
QPoint,
|
||||||
|
QPropertyAnimation,
|
||||||
|
Qt,
|
||||||
|
QTimer,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractScrollArea,
|
||||||
|
QGraphicsOpacityEffect,
|
||||||
|
QStackedWidget,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Durations in milliseconds.
|
||||||
|
FAST = 110 # hover-scale feedback, small fades
|
||||||
|
BASE = 170 # the default: page and panel transitions
|
||||||
|
SLOW = 260 # large travel, e.g. a drawer crossing the workspace
|
||||||
|
|
||||||
|
#: Entering elements decelerate; elements that move between two known places
|
||||||
|
#: ease in and out; large travel gets a longer tail so it never looks linear.
|
||||||
|
EASE_ENTER = QEasingCurve.Type.OutCubic
|
||||||
|
EASE_MOVE = QEasingCurve.Type.InOutCubic
|
||||||
|
EASE_TRAVEL = QEasingCurve.Type.OutQuint
|
||||||
|
|
||||||
|
#: How far an entering surface rises, in device-independent pixels. Kept small
|
||||||
|
#: on purpose: a page that slides a long way reads as a slideshow, not an app.
|
||||||
|
RISE = 8
|
||||||
|
|
||||||
|
|
||||||
|
def reduced_motion() -> bool:
|
||||||
|
"""Whether animation should be skipped entirely.
|
||||||
|
|
||||||
|
Off by default under the offscreen platform so widget grabs in tests and in
|
||||||
|
the packaging smoke checks capture a settled frame rather than a frame from
|
||||||
|
the middle of a fade. ``DOCTOR_MOTION=on`` / ``off`` overrides either way.
|
||||||
|
"""
|
||||||
|
|
||||||
|
override = os.getenv("DOCTOR_MOTION", "").strip().lower()
|
||||||
|
if override in {"off", "0", "false", "none", "reduce"}:
|
||||||
|
return True
|
||||||
|
if override in {"on", "1", "true", "full"}:
|
||||||
|
return False
|
||||||
|
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
|
||||||
|
|
||||||
|
|
||||||
|
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
|
||||||
|
"""Park an animation on its target so Python does not collect it early."""
|
||||||
|
|
||||||
|
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
|
||||||
|
previous = running.get(key)
|
||||||
|
if previous is not None:
|
||||||
|
previous.stop()
|
||||||
|
running[key] = animation
|
||||||
|
target._doctor_motion = running
|
||||||
|
return animation
|
||||||
|
|
||||||
|
|
||||||
|
def animate(
|
||||||
|
target: Any,
|
||||||
|
prop: bytes,
|
||||||
|
start: Any,
|
||||||
|
end: Any,
|
||||||
|
*,
|
||||||
|
duration: int = BASE,
|
||||||
|
easing: QEasingCurve.Type = EASE_ENTER,
|
||||||
|
key: str | None = None,
|
||||||
|
owner: QWidget | None = None,
|
||||||
|
on_finished: Callable[[], None] | None = None,
|
||||||
|
) -> QPropertyAnimation | None:
|
||||||
|
"""Animate one Qt property, or apply the end value outright if motion is off.
|
||||||
|
|
||||||
|
``owner`` keeps the animation alive independently of ``target``. Fades
|
||||||
|
animate a ``QGraphicsOpacityEffect`` that is deleted the moment the fade
|
||||||
|
ends, so parenting the animation to the effect would destroy the animation
|
||||||
|
from inside its own ``finished`` emission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if reduced_motion():
|
||||||
|
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
return None
|
||||||
|
animation = QPropertyAnimation(target, prop, owner if owner is not None else target)
|
||||||
|
animation.setDuration(duration)
|
||||||
|
animation.setEasingCurve(easing)
|
||||||
|
animation.setStartValue(start)
|
||||||
|
animation.setEndValue(end)
|
||||||
|
if on_finished is not None:
|
||||||
|
animation.finished.connect(on_finished)
|
||||||
|
_own(owner if owner is not None else target, key or prop.decode(), animation)
|
||||||
|
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
|
||||||
|
return animation
|
||||||
|
|
||||||
|
|
||||||
|
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
|
||||||
|
effect = widget.graphicsEffect()
|
||||||
|
if not isinstance(effect, QGraphicsOpacityEffect):
|
||||||
|
effect = QGraphicsOpacityEffect(widget)
|
||||||
|
widget.setGraphicsEffect(effect)
|
||||||
|
effect.setEnabled(True)
|
||||||
|
return effect
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_effect(widget: QWidget) -> None:
|
||||||
|
"""Detach the opacity effect once a fade is done.
|
||||||
|
|
||||||
|
Leaving it attached keeps the widget on Qt's offscreen composite path, which
|
||||||
|
is exactly the sort of quiet, permanent frame-rate tax this module exists to
|
||||||
|
avoid introducing.
|
||||||
|
|
||||||
|
The detach is deferred by one event-loop turn on purpose. ``finished`` is
|
||||||
|
emitted from inside the animation, and ``setGraphicsEffect(None)`` deletes
|
||||||
|
the old effect immediately - tearing down the object graph underneath a
|
||||||
|
signal that is still being delivered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def detach() -> None:
|
||||||
|
try:
|
||||||
|
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
|
||||||
|
widget.setGraphicsEffect(None)
|
||||||
|
except RuntimeError: # the widget went away while the fade was running
|
||||||
|
pass
|
||||||
|
|
||||||
|
QTimer.singleShot(0, detach)
|
||||||
|
|
||||||
|
|
||||||
|
def fade_in(
|
||||||
|
widget: QWidget,
|
||||||
|
*,
|
||||||
|
duration: int = BASE,
|
||||||
|
start: float = 0.0,
|
||||||
|
easing: QEasingCurve.Type = EASE_ENTER,
|
||||||
|
) -> None:
|
||||||
|
"""Fade a widget up to full opacity, showing it first if needed."""
|
||||||
|
|
||||||
|
if reduced_motion():
|
||||||
|
widget.show()
|
||||||
|
return
|
||||||
|
effect = _opacity_effect(widget)
|
||||||
|
effect.setOpacity(start)
|
||||||
|
widget.show()
|
||||||
|
animate(
|
||||||
|
effect,
|
||||||
|
b"opacity",
|
||||||
|
start,
|
||||||
|
1.0,
|
||||||
|
duration=duration,
|
||||||
|
easing=easing,
|
||||||
|
key="fade",
|
||||||
|
owner=widget,
|
||||||
|
on_finished=lambda: _drop_effect(widget),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fade_out(
|
||||||
|
widget: QWidget,
|
||||||
|
*,
|
||||||
|
duration: int = FAST,
|
||||||
|
hide: bool = True,
|
||||||
|
on_finished: Callable[[], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fade a widget down, optionally hiding it when the fade completes."""
|
||||||
|
|
||||||
|
if reduced_motion():
|
||||||
|
if hide:
|
||||||
|
widget.hide()
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
return
|
||||||
|
effect = _opacity_effect(widget)
|
||||||
|
|
||||||
|
def done() -> None:
|
||||||
|
if hide:
|
||||||
|
widget.hide()
|
||||||
|
_drop_effect(widget)
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
animate(
|
||||||
|
effect,
|
||||||
|
b"opacity",
|
||||||
|
float(effect.opacity()),
|
||||||
|
0.0,
|
||||||
|
duration=duration,
|
||||||
|
easing=EASE_MOVE,
|
||||||
|
key="fade",
|
||||||
|
owner=widget,
|
||||||
|
on_finished=done,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
|
||||||
|
"""Fade a surface in while it settles upward by a few pixels.
|
||||||
|
|
||||||
|
The rise is what makes a swap read as one surface replacing another rather
|
||||||
|
than as a repaint; keeping it under ten pixels stops it becoming a gesture
|
||||||
|
the user has to wait out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if reduced_motion():
|
||||||
|
widget.show()
|
||||||
|
return
|
||||||
|
fade_in(widget, duration=duration)
|
||||||
|
if rise:
|
||||||
|
origin = widget.pos()
|
||||||
|
widget.move(origin + QPoint(0, rise))
|
||||||
|
animate(
|
||||||
|
widget,
|
||||||
|
b"pos",
|
||||||
|
widget.pos(),
|
||||||
|
origin,
|
||||||
|
duration=duration,
|
||||||
|
easing=EASE_ENTER,
|
||||||
|
key="enter",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None:
|
||||||
|
"""Change the current page of a stack with a short cross-fade.
|
||||||
|
|
||||||
|
``QStackedWidget`` swaps pages between two frames with nothing in between,
|
||||||
|
which is the single most-seen transition in this product - it happens on
|
||||||
|
every sidebar click and on every list that toggles to its empty state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if index < 0 or index >= stack.count() or stack.currentIndex() == index:
|
||||||
|
stack.setCurrentIndex(index)
|
||||||
|
return
|
||||||
|
stack.setCurrentIndex(index)
|
||||||
|
page = stack.currentWidget()
|
||||||
|
if page is None or reduced_motion():
|
||||||
|
return
|
||||||
|
enter(page, rise=rise)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Smooth scrolling -----------------------------------------------------
|
||||||
|
|
||||||
|
#: One wheel notch travels this far, and takes this long to get there. Qt's
|
||||||
|
#: default is an instant jump of three lines per notch, which on a long clinical
|
||||||
|
#: record is the single jerkiest thing in the interface.
|
||||||
|
SCROLL_STEP = 120
|
||||||
|
SCROLL_MS = 190
|
||||||
|
|
||||||
|
|
||||||
|
class _SmoothScroller(QObject):
|
||||||
|
"""Animate a scroll area's wheel movement instead of jumping to it."""
|
||||||
|
|
||||||
|
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
|
||||||
|
super().__init__(area)
|
||||||
|
self._bar = (
|
||||||
|
area.verticalScrollBar()
|
||||||
|
if orientation is Qt.Orientation.Vertical
|
||||||
|
else area.horizontalScrollBar()
|
||||||
|
)
|
||||||
|
self._target = self._bar.value()
|
||||||
|
self._animation = QPropertyAnimation(self._bar, b"value", self)
|
||||||
|
self._animation.setEasingCurve(EASE_ENTER)
|
||||||
|
self._animation.setDuration(SCROLL_MS)
|
||||||
|
# Keyboard, programmatic and drag movements must not be fought over: when
|
||||||
|
# nothing is animating, the wheel target follows wherever the bar went.
|
||||||
|
self._bar.valueChanged.connect(self._sync_target)
|
||||||
|
area.viewport().installEventFilter(self)
|
||||||
|
|
||||||
|
def _sync_target(self, value: int) -> None:
|
||||||
|
if self._animation.state() != QAbstractAnimation.State.Running:
|
||||||
|
self._target = value
|
||||||
|
|
||||||
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||||||
|
del watched
|
||||||
|
if event.type() is not QEvent.Type.Wheel or reduced_motion():
|
||||||
|
return False
|
||||||
|
delta = event.angleDelta().y() or event.angleDelta().x()
|
||||||
|
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||||
|
return False
|
||||||
|
lower, upper = self._bar.minimum(), self._bar.maximum()
|
||||||
|
# Re-clamp first: the range can shrink underneath a running animation
|
||||||
|
# when the content behind it reloads, which would otherwise leave the
|
||||||
|
# pending target past the end of the new content.
|
||||||
|
self._target = max(lower, min(upper, self._target))
|
||||||
|
target = self._target - round(delta / 120.0 * SCROLL_STEP)
|
||||||
|
target = max(lower, min(upper, target))
|
||||||
|
# At either end, hand the wheel back so an enclosing scroll area still
|
||||||
|
# gets it - swallowing it there is what makes nested panes feel stuck.
|
||||||
|
if target == self._target:
|
||||||
|
return False
|
||||||
|
self._target = target
|
||||||
|
self._animation.stop()
|
||||||
|
self._animation.setStartValue(self._bar.value())
|
||||||
|
self._animation.setEndValue(target)
|
||||||
|
self._animation.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def install_smooth_scroll(
|
||||||
|
area: QAbstractScrollArea,
|
||||||
|
*,
|
||||||
|
orientation: Qt.Orientation = Qt.Orientation.Vertical,
|
||||||
|
) -> None:
|
||||||
|
"""Give a scroll area eased wheel scrolling."""
|
||||||
|
|
||||||
|
if getattr(area, "_doctor_smooth_scroll", None) is not None:
|
||||||
|
return
|
||||||
|
area._doctor_smooth_scroll = _SmoothScroller(area, orientation=orientation)
|
||||||
|
|
||||||
|
|
||||||
|
def press_feedback(widget: QWidget) -> None:
|
||||||
|
"""Mark a widget so the shared stylesheet can give it a pressed transform.
|
||||||
|
|
||||||
|
Qt has no CSS transitions, so the visual step itself lives in the palette's
|
||||||
|
pressed state; this only tags the widget as one that should get it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
widget.setProperty("motionPress", True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BASE",
|
||||||
|
"EASE_ENTER",
|
||||||
|
"EASE_MOVE",
|
||||||
|
"EASE_TRAVEL",
|
||||||
|
"FAST",
|
||||||
|
"RISE",
|
||||||
|
"SLOW",
|
||||||
|
"animate",
|
||||||
|
"enter",
|
||||||
|
"fade_in",
|
||||||
|
"fade_out",
|
||||||
|
"install_smooth_scroll",
|
||||||
|
"press_feedback",
|
||||||
|
"reduced_motion",
|
||||||
|
"switch_stack",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
|||||||
|
"""Scoped surfaces for the approved patient order-management workspace."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family
|
||||||
|
|
||||||
|
|
||||||
|
def patient_orders_stylesheet() -> str:
|
||||||
|
return Template(_ORDERS).substitute(body=body_family())
|
||||||
|
|
||||||
|
|
||||||
|
_ORDERS = """
|
||||||
|
#PatientOrdersWorkspace { background: transparent; color: #273244; }
|
||||||
|
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
|
||||||
|
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
|
||||||
|
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
|
||||||
|
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
|
||||||
|
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderFilterCard,
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderTableCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
|
||||||
|
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
|
||||||
|
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
|
||||||
|
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
|
||||||
|
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
|
||||||
|
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
|
||||||
|
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
|
||||||
|
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
|
||||||
|
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
|
||||||
|
#PatientOrdersWorkspace QDateEdit {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||||
|
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
|
||||||
|
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||||
|
#PatientOrdersWorkspace QLineEdit QToolButton {
|
||||||
|
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
|
||||||
|
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
|
||||||
|
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PatientOrdersWorkspace QComboBox QAbstractItemView {
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||||
|
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
|
||||||
|
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
|
||||||
|
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget {
|
||||||
|
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget QToolButton {
|
||||||
|
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
|
||||||
|
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
|
||||||
|
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
|
||||||
|
#PatientOrdersWorkspace QCheckBox::indicator {
|
||||||
|
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||||
|
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
|
||||||
|
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
|
||||||
|
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
|
||||||
|
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
|
||||||
|
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
|
||||||
|
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
|
||||||
|
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||||
|
#PatientOrdersWorkspace QTableWidget#OrderTable {
|
||||||
|
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
|
||||||
|
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
|
||||||
|
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
|
||||||
|
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
|
||||||
|
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
|
||||||
|
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; padding: 5px;
|
||||||
|
}
|
||||||
|
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
|
||||||
|
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Local typography and surfaces for the approved consultation-progress page."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family
|
||||||
|
|
||||||
|
|
||||||
|
def patient_progress_stylesheet() -> str:
|
||||||
|
return Template(_PROGRESS).substitute(body=body_family())
|
||||||
|
|
||||||
|
|
||||||
|
_PROGRESS = """
|
||||||
|
#PatientProgressWorkspace { background: transparent; color: #273244; }
|
||||||
|
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
|
||||||
|
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
|
||||||
|
#PatientProgressWorkspace QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
|
||||||
|
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
|
||||||
|
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
|
||||||
|
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressQueueCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
|
||||||
|
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
|
||||||
|
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
|
||||||
|
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
|
||||||
|
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
|
||||||
|
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
|
||||||
|
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
|
||||||
|
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
|
||||||
|
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
|
||||||
|
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
|
||||||
|
#PatientProgressWorkspace QHeaderView::section {
|
||||||
|
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
def patients_chrome_stylesheet() -> str:
|
||||||
|
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
def patient_list_stylesheet() -> str:
|
||||||
|
return Template(_LIST).substitute(body=body_family())
|
||||||
|
|
||||||
|
|
||||||
|
_CHROME = """
|
||||||
|
#PatientsPage { background: #F3F7FD; }
|
||||||
|
#PatientsPage QWidget#PageHeader QLabel {
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
|
||||||
|
}
|
||||||
|
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#PatientsPage QPushButton#PatientRefreshButton {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 20px;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
|
||||||
|
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
|
||||||
|
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
|
||||||
|
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
|
||||||
|
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LIST = """
|
||||||
|
#PatientListWorkspace { background: transparent; color: #273244; }
|
||||||
|
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
|
||||||
|
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
|
||||||
|
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
|
||||||
|
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
|
||||||
|
#PatientSearchToolbar QPushButton {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
|
||||||
|
color: #5D6B80; font-size: 13px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QFrame#PatientFilterCard,
|
||||||
|
#PatientListWorkspace QFrame#PatientSummaryStrip,
|
||||||
|
#PatientListWorkspace QFrame#PatientListCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 16px;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
|
||||||
|
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#PatientSearchToolbar QPushButton#PatientSearchButton {
|
||||||
|
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
|
||||||
|
#PatientSearchToolbar QLineEdit {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
|
||||||
|
}
|
||||||
|
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
|
||||||
|
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
|
||||||
|
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
|
||||||
|
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
|
||||||
|
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
|
||||||
|
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
|
||||||
|
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
|
||||||
|
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
|
||||||
|
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
|
||||||
|
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QDateEdit {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
|
||||||
|
selection-background-color: #DCEAFF;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
|
||||||
|
#PatientListWorkspace QDateEdit:disabled {
|
||||||
|
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
|
||||||
|
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PatientListWorkspace QPushButton[summaryCard="true"] {
|
||||||
|
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
|
||||||
|
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
|
||||||
|
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
|
||||||
|
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
|
||||||
|
#PatientListWorkspace QTableWidget#PatientTable {
|
||||||
|
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
|
||||||
|
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
|
||||||
|
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
|
||||||
|
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
|
||||||
|
#PatientListWorkspace QPushButton[rowAction="true"],
|
||||||
|
#PatientListWorkspace QToolButton#RowActionsMore {
|
||||||
|
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
|
||||||
|
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
|
||||||
|
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
|
||||||
|
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
|
||||||
|
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
|
||||||
|
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
|
||||||
|
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
|
||||||
|
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
|
||||||
|
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
|
||||||
|
}
|
||||||
|
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
|
||||||
|
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""Local technology-blue rendering for the approved prescription library."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from string import Template
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QPushButton,
|
||||||
|
QStyle,
|
||||||
|
QStyledItemDelegate,
|
||||||
|
QToolTip,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
from .widgets import SortableTable, first_value
|
||||||
|
|
||||||
|
|
||||||
|
def library_stylesheet() -> str:
|
||||||
|
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
|
||||||
|
# Defer the page helper lookup so importing this module alone does not
|
||||||
|
# recurse through pages.__init__ and the library page's own style import.
|
||||||
|
from .pages.prescriptions import _painted_icon
|
||||||
|
|
||||||
|
result = QIcon()
|
||||||
|
for mode, tint in (
|
||||||
|
(QIcon.Mode.Normal, color),
|
||||||
|
(QIcon.Mode.Active, color),
|
||||||
|
(QIcon.Mode.Selected, color),
|
||||||
|
(QIcon.Mode.Disabled, "#A4ADBA"),
|
||||||
|
):
|
||||||
|
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
|
||||||
|
for state in (QIcon.State.Off, QIcon.State.On):
|
||||||
|
result.addPixmap(pixmap, mode, state)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryComboBox(QComboBox):
|
||||||
|
"""Keep native combo interaction while painting the local caret."""
|
||||||
|
|
||||||
|
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||||
|
super().paintEvent(event)
|
||||||
|
painter = QPainter(self)
|
||||||
|
try:
|
||||||
|
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
|
||||||
|
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
|
||||||
|
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryTable(SortableTable):
|
||||||
|
"""Repaint existing action widgets and restore selection by template ID."""
|
||||||
|
|
||||||
|
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||||
|
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
|
||||||
|
super().set_rows(rows)
|
||||||
|
# The shared table restores an unsorted row index after enabling sort.
|
||||||
|
# Locate the same source object in the finished visual order instead.
|
||||||
|
if selected_id is not None:
|
||||||
|
for row_index in range(self.rowCount()):
|
||||||
|
item = self.item(row_index, 0)
|
||||||
|
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||||
|
row_id = first_value(row, "id", "template_id", default=None)
|
||||||
|
if row_id is not None and str(row_id) == str(selected_id):
|
||||||
|
self.selectRow(row_index)
|
||||||
|
return
|
||||||
|
self.clearSelection()
|
||||||
|
self.setCurrentCell(-1, -1)
|
||||||
|
|
||||||
|
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
|
||||||
|
super().setCellWidget(row, column, widget)
|
||||||
|
if widget is None or column != 9:
|
||||||
|
return
|
||||||
|
widget.setObjectName("PrescriptionLibraryTableCellHost")
|
||||||
|
widget.setAutoFillBackground(False)
|
||||||
|
glyphs = {
|
||||||
|
"查看处方模板": "eye",
|
||||||
|
"AI 解释": "spark",
|
||||||
|
"编辑处方模板": "pencil",
|
||||||
|
"删除处方模板": "trash",
|
||||||
|
}
|
||||||
|
for button in widget.findChildren(QPushButton):
|
||||||
|
if not button.property("rowAction"):
|
||||||
|
continue
|
||||||
|
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
|
||||||
|
if glyph is not None:
|
||||||
|
color = "#BE4657" if button.property("danger") else "#1769E8"
|
||||||
|
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
|
||||||
|
|
||||||
|
|
||||||
|
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
|
||||||
|
font = QFont(heading_family() if medium else body_family())
|
||||||
|
font.setPixelSize(size)
|
||||||
|
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
|
||||||
|
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
|
||||||
|
|
||||||
|
|
||||||
|
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||||
|
"""Wrap between complete herb entries; elide only the displayed lines."""
|
||||||
|
entries = text.split("、")
|
||||||
|
first = entries[0]
|
||||||
|
next_index = 1
|
||||||
|
while next_index < min(2, len(entries)):
|
||||||
|
candidate = first + "、" + entries[next_index]
|
||||||
|
if metrics.horizontalAdvance(candidate) > width:
|
||||||
|
break
|
||||||
|
first = candidate
|
||||||
|
next_index += 1
|
||||||
|
lines = [_elide(first, metrics, width)]
|
||||||
|
if next_index < len(entries):
|
||||||
|
lines.append(_elide("、".join(entries[next_index:]), metrics, width))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
|
||||||
|
if metrics.horizontalAdvance(text) <= width:
|
||||||
|
return [text]
|
||||||
|
split = 1
|
||||||
|
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
|
||||||
|
split += 1
|
||||||
|
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryItemDelegate(QStyledItemDelegate):
|
||||||
|
"""Paint the ten existing columns without changing display or source roles."""
|
||||||
|
|
||||||
|
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
|
||||||
|
size = super().sizeHint(option, index)
|
||||||
|
size.setHeight(78)
|
||||||
|
return size
|
||||||
|
|
||||||
|
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
|
||||||
|
if event is not None and index.isValid() and index.column() != 9:
|
||||||
|
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
|
||||||
|
if text:
|
||||||
|
QToolTip.showText(event.globalPos(), str(text), view)
|
||||||
|
return True
|
||||||
|
return super().helpEvent(event, view, option, index)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _paint_lines(
|
||||||
|
painter: QPainter,
|
||||||
|
rect: QRectF,
|
||||||
|
lines: list[str],
|
||||||
|
*,
|
||||||
|
secondary_muted: bool = False,
|
||||||
|
) -> None:
|
||||||
|
line_height = 26
|
||||||
|
top = rect.center().y() - len(lines) * line_height / 2
|
||||||
|
for line_index, line in enumerate(lines):
|
||||||
|
if secondary_muted and line_index:
|
||||||
|
painter.setFont(_library_font(13))
|
||||||
|
painter.setPen(QColor("#5D6B80"))
|
||||||
|
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
|
||||||
|
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
|
||||||
|
painter.save()
|
||||||
|
try:
|
||||||
|
painter.setClipRect(option.rect)
|
||||||
|
selected = bool(option.state & QStyle.StateFlag.State_Selected)
|
||||||
|
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
|
||||||
|
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
|
||||||
|
painter.fillRect(option.rect, QColor(background))
|
||||||
|
painter.setPen(QColor("#E6EDF6"))
|
||||||
|
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
|
||||||
|
if selected and index.column() == 0:
|
||||||
|
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
|
||||||
|
painter.fillRect(stripe, QColor("#1769E8"))
|
||||||
|
if option.state & QStyle.StateFlag.State_HasFocus:
|
||||||
|
painter.setPen(QColor("#75A5F0"))
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
|
||||||
|
|
||||||
|
column = index.column()
|
||||||
|
if column == 9:
|
||||||
|
return
|
||||||
|
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
|
||||||
|
if rect.width() <= 0:
|
||||||
|
return
|
||||||
|
value = index.data(Qt.ItemDataRole.DisplayRole)
|
||||||
|
text = "" if value is None else str(value)
|
||||||
|
font = _library_font(medium=column == 1)
|
||||||
|
painter.setFont(font)
|
||||||
|
painter.setPen(QColor("#273244"))
|
||||||
|
metrics = QFontMetrics(font)
|
||||||
|
width = int(rect.width())
|
||||||
|
|
||||||
|
if column == 2:
|
||||||
|
painter.setFont(_library_font(13))
|
||||||
|
metrics = painter.fontMetrics()
|
||||||
|
label = _elide(text, metrics, width - 12)
|
||||||
|
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
|
||||||
|
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor("#EAF2FF"))
|
||||||
|
painter.drawRoundedRect(pill, 4, 4)
|
||||||
|
painter.setPen(QColor("#1769E8"))
|
||||||
|
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
|
||||||
|
elif column == 6:
|
||||||
|
glyph = "users" if text == "所有人可见" else "lock"
|
||||||
|
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
|
||||||
|
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
|
||||||
|
label_rect = rect.adjusted(22, 0, 0, 0)
|
||||||
|
painter.drawText(
|
||||||
|
label_rect,
|
||||||
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
_elide(text, metrics, int(label_rect.width())),
|
||||||
|
)
|
||||||
|
elif column == 4:
|
||||||
|
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
|
||||||
|
elif column == 8:
|
||||||
|
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
|
||||||
|
lines = [_elide(parts[0], metrics, width)]
|
||||||
|
if len(parts) > 1:
|
||||||
|
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
|
||||||
|
self._paint_lines(painter, rect, lines, secondary_muted=True)
|
||||||
|
elif column == 1:
|
||||||
|
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
|
||||||
|
else:
|
||||||
|
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
|
||||||
|
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
)
|
||||||
|
painter.drawText(rect, alignment, _elide(text, metrics, width))
|
||||||
|
finally:
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
|
||||||
|
_LIBRARY = """
|
||||||
|
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
|
||||||
|
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
|
||||||
|
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
|
||||||
|
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
|
||||||
|
background: transparent; border: 0;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
|
||||||
|
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
|
||||||
|
#PrescriptionLibraryPage QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
|
||||||
|
#PrescriptionLibraryPage QFrame#MetricCard,
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
|
||||||
|
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
|
||||||
|
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
|
||||||
|
border: 0; border-radius: 0; background: transparent;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||||
|
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||||
|
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
|
||||||
|
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
|
||||||
|
background: #155BCC; border-color: #155BCC;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
|
||||||
|
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
|
||||||
|
background: #124EA9; border-color: #124EA9;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
|
||||||
|
#PrescriptionLibraryPage QPushButton:disabled,
|
||||||
|
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
|
||||||
|
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||||
|
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
|
||||||
|
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
|
||||||
|
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
|
||||||
|
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
|
||||||
|
#PrescriptionLibraryPage QComboBox QAbstractItemView {
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
|
||||||
|
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
|
||||||
|
background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 0; color: #273244; font-size: 14px;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
|
||||||
|
color: #1769E8; border-bottom-color: #1769E8;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
|
||||||
|
color: #1555B6; background: #F2F7FF;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QTableWidget {
|
||||||
|
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
|
||||||
|
#PrescriptionLibraryPage QHeaderView::section {
|
||||||
|
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
|
||||||
|
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||||
|
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||||
|
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
|
||||||
|
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
|
||||||
|
font-size: 24px; color: #1769E8; background: #F2F7FF;
|
||||||
|
border: 1px solid #DBE5F2; border-radius: 22px;
|
||||||
|
}
|
||||||
|
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
|
||||||
|
|
||||||
|
from string import Template
|
||||||
|
|
||||||
|
from .reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
def prescriptions_stylesheet() -> str:
|
||||||
|
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
|
||||||
|
|
||||||
|
|
||||||
|
_PRESCRIPTIONS = """
|
||||||
|
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
|
||||||
|
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
|
||||||
|
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
|
||||||
|
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
|
||||||
|
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLabel[role="pageTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLabel[role="sectionTitle"] {
|
||||||
|
font-family: "$heading"; font-size: 14px; font-weight: 600;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
|
||||||
|
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
|
||||||
|
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
|
||||||
|
#PrescriptionsPage QPushButton {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
|
||||||
|
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
|
||||||
|
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
|
||||||
|
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
|
||||||
|
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
|
||||||
|
background: #155BCC; border-color: #155BCC;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
|
||||||
|
background: #124EA9; border-color: #124EA9;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
|
||||||
|
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
|
||||||
|
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
|
||||||
|
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
|
||||||
|
border-radius: 5px; background: #FFFFFF; color: #273244;
|
||||||
|
selection-background-color: #DCEAFF; selection-color: #273244;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
|
||||||
|
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
|
||||||
|
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
|
||||||
|
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
|
||||||
|
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
|
||||||
|
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
|
||||||
|
#PrescriptionsPage QComboBox QAbstractItemView {
|
||||||
|
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
|
||||||
|
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
|
||||||
|
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
|
||||||
|
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
|
||||||
|
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
|
||||||
|
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
|
||||||
|
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
|
||||||
|
#PrescriptionsPage QTableWidget#PrescriptionTable {
|
||||||
|
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
|
||||||
|
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
|
||||||
|
#PrescriptionsPage QHeaderView::section {
|
||||||
|
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
|
||||||
|
border: 0; border-bottom: 1px solid #DBE5F2;
|
||||||
|
font-family: "$body"; font-size: 13px; font-weight: 400;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"] {
|
||||||
|
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
|
||||||
|
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
|
||||||
|
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
|
||||||
|
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
|
||||||
|
background: transparent; border-color: transparent; color: #A4ADBA;
|
||||||
|
}
|
||||||
|
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
|
||||||
|
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
|
||||||
|
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
|
||||||
|
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
|
||||||
|
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
|
||||||
|
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
|
||||||
|
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
|
||||||
|
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
|
||||||
|
"""
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Palette and real font families for the approved reception page only."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtGui import QFontDatabase
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.resources import resource_path
|
||||||
|
|
||||||
|
TECH_BLUE = {
|
||||||
|
"accent": "#1769E8",
|
||||||
|
"accent_hover": "#155BCC",
|
||||||
|
"accent_pressed": "#124EA9",
|
||||||
|
"selection": "#EAF2FF",
|
||||||
|
"selected_text": "#1555B6",
|
||||||
|
"canvas": "#F3F7FD",
|
||||||
|
"sidebar": "#EDF4FF",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"raised": "#F7FAFE",
|
||||||
|
"line": "#DBE5F2",
|
||||||
|
"text": "#273244",
|
||||||
|
"heading": "#202C3F",
|
||||||
|
"muted": "#5D6B80",
|
||||||
|
"focus": "#75A5F0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _families() -> dict[str, str]:
|
||||||
|
"""Resolve after Qt starts; font registration does not change its theme."""
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
raise RuntimeError("Reception font families require a QApplication")
|
||||||
|
cached = getattr(app, "_reception_font_families", None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
heading = getattr(app, "_doctor_bundled_font_family", None)
|
||||||
|
if not heading:
|
||||||
|
font_path = resource_path("fonts", "NotoSansSC-VF.ttf")
|
||||||
|
if font_path.is_file():
|
||||||
|
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||||
|
registered = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
|
||||||
|
if registered:
|
||||||
|
heading = registered[0]
|
||||||
|
app._doctor_bundled_font_family = heading
|
||||||
|
|
||||||
|
available = set(QFontDatabase.families())
|
||||||
|
# Qt's offscreen platform does not enumerate Windows fonts automatically.
|
||||||
|
# Register only files already installed on this machine, never substitutes
|
||||||
|
# downloaded or installed into the user's Windows font registry.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
font_dir = (
|
||||||
|
Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or "C:/Windows") / "Fonts"
|
||||||
|
)
|
||||||
|
for family, filenames in (
|
||||||
|
("Microsoft YaHei UI", ("msyh.ttc",)),
|
||||||
|
("Segoe UI", ("segoeui.ttf", "seguisb.ttf")),
|
||||||
|
):
|
||||||
|
if family in available:
|
||||||
|
continue
|
||||||
|
for filename in filenames:
|
||||||
|
font_path = font_dir / filename
|
||||||
|
if not font_path.is_file():
|
||||||
|
continue
|
||||||
|
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||||
|
if font_id >= 0:
|
||||||
|
available.update(QFontDatabase.applicationFontFamilies(font_id))
|
||||||
|
|
||||||
|
fallback = heading or next(
|
||||||
|
(
|
||||||
|
family
|
||||||
|
for family in ("Noto Sans SC", "Noto Sans CJK SC", "PingFang SC")
|
||||||
|
if family in available
|
||||||
|
),
|
||||||
|
app.font().family(),
|
||||||
|
)
|
||||||
|
resolved = {
|
||||||
|
"body": "Microsoft YaHei UI" if "Microsoft YaHei UI" in available else fallback,
|
||||||
|
"heading": heading or fallback,
|
||||||
|
"number": "Segoe UI" if "Segoe UI" in available else fallback,
|
||||||
|
}
|
||||||
|
app._reception_font_families = resolved
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def body_family() -> str:
|
||||||
|
"""Regular Chinese body copy; YaHei UI has no genuine Medium face."""
|
||||||
|
|
||||||
|
return _families()["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def heading_family() -> str:
|
||||||
|
"""Bundled Noto Sans SC supplies genuine Medium and Semibold faces."""
|
||||||
|
|
||||||
|
return _families()["heading"]
|
||||||
|
|
||||||
|
|
||||||
|
def number_family() -> str:
|
||||||
|
"""Segoe UI supplies Regular and Semibold for numeric labels."""
|
||||||
|
|
||||||
|
return _families()["number"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,4 @@
|
|||||||
"""Application-wide visual system for the AI consultation workstation.
|
"""Shared typography and neutral reading surfaces for the clinical workspace."""
|
||||||
|
|
||||||
The palette and density follow the supplied product references: a quiet blue
|
|
||||||
canvas, crisp white data surfaces, luminous indigo actions and compact tables.
|
|
||||||
|
|
||||||
Every size in the interface comes from the token tables below. Before they
|
|
||||||
existed the UI had grown 19 distinct font sizes and 8 control heights, which is
|
|
||||||
what made neighbouring controls look subtly mismatched; keep new work on the
|
|
||||||
scale instead of introducing another one-off pixel value.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,12 +8,15 @@ from pathlib import Path
|
|||||||
from string import Template
|
from string import Template
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, Qt
|
from PySide6.QtCore import QEvent, QObject, QPointF, Qt
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor,
|
QColor,
|
||||||
QFont,
|
QFont,
|
||||||
QFontDatabase,
|
QFontDatabase,
|
||||||
|
QPainter,
|
||||||
|
QPainterPath,
|
||||||
QPalette,
|
QPalette,
|
||||||
|
QPen,
|
||||||
QPixmap,
|
QPixmap,
|
||||||
QTextBlockFormat,
|
QTextBlockFormat,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
@@ -35,50 +29,57 @@ from PySide6.QtWidgets import (
|
|||||||
QFileDialog,
|
QFileDialog,
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QScrollArea,
|
||||||
QTextBrowser,
|
QTextBrowser,
|
||||||
|
QTextEdit,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
|
from doctor_workstation.resources import resource_path
|
||||||
# callers while the stylesheet itself is generated from this mapping.
|
|
||||||
|
# ``motion`` deliberately imports nothing from this module, so this stays a
|
||||||
|
# one-way dependency: tokens here, animation there.
|
||||||
|
from . import motion
|
||||||
|
|
||||||
|
# Neutral reading colors follow the installed Codex light chrome defaults.
|
||||||
|
# Brand accents are independent of reading ink; use opaque text for stable contrast.
|
||||||
|
# The legacy teal/ink aliases remain for existing callers.
|
||||||
COLORS = {
|
COLORS = {
|
||||||
# Sampled from the supplied 1710 x 920 product comps. The window edge is
|
"canvas": "#F4F6FA",
|
||||||
# the only blue-tinted surface; the application workspace itself is an
|
"canvas_mid": "#FFFFFF",
|
||||||
# almost-white #FCFDFE field.
|
"canvas_glow": "#EEF1FA",
|
||||||
"canvas": "#EEF3FD",
|
|
||||||
"canvas_mid": "#F7F9FE",
|
|
||||||
"canvas_glow": "#E5ECFD",
|
|
||||||
"surface": "#FFFFFF",
|
"surface": "#FFFFFF",
|
||||||
"surface_alt": "#FAFBFE",
|
"surface_alt": "#F7F7F7",
|
||||||
"raised": "#F5F7FC",
|
"raised": "#F7F7F7",
|
||||||
"glass": "rgba(255, 255, 255, 252)",
|
"glass": "#FFFFFF",
|
||||||
"glass_alt": "rgba(248, 250, 255, 252)",
|
"glass_alt": "#F7F7F7",
|
||||||
"line": "#E6EAF5",
|
"line": "#EDEDEE",
|
||||||
"line_soft": "rgba(82, 97, 246, 40)",
|
"line_soft": "#E4E4E5",
|
||||||
"text": "#111F46",
|
"text": "#1A1C1F",
|
||||||
"text_soft": "#3F4E75",
|
"text_soft": "#606163",
|
||||||
"muted": "#7886AA",
|
"muted": "#6A6B6D",
|
||||||
"disabled_surface": "#F0F2F8",
|
"disabled_surface": "#F2F2F2",
|
||||||
"disabled_text": "#A4ADC3",
|
"disabled_text": "#8E8F90",
|
||||||
"indigo": "#5761F4",
|
"indigo": "#4F63D9",
|
||||||
"indigo_hover": "#4C57E9",
|
"indigo_hover": "#4156C4",
|
||||||
"indigo_pressed": "#4451E2",
|
"indigo_pressed": "#354BB4",
|
||||||
"indigo_pale": "#F0F2FF",
|
"indigo_pale": "#EEF1FA",
|
||||||
"focus": "#8D9BFF",
|
"focus": "#8B9AD9",
|
||||||
"selection": "#EDF0FF",
|
"selection": "#EEF1FA",
|
||||||
"success": "#17A77D",
|
"success": "#287B65",
|
||||||
"success_pale": "#EAF9F3",
|
"success_pale": "#EEF7F3",
|
||||||
"warning": "#D38625",
|
"warning": "#A9691D",
|
||||||
"warning_pale": "#FFF5E6",
|
"warning_pale": "#FCF5E9",
|
||||||
"danger": "#F15B67",
|
"danger": "#BE4B58",
|
||||||
"danger_pale": "#FFF1F3",
|
"danger_pale": "#FCF0F2",
|
||||||
"info": "#4D69ED",
|
"info": "#4F63D9",
|
||||||
"info_pale": "#F0F4FF",
|
"info_pale": "#EEF1FA",
|
||||||
# Backward-compatible names used by older UI code and integrations.
|
# Backward-compatible names used by older UI code and integrations.
|
||||||
"ink": "#111F46",
|
"ink": "#1A1C1F",
|
||||||
"ink_soft": "#3F4E75",
|
"ink_soft": "#606163",
|
||||||
"teal": "#5761F4",
|
"teal": "#4F63D9",
|
||||||
"teal_dark": "#4451E2",
|
"teal_dark": "#354BB4",
|
||||||
"teal_pale": "#F0F2FF",
|
"teal_pale": "#EEF1FA",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -87,32 +88,47 @@ COLORS = {
|
|||||||
# more ink than Latin at the same pixel size, so the steps are spaced widely
|
# more ink than Latin at the same pixel size, so the steps are spaced widely
|
||||||
# enough that two adjacent levels are always distinguishable.
|
# enough that two adjacent levels are always distinguishable.
|
||||||
TYPE = {
|
TYPE = {
|
||||||
"fs_caption": "12px", # table headers, hints, badges, timestamps
|
"fs_caption": "13px", # table headers, hints, badges, timestamps
|
||||||
"fs_body": "13px", # default UI text
|
"fs_body": "14px", # default UI text
|
||||||
"fs_strong": "14px", # emphasised body, dialog prompts
|
"fs_strong": "15px", # emphasised body, dialog prompts
|
||||||
"fs_section": "16px", # card and section titles
|
"fs_section": "16px", # card and section titles
|
||||||
"fs_title": "20px", # page titles, dialog titles
|
"fs_title": "20px", # page titles, dialog titles
|
||||||
"fs_display": "26px", # metric values, empty-state glyphs
|
"fs_display": "26px", # metric values, empty-state glyphs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The bundled variable font supplies real Regular, Medium and Semibold faces.
|
||||||
|
# Keep ordinary controls at Medium and reserve Semibold for headings.
|
||||||
|
WEIGHTS = {
|
||||||
|
"fw_body": "400",
|
||||||
|
"fw_control": "500",
|
||||||
|
"fw_heading": "600",
|
||||||
|
}
|
||||||
|
|
||||||
# --- Control metrics ------------------------------------------------------
|
# --- Control metrics ------------------------------------------------------
|
||||||
# Three interactive heights. ``h_default`` drops from the previous 36px: the
|
# Controls leave room for the 14px reading size without increasing table density.
|
||||||
# old value made every toolbar, filter row and inline action read as heavy,
|
|
||||||
# which is the main reason the workspace felt clunky.
|
|
||||||
METRICS = {
|
METRICS = {
|
||||||
"h_compact": "28px", # inline row actions, chips, links
|
"h_compact": "28px", # inline row actions, chips, links
|
||||||
"h_default": "32px", # buttons, inputs, combos, tabs
|
"h_default": "34px", # buttons, inputs, combos, tabs
|
||||||
"h_cta": "38px", # primary dialog actions, sidebar navigation
|
"h_cta": "38px", # primary dialog actions, sidebar navigation
|
||||||
"h_bar": "52px", # dialog header / footer bars
|
"h_bar": "52px", # dialog header / footer bars
|
||||||
"r_sm": "6px",
|
# Corner radii. These existed before but the pages invented their own, so
|
||||||
"r_md": "8px",
|
# the same role - a card - shipped at 10, 11, 12, 13, 14 and 16 px across
|
||||||
"r_lg": "12px",
|
# six pages. Nothing in a product is a "10px card"; it is either a card or
|
||||||
"r_xl": "16px",
|
# it is not, and it should round like every other card next to it.
|
||||||
|
#
|
||||||
|
# Nesting rule: an element sitting flush inside a rounded container takes
|
||||||
|
# ``outer - gap``. Where the gap is bigger than the outer radius the inner
|
||||||
|
# element is far enough from the corner that its own radius is free.
|
||||||
|
"r_xs": "4px", # chips, badges, tiny status pills
|
||||||
|
"r_sm": "6px", # inline row actions, tags
|
||||||
|
"r_md": "8px", # buttons, inputs, combo boxes
|
||||||
|
"r_lg": "12px", # cards, filter bars, panels
|
||||||
|
"r_xl": "16px", # the shell surfaces the cards sit on
|
||||||
"pad_control": "12px", # horizontal padding inside default controls
|
"pad_control": "12px", # horizontal padding inside default controls
|
||||||
"pad_compact": "9px",
|
"pad_compact": "9px",
|
||||||
}
|
}
|
||||||
|
|
||||||
_QSS_TOKENS = {**COLORS, **TYPE, **METRICS}
|
_QSS_TOKENS = {**COLORS, **TYPE, **WEIGHTS, **METRICS}
|
||||||
|
|
||||||
|
|
||||||
def crisp_pixmap(width: int, height: int | None = None) -> QPixmap:
|
def crisp_pixmap(width: int, height: int | None = None) -> QPixmap:
|
||||||
@@ -144,8 +160,8 @@ GLOBAL_QSS = Template(
|
|||||||
QWidget {
|
QWidget {
|
||||||
color: $text;
|
color: $text;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
|
||||||
font-size: $fs_body;
|
font-size: $fs_body;
|
||||||
|
font-weight: $fw_body;
|
||||||
}
|
}
|
||||||
|
|
||||||
QMainWindow, QDialog, QWidget#LoginCanvas {
|
QMainWindow, QDialog, QWidget#LoginCanvas {
|
||||||
@@ -170,7 +186,7 @@ QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
|
|||||||
QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
|
QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
|
||||||
color: $text;
|
color: $text;
|
||||||
font-size: $fs_section;
|
font-size: $fs_section;
|
||||||
font-weight: 700;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
|
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
|
||||||
color: $muted;
|
color: $muted;
|
||||||
@@ -214,48 +230,43 @@ QInputDialog QLabel {
|
|||||||
font-size: $fs_strong;
|
font-size: $fs_strong;
|
||||||
}
|
}
|
||||||
QWidget#AppCanvas {
|
QWidget#AppCanvas {
|
||||||
background-color: qlineargradient(
|
background-color: $canvas;
|
||||||
x1: 0, y1: 0, x2: 1, y2: 1,
|
|
||||||
stop: 0 $canvas,
|
|
||||||
stop: 0.58 $canvas_mid,
|
|
||||||
stop: 1 $canvas_glow
|
|
||||||
);
|
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
|
QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
|
||||||
background-color: #FCFDFE;
|
background-color: $canvas_mid;
|
||||||
}
|
}
|
||||||
|
|
||||||
QLabel[role="muted"] { color: $muted; }
|
QLabel[role="muted"] { color: $muted; }
|
||||||
QLabel[role="danger"] { color: $danger; }
|
QLabel[role="danger"] { color: $danger; }
|
||||||
QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; }
|
QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; }
|
||||||
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: $fs_strong; }
|
QLabel[role="breadcrumbSeparator"] { color: #8E8F90; font-size: $fs_strong; }
|
||||||
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: 600; }
|
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: $fw_control; }
|
||||||
QLabel[role="eyebrow"] {
|
QLabel[role="eyebrow"] {
|
||||||
color: $indigo_hover;
|
color: $indigo_hover;
|
||||||
font-size: $fs_caption;
|
font-size: $fs_caption;
|
||||||
font-weight: 700;
|
font-weight: $fw_control;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
QLabel[role="pageTitle"] {
|
QLabel[role="pageTitle"] {
|
||||||
color: $text;
|
color: $text;
|
||||||
font-size: $fs_title;
|
font-size: $fs_title;
|
||||||
font-weight: 700;
|
font-weight: $fw_heading;
|
||||||
}
|
}
|
||||||
QLabel[role="sectionTitle"] {
|
QLabel[role="sectionTitle"] {
|
||||||
color: $text;
|
color: $text;
|
||||||
font-size: $fs_section;
|
font-size: $fs_section;
|
||||||
font-weight: 700;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QLabel[role="display"] {
|
QLabel[role="display"] {
|
||||||
color: $text;
|
color: $text;
|
||||||
font-size: $fs_display;
|
font-size: $fs_display;
|
||||||
font-weight: 700;
|
font-weight: $fw_heading;
|
||||||
}
|
}
|
||||||
QLabel[role="metric"] {
|
QLabel[role="metric"] {
|
||||||
color: $text;
|
color: $text;
|
||||||
font-size: $fs_title;
|
font-size: $fs_title;
|
||||||
font-weight: 700;
|
font-weight: $fw_heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
|
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
|
||||||
@@ -277,19 +288,19 @@ QFrame#MetricCard {
|
|||||||
}
|
}
|
||||||
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
|
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
|
||||||
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
|
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
|
||||||
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: $fs_title; font-weight: 700; }
|
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: $fs_title; font-weight: $fw_heading; }
|
||||||
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; }
|
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; }
|
||||||
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
|
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
|
||||||
QFrame#ReceptionAiCard {
|
QFrame#ReceptionAiCard {
|
||||||
min-height: 132px;
|
min-height: 132px;
|
||||||
background-color: #F8FAFF;
|
background-color: #FFFFFF;
|
||||||
border: 1px solid $line_soft;
|
border: 1px solid $line_soft;
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
}
|
}
|
||||||
QLabel#ReceptionAiTitle {
|
QLabel#ReceptionAiTitle {
|
||||||
color: $indigo_pressed;
|
color: $indigo_pressed;
|
||||||
font-size: $fs_strong;
|
font-size: $fs_strong;
|
||||||
font-weight: 700;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
|
QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
|
||||||
min-height: $h_compact;
|
min-height: $h_compact;
|
||||||
@@ -302,7 +313,7 @@ QLabel#MetricGlyph {
|
|||||||
border: 1px solid $line_soft;
|
border: 1px solid $line_soft;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
font-size: $fs_section;
|
font-size: $fs_section;
|
||||||
font-weight: 700;
|
font-weight: $fw_heading;
|
||||||
}
|
}
|
||||||
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
|
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
|
||||||
QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; }
|
QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; }
|
||||||
@@ -318,7 +329,7 @@ QGroupBox {
|
|||||||
border: 1px solid $line;
|
border: 1px solid $line;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background-color: $glass;
|
background-color: $glass;
|
||||||
font-weight: 600;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QGroupBox::title {
|
QGroupBox::title {
|
||||||
subcontrol-origin: margin;
|
subcontrol-origin: margin;
|
||||||
@@ -334,7 +345,7 @@ QPushButton {
|
|||||||
border-radius: $r_md;
|
border-radius: $r_md;
|
||||||
background-color: $surface;
|
background-color: $surface;
|
||||||
color: $text;
|
color: $text;
|
||||||
font-weight: 600;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: $raised;
|
background-color: $raised;
|
||||||
@@ -345,7 +356,8 @@ QPushButton:pressed {
|
|||||||
border-color: $indigo_pressed;
|
border-color: $indigo_pressed;
|
||||||
}
|
}
|
||||||
QPushButton:focus {
|
QPushButton:focus {
|
||||||
border: 2px solid $focus;
|
border: 1px solid $indigo;
|
||||||
|
background-color: $indigo_pale;
|
||||||
}
|
}
|
||||||
QPushButton:checked {
|
QPushButton:checked {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
@@ -361,10 +373,7 @@ QPushButton:disabled {
|
|||||||
|
|
||||||
QPushButton[variant="primary"] {
|
QPushButton[variant="primary"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background-color: qlineargradient(
|
background-color: $indigo;
|
||||||
x1: 0, y1: 0, x2: 1, y2: 0,
|
|
||||||
stop: 0 $indigo, stop: 1 #7769F7
|
|
||||||
);
|
|
||||||
border-color: $indigo;
|
border-color: $indigo;
|
||||||
}
|
}
|
||||||
QPushButton[variant="primary"]:hover,
|
QPushButton[variant="primary"]:hover,
|
||||||
@@ -377,7 +386,10 @@ QPushButton[variant="primary"]:checked {
|
|||||||
background-color: $indigo_pressed;
|
background-color: $indigo_pressed;
|
||||||
border-color: $indigo_pressed;
|
border-color: $indigo_pressed;
|
||||||
}
|
}
|
||||||
QPushButton[variant="primary"]:focus { border: 2px solid $focus; }
|
QPushButton[variant="primary"]:focus {
|
||||||
|
border: 1px solid $indigo_pressed;
|
||||||
|
background-color: $indigo_hover;
|
||||||
|
}
|
||||||
QPushButton[variant="primary"]:disabled {
|
QPushButton[variant="primary"]:disabled {
|
||||||
color: $disabled_text;
|
color: $disabled_text;
|
||||||
background-color: $surface_alt;
|
background-color: $surface_alt;
|
||||||
@@ -410,7 +422,7 @@ QWidget#RowActions QPushButton[rowAction="true"] {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: $r_sm;
|
border-radius: $r_sm;
|
||||||
font-weight: 600;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QWidget#RowActions QPushButton[rowAction="true"]:hover {
|
QWidget#RowActions QPushButton[rowAction="true"]:hover {
|
||||||
color: $indigo_pressed;
|
color: $indigo_pressed;
|
||||||
@@ -436,7 +448,7 @@ QToolButton#RowActionsMore {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: $r_sm;
|
border-radius: $r_sm;
|
||||||
font-weight: 600;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QToolButton#RowActionsMore:hover {
|
QToolButton#RowActionsMore:hover {
|
||||||
color: $text;
|
color: $text;
|
||||||
@@ -457,7 +469,7 @@ QPushButton#NoteAttachmentPreview {
|
|||||||
background-color: $surface_alt;
|
background-color: $surface_alt;
|
||||||
border: 1px solid $line;
|
border: 1px solid $line;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 500;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QPushButton#NoteAttachmentPreview:hover {
|
QPushButton#NoteAttachmentPreview:hover {
|
||||||
background-color: $indigo_pale;
|
background-color: $indigo_pale;
|
||||||
@@ -503,15 +515,15 @@ QPushButton[variant="success"] {
|
|||||||
background-color: $success;
|
background-color: $success;
|
||||||
border-color: $success;
|
border-color: $success;
|
||||||
}
|
}
|
||||||
QPushButton[variant="success"]:hover { background-color: #65D4B7; border-color: #65D4B7; }
|
QPushButton[variant="success"]:hover { background-color: #216A56; border-color: #216A56; }
|
||||||
QPushButton[variant="success"]:pressed { background-color: #319F84; border-color: #319F84; }
|
QPushButton[variant="success"]:pressed { background-color: #1B5746; border-color: #1B5746; }
|
||||||
QPushButton[variant="warning"] {
|
QPushButton[variant="warning"] {
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
background-color: $warning;
|
background-color: $warning;
|
||||||
border-color: $warning;
|
border-color: $warning;
|
||||||
}
|
}
|
||||||
QPushButton[variant="warning"]:hover { background-color: #F0C97C; border-color: #F0C97C; }
|
QPushButton[variant="warning"]:hover { background-color: #925B19; border-color: #925B19; }
|
||||||
QPushButton[variant="warning"]:pressed { background-color: #B99045; border-color: #B99045; }
|
QPushButton[variant="warning"]:pressed { background-color: #794B14; border-color: #794B14; }
|
||||||
|
|
||||||
QPushButton[variant="ghost"] {
|
QPushButton[variant="ghost"] {
|
||||||
color: $text_soft;
|
color: $text_soft;
|
||||||
@@ -536,7 +548,7 @@ QPushButton[variant="link"] {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
QPushButton[variant="link"]:hover { color: $focus; background-color: $indigo_pale; }
|
QPushButton[variant="link"]:hover { color: $indigo_hover; background-color: $indigo_pale; }
|
||||||
QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
|
QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
|
||||||
QPushButton[variant="chip"] {
|
QPushButton[variant="chip"] {
|
||||||
min-height: $h_compact;
|
min-height: $h_compact;
|
||||||
@@ -564,7 +576,7 @@ QPushButton[variant="nav"] {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: $muted;
|
color: $muted;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; }
|
QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; }
|
||||||
QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; }
|
QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; }
|
||||||
@@ -580,7 +592,7 @@ QToolButton {
|
|||||||
}
|
}
|
||||||
QToolButton:hover { color: $text; background-color: $surface_alt; border-color: $line; }
|
QToolButton:hover { color: $text; background-color: $surface_alt; border-color: $line; }
|
||||||
QToolButton:pressed { background-color: $indigo_pale; border-color: $indigo_pressed; }
|
QToolButton:pressed { background-color: $indigo_pale; border-color: $indigo_pressed; }
|
||||||
QToolButton:focus { border: 2px solid $focus; }
|
QToolButton:focus { border: 1px solid $indigo; background-color: $indigo_pale; }
|
||||||
QToolButton:checked { color: #FFFFFF; background-color: $indigo_pressed; border-color: $indigo_hover; }
|
QToolButton:checked { color: #FFFFFF; background-color: $indigo_pressed; border-color: $indigo_hover; }
|
||||||
QToolButton:disabled { color: $disabled_text; background-color: transparent; border-color: transparent; }
|
QToolButton:disabled { color: $disabled_text; background-color: transparent; border-color: transparent; }
|
||||||
QToolButton[diagnosisChip="true"] {
|
QToolButton[diagnosisChip="true"] {
|
||||||
@@ -616,8 +628,8 @@ QDoubleSpinBox:hover, QKeySequenceEdit:hover { border-color: $indigo_hover; }
|
|||||||
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus,
|
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus,
|
||||||
QDateEdit:focus, QDateTimeEdit:focus, QTimeEdit:focus, QSpinBox:focus,
|
QDateEdit:focus, QDateTimeEdit:focus, QTimeEdit:focus, QSpinBox:focus,
|
||||||
QDoubleSpinBox:focus, QKeySequenceEdit:focus {
|
QDoubleSpinBox:focus, QKeySequenceEdit:focus {
|
||||||
border: 2px solid $focus;
|
border: 1px solid $indigo;
|
||||||
background-color: $surface_alt;
|
background-color: $surface;
|
||||||
}
|
}
|
||||||
QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only {
|
QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only {
|
||||||
color: $muted;
|
color: $muted;
|
||||||
@@ -676,7 +688,7 @@ QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
|
|||||||
QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView {
|
QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView {
|
||||||
color: $text;
|
color: $text;
|
||||||
background-color: $surface;
|
background-color: $surface;
|
||||||
alternate-background-color: #FBFCFF;
|
alternate-background-color: $surface_alt;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
gridline-color: $line;
|
gridline-color: $line;
|
||||||
@@ -684,7 +696,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
|
|||||||
selection-color: $text;
|
selection-color: $text;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
QAbstractItemView:focus { border: 1px solid $indigo_hover; }
|
QAbstractItemView:focus { border: 0; }
|
||||||
QTableWidget::item, QTableView::item {
|
QTableWidget::item, QTableView::item {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-bottom: 1px solid $line;
|
border-bottom: 1px solid $line;
|
||||||
@@ -695,14 +707,14 @@ QTableWidget::item:selected, QTableView::item:selected {
|
|||||||
background-color: $selection;
|
background-color: $selection;
|
||||||
}
|
}
|
||||||
QHeaderView::section {
|
QHeaderView::section {
|
||||||
background-color: #F7F9FE;
|
background-color: $raised;
|
||||||
color: $muted;
|
color: $muted;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-right: 1px solid $line;
|
border-right: 0;
|
||||||
border-bottom: 1px solid $line;
|
border-bottom: 1px solid $line;
|
||||||
padding: 7px 8px;
|
padding: 7px 8px;
|
||||||
font-size: $fs_caption;
|
font-size: $fs_caption;
|
||||||
font-weight: 700;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QHeaderView::section:hover { color: $text; background-color: $raised; }
|
QHeaderView::section:hover { color: $text; background-color: $raised; }
|
||||||
QTableCornerButton::section { background-color: $surface_alt; border: 0; }
|
QTableCornerButton::section { background-color: $surface_alt; border: 0; }
|
||||||
@@ -736,7 +748,7 @@ QTabBar::tab {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
font-weight: 600;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
|
QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
|
||||||
QTabBar::tab:focus { border-color: $focus; }
|
QTabBar::tab:focus { border-color: $focus; }
|
||||||
@@ -788,8 +800,8 @@ QMenu::item {
|
|||||||
border-radius: $r_sm;
|
border-radius: $r_sm;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
QMenu::item:selected { color: #FFFFFF; background-color: $indigo_pressed; }
|
QMenu::item:selected { color: $indigo_pressed; background-color: $indigo_pale; }
|
||||||
QMenu::item:pressed { background-color: $indigo; }
|
QMenu::item:pressed { color: #FFFFFF; background-color: $indigo; }
|
||||||
QMenu::item:disabled { color: $disabled_text; background-color: transparent; }
|
QMenu::item:disabled { color: $disabled_text; background-color: transparent; }
|
||||||
QMenu::item[danger="true"] { color: $danger; }
|
QMenu::item[danger="true"] { color: $danger; }
|
||||||
QMenu::item[danger="true"]:selected { color: $danger; background-color: $danger_pale; }
|
QMenu::item[danger="true"]:selected { color: $danger; background-color: $danger_pale; }
|
||||||
@@ -841,8 +853,8 @@ QScrollBar::handle:vertical {
|
|||||||
min-height: $h_compact;
|
min-height: $h_compact;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
QScrollBar::handle:vertical:hover { background: $indigo_pressed; }
|
QScrollBar::handle:vertical:hover { background: #A7B1C9; }
|
||||||
QScrollBar::handle:vertical:pressed { background: $indigo; }
|
QScrollBar::handle:vertical:pressed { background: #8894B2; }
|
||||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
|
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
|
||||||
QScrollBar:horizontal {
|
QScrollBar:horizontal {
|
||||||
@@ -855,8 +867,8 @@ QScrollBar::handle:horizontal {
|
|||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
QScrollBar::handle:horizontal:hover { background: $indigo_pressed; }
|
QScrollBar::handle:horizontal:hover { background: #A7B1C9; }
|
||||||
QScrollBar::handle:horizontal:pressed { background: $indigo; }
|
QScrollBar::handle:horizontal:pressed { background: #8894B2; }
|
||||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
|
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
|
||||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
|
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
|
||||||
|
|
||||||
@@ -885,7 +897,7 @@ QLabel#StatusBadge {
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: $r_sm;
|
border-radius: $r_sm;
|
||||||
font-size: $fs_caption;
|
font-size: $fs_caption;
|
||||||
font-weight: 700;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QLabel#StatusBadge[kind="neutral"] { color: $muted; background-color: $surface_alt; border-color: $line; }
|
QLabel#StatusBadge[kind="neutral"] { color: $muted; background-color: $surface_alt; border-color: $line; }
|
||||||
QLabel#StatusBadge[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 72); }
|
QLabel#StatusBadge[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 72); }
|
||||||
@@ -902,7 +914,7 @@ QWidget#Pager QLabel#PagerActive {
|
|||||||
border: 1px solid $indigo;
|
border: 1px solid $indigo;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: $fs_caption;
|
font-size: $fs_caption;
|
||||||
font-weight: 700;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QWidget#Pager QPushButton {
|
QWidget#Pager QPushButton {
|
||||||
min-height: $h_compact;
|
min-height: $h_compact;
|
||||||
@@ -918,7 +930,7 @@ QLabel#EmptyStateGlyph {
|
|||||||
border: 1px solid $line_soft;
|
border: 1px solid $line_soft;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
font-size: $fs_display;
|
font-size: $fs_display;
|
||||||
font-weight: 500;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
|
|
||||||
QFrame#MessageBanner {
|
QFrame#MessageBanner {
|
||||||
@@ -927,7 +939,7 @@ QFrame#MessageBanner {
|
|||||||
color: $text_soft;
|
color: $text_soft;
|
||||||
}
|
}
|
||||||
QFrame#MessageBanner QLabel { background-color: transparent; }
|
QFrame#MessageBanner QLabel { background-color: transparent; }
|
||||||
QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: 700; }
|
QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: $fw_heading; }
|
||||||
QFrame#MessageBanner[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 82); }
|
QFrame#MessageBanner[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 82); }
|
||||||
QFrame#MessageBanner[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 82); }
|
QFrame#MessageBanner[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 82); }
|
||||||
QFrame#MessageBanner[kind="warning"] { color: $warning; background-color: $warning_pale; border-color: rgba(228, 185, 103, 82); }
|
QFrame#MessageBanner[kind="warning"] { color: $warning; background-color: $warning_pale; border-color: rgba(228, 185, 103, 82); }
|
||||||
@@ -943,7 +955,7 @@ QLabel#Toast {
|
|||||||
border: 1px solid $line_soft;
|
border: 1px solid $line_soft;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
padding: 11px 16px;
|
padding: 11px 16px;
|
||||||
font-weight: 600;
|
font-weight: $fw_control;
|
||||||
}
|
}
|
||||||
QLabel#Toast[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 96); }
|
QLabel#Toast[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 96); }
|
||||||
QLabel#Toast[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 96); }
|
QLabel#Toast[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 96); }
|
||||||
@@ -978,7 +990,7 @@ QLabel#UserAvatar {
|
|||||||
border: 1px solid $line_soft;
|
border: 1px solid $line_soft;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
font-size: $fs_strong;
|
font-size: $fs_strong;
|
||||||
font-weight: 700;
|
font-weight: $fw_heading;
|
||||||
}
|
}
|
||||||
QWidget#LoginBrandPanel {
|
QWidget#LoginBrandPanel {
|
||||||
background-color: qlineargradient(
|
background-color: qlineargradient(
|
||||||
@@ -997,6 +1009,29 @@ QFrame#LoginCard {
|
|||||||
|
|
||||||
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
|
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
|
||||||
QSplitter::handle:hover { background-color: $indigo_pressed; }
|
QSplitter::handle:hover { background-color: $indigo_pressed; }
|
||||||
|
QPushButton[variant="secondary"]:disabled,
|
||||||
|
QPushButton[variant="secondary"]:checked:disabled,
|
||||||
|
QPushButton[variant="success"]:disabled,
|
||||||
|
QPushButton[variant="success"]:checked:disabled,
|
||||||
|
QPushButton[variant="warning"]:disabled,
|
||||||
|
QPushButton[variant="warning"]:checked:disabled,
|
||||||
|
QPushButton[variant="danger"]:disabled,
|
||||||
|
QPushButton[variant="danger"]:checked:disabled,
|
||||||
|
QPushButton[variant="dangerGhost"]:disabled,
|
||||||
|
QPushButton[variant="dangerGhost"]:checked:disabled,
|
||||||
|
QPushButton[variant="ghost"]:disabled,
|
||||||
|
QPushButton[variant="ghost"]:checked:disabled,
|
||||||
|
QPushButton[variant="link"]:disabled,
|
||||||
|
QPushButton[variant="link"]:checked:disabled,
|
||||||
|
QPushButton[variant="chip"]:disabled,
|
||||||
|
QPushButton[variant="chip"]:checked:disabled,
|
||||||
|
QPushButton[variant="nav"]:disabled,
|
||||||
|
QPushButton[variant="nav"]:checked:disabled {
|
||||||
|
color: $disabled_text;
|
||||||
|
background-color: $disabled_surface;
|
||||||
|
border-color: $line;
|
||||||
|
}
|
||||||
|
|
||||||
QToolTip {
|
QToolTip {
|
||||||
color: $text;
|
color: $text;
|
||||||
background-color: $raised;
|
background-color: $raised;
|
||||||
@@ -1024,6 +1059,166 @@ _BLOCK_RHYTHM = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Surfaces -------------------------------------------------------------
|
||||||
|
# There is deliberately no drop-shadow system here. One was tried: a painted
|
||||||
|
# layer behind each page's cards, on the theory that flat outlines were what
|
||||||
|
# made the workspace look unfinished. It does not work in this palette. The
|
||||||
|
# cards are #FFFFFF sitting on a #FFFFFF workspace - barely one percent apart -
|
||||||
|
# so a shadow has no tonal room to read as depth and instead composites into a
|
||||||
|
# neutral grey rim around every card, which against the blue-tinted ground looks
|
||||||
|
# like a dirty second border rather than elevation.
|
||||||
|
#
|
||||||
|
# Depth in this product comes from the border and the fill, not from shadow.
|
||||||
|
|
||||||
|
# --- Control indicator glyphs --------------------------------------------
|
||||||
|
# Styling ``QCheckBox::indicator`` with a background and border but no image
|
||||||
|
# tells Qt to stop drawing its own tick, so every checked box in the product
|
||||||
|
# rendered as a plain indigo square and the partially-checked state was an
|
||||||
|
# unlabelled grey one. Selection columns on the prescription, patient and
|
||||||
|
# diagnosis tables all depend on that tick, so the marks are painted here and
|
||||||
|
# handed back to the stylesheet as cached PNGs.
|
||||||
|
|
||||||
|
_INDICATOR_BOX = 15
|
||||||
|
_INDICATOR_REVISION = "1"
|
||||||
|
|
||||||
|
|
||||||
|
#: The dropdown caret is the one glyph Fusion still drew itself - a solid
|
||||||
|
#: triangle sitting beside an otherwise entirely stroked icon set. Rendering it
|
||||||
|
#: here brings every QComboBox, date field and tool-button menu onto the same
|
||||||
|
#: 24-unit grid as the rest of the product.
|
||||||
|
_CARET_BOX = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _indicator_pixmap(kind: str, color: str, ratio: int, box_size: int | None = None) -> QPixmap:
|
||||||
|
box = (box_size or _INDICATOR_BOX) * ratio
|
||||||
|
pixmap = QPixmap(box, box)
|
||||||
|
pixmap.fill(Qt.GlobalColor.transparent)
|
||||||
|
painter = QPainter(pixmap)
|
||||||
|
try:
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
scale = box / 24.0
|
||||||
|
painter.scale(scale, scale)
|
||||||
|
pen = QPen(QColor(color), 3.4)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
if kind == "check":
|
||||||
|
path = QPainterPath(QPointF(5.0, 12.5))
|
||||||
|
path.lineTo(QPointF(10.0, 17.5))
|
||||||
|
path.lineTo(QPointF(19.0, 6.5))
|
||||||
|
painter.drawPath(path)
|
||||||
|
elif kind == "dash":
|
||||||
|
painter.drawLine(QPointF(6.0, 12.0), QPointF(18.0, 12.0))
|
||||||
|
elif kind == "dot":
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor(color))
|
||||||
|
painter.drawEllipse(QPointF(12.0, 12.0), 4.6, 4.6)
|
||||||
|
elif kind == "caret":
|
||||||
|
pen.setWidthF(2.6)
|
||||||
|
painter.setPen(pen)
|
||||||
|
path = QPainterPath(QPointF(6.0, 9.5))
|
||||||
|
path.lineTo(QPointF(12.0, 15.5))
|
||||||
|
path.lineTo(QPointF(18.0, 9.5))
|
||||||
|
painter.drawPath(path)
|
||||||
|
finally:
|
||||||
|
painter.end()
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
|
||||||
|
def _indicator_asset_dir() -> Path | None:
|
||||||
|
try:
|
||||||
|
from platformdirs import user_cache_dir
|
||||||
|
|
||||||
|
from ..config import APP_AUTHOR, APP_NAME
|
||||||
|
|
||||||
|
directory = Path(user_cache_dir(APP_NAME, APP_AUTHOR)) / "indicators"
|
||||||
|
except Exception: # pragma: no cover - falls back to the user home
|
||||||
|
directory = Path.home() / ".zhenyangdoctor" / "indicators"
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError: # pragma: no cover - read-only deployment
|
||||||
|
return None
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def _indicator_url(kind: str, color: str, box_size: int | None = None) -> str | None:
|
||||||
|
"""Return a stylesheet ``url()`` body for one indicator mark, or None."""
|
||||||
|
|
||||||
|
directory = _indicator_asset_dir()
|
||||||
|
if directory is None:
|
||||||
|
return None
|
||||||
|
stem = f"{kind}-{color.lstrip('#').lower()}-{box_size or _INDICATOR_BOX}-{_INDICATOR_REVISION}"
|
||||||
|
base = directory / f"{stem}.png"
|
||||||
|
# Qt resolves the ``@2x`` companion itself on scaled displays, which keeps
|
||||||
|
# the mark crisp at the 125%/150% factors clinic workstations run at.
|
||||||
|
retina = directory / f"{stem}@2x.png"
|
||||||
|
try:
|
||||||
|
if not base.exists() and not _indicator_pixmap(kind, color, 1, box_size).save(
|
||||||
|
str(base), "PNG"
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
if not retina.exists():
|
||||||
|
_indicator_pixmap(kind, color, 2, box_size).save(str(retina), "PNG")
|
||||||
|
except OSError: # pragma: no cover - read-only deployment
|
||||||
|
return None
|
||||||
|
return base.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _indicator_qss() -> str:
|
||||||
|
"""Stylesheet fragment that restores the tick, dash and radio dot."""
|
||||||
|
|
||||||
|
marks = {
|
||||||
|
"check": _indicator_url("check", "#FFFFFF"),
|
||||||
|
"check_disabled": _indicator_url("check", COLORS["disabled_text"]),
|
||||||
|
"dash": _indicator_url("dash", "#FFFFFF"),
|
||||||
|
"dot": _indicator_url("dot", "#FFFFFF"),
|
||||||
|
"dot_disabled": _indicator_url("dot", COLORS["disabled_text"]),
|
||||||
|
"caret": _indicator_url("caret", COLORS["muted"], _CARET_BOX),
|
||||||
|
"caret_disabled": _indicator_url("caret", COLORS["disabled_text"], _CARET_BOX),
|
||||||
|
}
|
||||||
|
if any(value is None for value in marks.values()):
|
||||||
|
return ""
|
||||||
|
return f"""
|
||||||
|
QCheckBox::indicator:checked {{ image: url({marks["check"]}); }}
|
||||||
|
QCheckBox::indicator:indeterminate {{
|
||||||
|
background-color: {COLORS["indigo"]};
|
||||||
|
border: 1px solid {COLORS["indigo"]};
|
||||||
|
border-radius: 4px;
|
||||||
|
image: url({marks["dash"]});
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator:checked:disabled {{ image: url({marks["check_disabled"]}); }}
|
||||||
|
QRadioButton::indicator:checked {{ image: url({marks["dot"]}); }}
|
||||||
|
QRadioButton::indicator:checked:disabled {{ image: url({marks["dot_disabled"]}); }}
|
||||||
|
QComboBox::down-arrow, QDateEdit::down-arrow, QDateTimeEdit::down-arrow,
|
||||||
|
QTimeEdit::down-arrow {{
|
||||||
|
width: {_CARET_BOX}px;
|
||||||
|
height: {_CARET_BOX}px;
|
||||||
|
image: url({marks["caret"]});
|
||||||
|
}}
|
||||||
|
/* A tool button's menu indicator defaults to the bottom-right corner, which was
|
||||||
|
invisible while it was Fusion's 6 px triangle and became obvious once it was a
|
||||||
|
12 px chevron - the caret dropped below the label instead of sitting beside
|
||||||
|
it. Anchor it to the right edge; the buttons that show one already reserve
|
||||||
|
right padding for it, so nothing here changes their metrics. */
|
||||||
|
QToolButton::menu-indicator {{
|
||||||
|
width: {_CARET_BOX}px;
|
||||||
|
height: {_CARET_BOX}px;
|
||||||
|
image: url({marks["caret"]});
|
||||||
|
subcontrol-origin: padding;
|
||||||
|
subcontrol-position: right center;
|
||||||
|
right: 6px;
|
||||||
|
}}
|
||||||
|
/* Re-assert the suppressions that the block above would otherwise override.
|
||||||
|
These live here rather than in the main sheet because this fragment is
|
||||||
|
appended after it, and a later rule wins in Qt when specificity ties. */
|
||||||
|
QToolButton#RowActionsMore::menu-indicator {{ width: 0; height: 0; image: none; }}
|
||||||
|
QComboBox::down-arrow:disabled, QDateEdit::down-arrow:disabled,
|
||||||
|
QDateTimeEdit::down-arrow:disabled, QTimeEdit::down-arrow:disabled,
|
||||||
|
QToolButton::menu-indicator:disabled {{ image: url({marks["caret_disabled"]}); }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _block_is_all_bold(block: Any) -> bool:
|
def _block_is_all_bold(block: Any) -> bool:
|
||||||
"""True when every visible run in the block is bold.
|
"""True when every visible run in the block is bold.
|
||||||
|
|
||||||
@@ -1100,7 +1295,7 @@ def apply_reading_rhythm(browser: QTextBrowser, *, role: str) -> None:
|
|||||||
char_format = QTextCharFormat()
|
char_format = QTextCharFormat()
|
||||||
char_format.setFont(heading_font)
|
char_format.setFont(heading_font)
|
||||||
if role != "doctor":
|
if role != "doctor":
|
||||||
char_format.setForeground(QColor("#111B3F"))
|
char_format.setForeground(QColor("#1A1C1F"))
|
||||||
cursor.setPosition(block.position())
|
cursor.setPosition(block.position())
|
||||||
cursor.setPosition(
|
cursor.setPosition(
|
||||||
block.position() + block.length() - 1,
|
block.position() + block.length() - 1,
|
||||||
@@ -1123,14 +1318,21 @@ def _apply_group(
|
|||||||
|
|
||||||
|
|
||||||
def _register_preferred_cjk_fonts() -> str:
|
def _register_preferred_cjk_fonts() -> str:
|
||||||
"""Make the bundled/offscreen Windows runtime aware of its CJK fonts.
|
"""Prefer the shipped outline font, with native CJK faces as a fallback."""
|
||||||
|
|
||||||
Qt's offscreen platform does not always enumerate the Windows font
|
app = QApplication.instance()
|
||||||
collection. Registering the already-installed YaHei collection only
|
registered = getattr(app, "_doctor_bundled_font_family", None)
|
||||||
when it is missing prevents Chinese text from degrading to tofu boxes in
|
if registered:
|
||||||
packaged captures and headless visual checks. Other platforms continue
|
return registered
|
||||||
to use their native PingFang/Noto fallback.
|
bundled_font = resource_path("fonts", "NotoSansSC-VF.ttf")
|
||||||
"""
|
if bundled_font.is_file():
|
||||||
|
font_id = QFontDatabase.addApplicationFont(str(bundled_font))
|
||||||
|
families = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
|
||||||
|
if families:
|
||||||
|
family = families[0]
|
||||||
|
if app is not None:
|
||||||
|
app._doctor_bundled_font_family = family
|
||||||
|
return family
|
||||||
|
|
||||||
platform_families = {
|
platform_families = {
|
||||||
"win32": ("Microsoft YaHei UI", "Microsoft YaHei"),
|
"win32": ("Microsoft YaHei UI", "Microsoft YaHei"),
|
||||||
@@ -1259,12 +1461,27 @@ class _BusinessDialogStyleFilter(QObject):
|
|||||||
or dialog.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
or dialog.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: The filter is installed on the QApplication, so it is handed every event
|
||||||
|
#: in the process - roughly 5,000 per list refresh. Only two event types
|
||||||
|
#: matter, and bailing on the type before touching ``isinstance`` keeps the
|
||||||
|
#: hot path to a single comparison.
|
||||||
|
_WATCHED = frozenset({QEvent.Type.Polish, QEvent.Type.Show})
|
||||||
|
|
||||||
|
#: Reading surfaces that get eased wheel scrolling when they are polished.
|
||||||
|
#: Deliberately not every ``QAbstractScrollArea``: animating the scrollbar of
|
||||||
|
#: an item view that hosts a widget per row means repainting those widgets
|
||||||
|
#: for the length of the animation, which would trade a jerky scroll for a
|
||||||
|
#: slow one. The list pages opt their own tables in individually.
|
||||||
|
_SMOOTH_SCROLL = (QScrollArea, QTextEdit, QTextBrowser, QPlainTextEdit)
|
||||||
|
|
||||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
|
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
|
||||||
event_type = event.type()
|
event_type = event.type()
|
||||||
if isinstance(watched, QDialogButtonBox) and event_type in {
|
if event_type not in self._WATCHED:
|
||||||
QEvent.Type.Polish,
|
return False
|
||||||
QEvent.Type.Show,
|
if event_type is QEvent.Type.Polish and isinstance(watched, self._SMOOTH_SCROLL):
|
||||||
}:
|
motion.install_smooth_scroll(watched)
|
||||||
|
return False
|
||||||
|
if isinstance(watched, QDialogButtonBox):
|
||||||
_polish_dialog_buttons(watched)
|
_polish_dialog_buttons(watched)
|
||||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
|
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
|
||||||
# Window flags can only be changed before the dialog is on screen,
|
# Window flags can only be changed before the dialog is on screen,
|
||||||
@@ -1305,11 +1522,17 @@ def apply_theme(app: QApplication) -> None:
|
|||||||
|
|
||||||
app.setStyle("Fusion")
|
app.setStyle("Fusion")
|
||||||
cjk_family = _register_preferred_cjk_fonts()
|
cjk_family = _register_preferred_cjk_fonts()
|
||||||
# Pin the Windows CJK face explicitly. A comma-separated QSS fallback
|
# Resolve once for both styled widgets and custom-painted table cells.
|
||||||
# list can resolve to Qt's generic sans face in offscreen/native title-bar
|
application_font = QFont(cjk_family)
|
||||||
# captures, which changes glyph width and can even yield tofu boxes.
|
application_font.setPixelSize(int(TYPE["fs_body"].removesuffix("px")))
|
||||||
application_font = QFont(app.font())
|
application_font.setWeight(QFont.Weight.Normal)
|
||||||
application_font.setFamily(cjk_family)
|
application_font.setStyleStrategy(
|
||||||
|
QFont.StyleStrategy.PreferAntialias
|
||||||
|
| QFont.StyleStrategy.PreferOutline
|
||||||
|
)
|
||||||
|
# Keep the platform's pixel fitting and subpixel rendering available.
|
||||||
|
# Forcing grayscale plus vertical-only hinting softens small Windows text.
|
||||||
|
application_font.setHintingPreference(QFont.HintingPreference.PreferDefaultHinting)
|
||||||
app.setFont(application_font)
|
app.setFont(application_font)
|
||||||
palette = QPalette()
|
palette = QPalette()
|
||||||
active = {
|
active = {
|
||||||
@@ -1324,7 +1547,7 @@ def apply_theme(app: QApplication) -> None:
|
|||||||
QPalette.ColorRole.ButtonText: COLORS["text"],
|
QPalette.ColorRole.ButtonText: COLORS["text"],
|
||||||
QPalette.ColorRole.Base: COLORS["surface"],
|
QPalette.ColorRole.Base: COLORS["surface"],
|
||||||
QPalette.ColorRole.Window: COLORS["canvas"],
|
QPalette.ColorRole.Window: COLORS["canvas"],
|
||||||
QPalette.ColorRole.Shadow: "#B7C0D2",
|
QPalette.ColorRole.Shadow: "#C2C2C2",
|
||||||
QPalette.ColorRole.Highlight: COLORS["indigo"],
|
QPalette.ColorRole.Highlight: COLORS["indigo"],
|
||||||
QPalette.ColorRole.HighlightedText: "#FFFFFF",
|
QPalette.ColorRole.HighlightedText: "#FFFFFF",
|
||||||
QPalette.ColorRole.Link: COLORS["info"],
|
QPalette.ColorRole.Link: COLORS["info"],
|
||||||
@@ -1347,7 +1570,7 @@ def apply_theme(app: QApplication) -> None:
|
|||||||
QPalette.ColorRole.ButtonText: COLORS["disabled_text"],
|
QPalette.ColorRole.ButtonText: COLORS["disabled_text"],
|
||||||
QPalette.ColorRole.Base: COLORS["disabled_surface"],
|
QPalette.ColorRole.Base: COLORS["disabled_surface"],
|
||||||
QPalette.ColorRole.Window: COLORS["canvas"],
|
QPalette.ColorRole.Window: COLORS["canvas"],
|
||||||
QPalette.ColorRole.Shadow: "#C8CFDC",
|
QPalette.ColorRole.Shadow: "#D2D2D2",
|
||||||
QPalette.ColorRole.Highlight: COLORS["line"],
|
QPalette.ColorRole.Highlight: COLORS["line"],
|
||||||
QPalette.ColorRole.HighlightedText: COLORS["disabled_text"],
|
QPalette.ColorRole.HighlightedText: COLORS["disabled_text"],
|
||||||
QPalette.ColorRole.Link: COLORS["disabled_text"],
|
QPalette.ColorRole.Link: COLORS["disabled_text"],
|
||||||
@@ -1362,7 +1585,7 @@ def apply_theme(app: QApplication) -> None:
|
|||||||
_apply_group(palette, QPalette.ColorGroup.Inactive, active)
|
_apply_group(palette, QPalette.ColorGroup.Inactive, active)
|
||||||
_apply_group(palette, QPalette.ColorGroup.Disabled, disabled)
|
_apply_group(palette, QPalette.ColorGroup.Disabled, disabled)
|
||||||
app.setPalette(palette)
|
app.setPalette(palette)
|
||||||
app.setStyleSheet(GLOBAL_QSS)
|
app.setStyleSheet(GLOBAL_QSS + _indicator_qss())
|
||||||
_install_business_dialog_styling(app)
|
_install_business_dialog_styling(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -1373,6 +1596,7 @@ __all__ = [
|
|||||||
"GLOBAL_QSS",
|
"GLOBAL_QSS",
|
||||||
"METRICS",
|
"METRICS",
|
||||||
"TYPE",
|
"TYPE",
|
||||||
|
"WEIGHTS",
|
||||||
"apply_theme",
|
"apply_theme",
|
||||||
"crisp_pixmap",
|
"crisp_pixmap",
|
||||||
"mark_business_dialog",
|
"mark_business_dialog",
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ from dataclasses import dataclass
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
|
from PySide6.QtCore import (
|
||||||
|
QObject,
|
||||||
|
QRunnable,
|
||||||
|
Qt,
|
||||||
|
QThreadPool,
|
||||||
|
QTimer,
|
||||||
|
Signal,
|
||||||
|
Slot,
|
||||||
|
)
|
||||||
from PySide6.QtGui import QColor, QPainter, QPaintEvent, QResizeEvent
|
from PySide6.QtGui import QColor, QPainter, QPaintEvent, QResizeEvent
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
@@ -35,6 +43,8 @@ from doctor_workstation.core.errors import (
|
|||||||
AuthenticationExpiredError,
|
AuthenticationExpiredError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from . import icons, motion
|
||||||
|
|
||||||
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
|
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
|
||||||
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
|
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
|
||||||
|
|
||||||
@@ -470,6 +480,8 @@ class PageHeader(QWidget):
|
|||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("PageHeader")
|
self.setObjectName("PageHeader")
|
||||||
|
self._compact = False
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(8)
|
layout.setSpacing(8)
|
||||||
@@ -514,7 +526,31 @@ class PageHeader(QWidget):
|
|||||||
|
|
||||||
def set_subtitle(self, text: str) -> None:
|
def set_subtitle(self, text: str) -> None:
|
||||||
self.subtitle_label.setText(text)
|
self.subtitle_label.setText(text)
|
||||||
self.subtitle_label.setVisible(bool(text))
|
self.subtitle_label.setVisible(bool(text) and not self._compact)
|
||||||
|
self.title_label.setToolTip(text if self._compact else "")
|
||||||
|
|
||||||
|
def set_compact(self, compact: bool = True) -> None:
|
||||||
|
"""Use a single title/action row on list pages with foldable search."""
|
||||||
|
self._compact = compact
|
||||||
|
layout = self.layout()
|
||||||
|
breadcrumb = layout.itemAt(0).layout()
|
||||||
|
for index in range(breadcrumb.count()):
|
||||||
|
widget = breadcrumb.itemAt(index).widget()
|
||||||
|
if widget is not None:
|
||||||
|
widget.setVisible(not compact)
|
||||||
|
self.subtitle_label.setVisible(bool(self.subtitle_label.text()) and not compact)
|
||||||
|
self.title_label.setToolTip(self.subtitle_label.text() if compact else "")
|
||||||
|
layout.setSpacing(0 if compact else 8)
|
||||||
|
layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
heading = layout.itemAt(1).layout()
|
||||||
|
heading.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
heading.itemAt(0).layout().setSpacing(0 if compact else 3)
|
||||||
|
self.actions.setSpacing(8)
|
||||||
|
if compact:
|
||||||
|
self.setFixedHeight(44)
|
||||||
|
else:
|
||||||
|
self.setMinimumHeight(0)
|
||||||
|
self.setMaximumHeight(16777215)
|
||||||
|
|
||||||
|
|
||||||
class MetricCard(QFrame):
|
class MetricCard(QFrame):
|
||||||
@@ -636,7 +672,9 @@ class MessageBanner(QFrame):
|
|||||||
layout = QHBoxLayout(self)
|
layout = QHBoxLayout(self)
|
||||||
layout.setContentsMargins(12, 9, 12, 9)
|
layout.setContentsMargins(12, 9, 12, 9)
|
||||||
layout.setSpacing(9)
|
layout.setSpacing(9)
|
||||||
self.icon = QLabel("i", self)
|
# The banner used to letter its own icons - a lowercase "i", a "!", and
|
||||||
|
# a U+2713 whose shape depended on whichever font happened to cover it.
|
||||||
|
self.icon = QLabel(self)
|
||||||
self.icon.setObjectName("MessageBannerIcon")
|
self.icon.setObjectName("MessageBannerIcon")
|
||||||
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.icon.setFixedSize(20, 20)
|
self.icon.setFixedSize(20, 20)
|
||||||
@@ -647,18 +685,33 @@ class MessageBanner(QFrame):
|
|||||||
layout.addWidget(self.label, 1)
|
layout.addWidget(self.label, 1)
|
||||||
self.setVisible(bool(text))
|
self.setVisible(bool(text))
|
||||||
|
|
||||||
|
#: Banner kind -> shared glyph and colour role.
|
||||||
|
_ICONS = {
|
||||||
|
"info": ("info", "info"),
|
||||||
|
"success": ("check_circle", "success"),
|
||||||
|
"warning": ("alert", "warning"),
|
||||||
|
"danger": ("alert", "danger"),
|
||||||
|
}
|
||||||
|
|
||||||
def show_message(self, text: str, kind: str = "info") -> None:
|
def show_message(self, text: str, kind: str = "info") -> None:
|
||||||
glyphs = {"info": "i", "success": "✓", "warning": "!", "danger": "!"}
|
glyph, role = self._ICONS.get(kind, self._ICONS["info"])
|
||||||
self.label.setText(text)
|
self.label.setText(text)
|
||||||
self.icon.setText(glyphs.get(kind, "i"))
|
self.icon.setPixmap(icons.pixmap(glyph, role, 16))
|
||||||
self.setProperty("kind", kind)
|
self.setProperty("kind", kind)
|
||||||
self.style().unpolish(self)
|
self.style().unpolish(self)
|
||||||
self.style().polish(self)
|
self.style().polish(self)
|
||||||
self.setVisible(bool(text))
|
if not text:
|
||||||
|
self.setVisible(False)
|
||||||
|
return
|
||||||
|
if self.isVisible():
|
||||||
|
return
|
||||||
|
motion.fade_in(self, duration=motion.FAST)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
self.setVisible(False)
|
if self.isVisible():
|
||||||
self.label.clear()
|
motion.fade_out(self, duration=motion.FAST, on_finished=self.label.clear)
|
||||||
|
else:
|
||||||
|
self.label.clear()
|
||||||
|
|
||||||
|
|
||||||
class Toast(QLabel):
|
class Toast(QLabel):
|
||||||
@@ -670,7 +723,10 @@ class Toast(QLabel):
|
|||||||
self.hide()
|
self.hide()
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.setSingleShot(True)
|
self._timer.setSingleShot(True)
|
||||||
self._timer.timeout.connect(self.hide)
|
self._timer.timeout.connect(self._dismiss)
|
||||||
|
|
||||||
|
def _dismiss(self) -> None:
|
||||||
|
motion.fade_out(self, duration=motion.BASE)
|
||||||
|
|
||||||
def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
|
def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
|
||||||
self.setText(text)
|
self.setText(text)
|
||||||
@@ -682,7 +738,7 @@ class Toast(QLabel):
|
|||||||
if parent is not None:
|
if parent is not None:
|
||||||
self.move(max(16, parent.width() - self.width() - 24), 20)
|
self.move(max(16, parent.width() - self.width() - 24), 20)
|
||||||
self.raise_()
|
self.raise_()
|
||||||
self.show()
|
motion.fade_in(self, duration=motion.FAST)
|
||||||
self._timer.start(duration)
|
self._timer.start(duration)
|
||||||
|
|
||||||
|
|
||||||
@@ -730,7 +786,7 @@ class _BusyTrack(QWidget):
|
|||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
rect = self.rect()
|
rect = self.rect()
|
||||||
painter.setBrush(QColor("#D8DEEA"))
|
painter.setBrush(QColor("#E4E4E5"))
|
||||||
painter.drawRoundedRect(rect, 3, 3)
|
painter.drawRoundedRect(rect, 3, 3)
|
||||||
chunk_width = max(36, int(rect.width() * 0.32))
|
chunk_width = max(36, int(rect.width() * 0.32))
|
||||||
span = rect.width() + chunk_width
|
span = rect.width() + chunk_width
|
||||||
@@ -766,6 +822,16 @@ class BusyOverlay(QFrame):
|
|||||||
self.raise_()
|
self.raise_()
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
|
|
||||||
|
def reveal(self) -> None:
|
||||||
|
"""Fade the guard in, so a fast response never flashes a grey slab."""
|
||||||
|
|
||||||
|
if not self.isVisible():
|
||||||
|
motion.fade_in(self, duration=motion.FAST)
|
||||||
|
|
||||||
|
def dismiss(self) -> None:
|
||||||
|
if self.isVisible():
|
||||||
|
motion.fade_out(self, duration=motion.FAST)
|
||||||
|
|
||||||
|
|
||||||
class OverlayHost(QWidget):
|
class OverlayHost(QWidget):
|
||||||
"""Widget base that automatically sizes a BusyOverlay child."""
|
"""Widget base that automatically sizes a BusyOverlay child."""
|
||||||
@@ -795,11 +861,19 @@ class SortableTable(QTableWidget):
|
|||||||
self.setColumnCount(len(self.columns))
|
self.setColumnCount(len(self.columns))
|
||||||
self.setHorizontalHeaderLabels([column.title for column in self.columns])
|
self.setHorizontalHeaderLabels([column.title for column in self.columns])
|
||||||
self.setAlternatingRowColors(True)
|
self.setAlternatingRowColors(True)
|
||||||
|
# Row separation already comes from the ``::item`` bottom border, so the
|
||||||
|
# grid only added a column rule that cut across every row - and it kept
|
||||||
|
# drawing past the last populated column, leaving a stray vertical line
|
||||||
|
# hanging in the empty part of the table.
|
||||||
|
self.setShowGrid(False)
|
||||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||||
|
# Per-pixel mode only smooths dragging; the wheel still jumped three rows
|
||||||
|
# at a time, which is how most of a list actually gets read.
|
||||||
|
motion.install_smooth_scroll(self)
|
||||||
self.setMinimumHeight(0)
|
self.setMinimumHeight(0)
|
||||||
self.setSortingEnabled(True)
|
self.setSortingEnabled(True)
|
||||||
self.verticalHeader().setVisible(False)
|
self.verticalHeader().setVisible(False)
|
||||||
|
|||||||
@@ -1634,7 +1634,11 @@ def test_report_bubbles_report_the_height_they_actually_paint(
|
|||||||
host, bubble = _fitted_bubble(reply)
|
host, bubble = _fitted_bubble(reply)
|
||||||
|
|
||||||
assert bubble.height() > 0
|
assert bubble.height() > 0
|
||||||
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2
|
# Wrapping labels can legitimately have a different preferred height at
|
||||||
|
# their preferred width. Compare against the actual reading-column width.
|
||||||
|
fitted_height = bubble.heightForWidth(bubble.width())
|
||||||
|
expected_height = fitted_height if fitted_height >= 0 else bubble.sizeHint().height()
|
||||||
|
assert abs(expected_height - bubble.height()) <= 2
|
||||||
host.close()
|
host.close()
|
||||||
host.deleteLater()
|
host.deleteLater()
|
||||||
|
|
||||||
@@ -1646,14 +1650,15 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
|
|||||||
assert "#FEF3F2" in risk_card
|
assert "#FEF3F2" in risk_card
|
||||||
assert "#F1B35C" not in risk_card # 旧的橙色描边
|
assert "#F1B35C" not in risk_card # 旧的橙色描边
|
||||||
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
|
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
|
||||||
assert "#C0392B" in marker
|
assert "#BE4B58" in marker
|
||||||
|
|
||||||
|
|
||||||
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
|
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
|
||||||
qss = ai_consult_module.AI_CONSULT_QSS
|
qss = ai_consult_module.AI_CONSULT_QSS
|
||||||
|
|
||||||
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1)
|
body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
|
||||||
assert len(body) == 2 or "font-size: 13px" in qss
|
assert "color: #1a1c1f" in body.lower()
|
||||||
|
assert "font-size: 14px" in body
|
||||||
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
|
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
|
||||||
assert "font-size: 13px" in block
|
assert "font-size: 14px" in block
|
||||||
assert "font-size: 11px" not in block
|
assert "font-size: 11px" not in block
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ def test_keyboard_focus_has_a_visible_state(
|
|||||||
assert focus_target.hasFocus()
|
assert focus_target.hasFocus()
|
||||||
assert application.focusWidget() is focus_target
|
assert application.focusWidget() is focus_target
|
||||||
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
|
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
|
||||||
assert "border-color: #8D9BFF;" in APPOINTMENT_DRAWER_QSS
|
assert "border-color: #8B9AD9;" in APPOINTMENT_DRAWER_QSS
|
||||||
|
|
||||||
drawer.close()
|
drawer.close()
|
||||||
host.close()
|
host.close()
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Scrolling, query isolation and refresh contracts for the two clinic queues."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QComboBox, QSpinBox, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||||
|
from doctor_workstation.ui.pages import appointments, consultations
|
||||||
|
|
||||||
|
|
||||||
|
class _DiagnosisDialog(QWidget):
|
||||||
|
saved = Signal()
|
||||||
|
|
||||||
|
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
self.fail_page: int | None = None
|
||||||
|
self.revision = 0
|
||||||
|
|
||||||
|
def _list(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(dict(query))
|
||||||
|
page = query["page_no"]
|
||||||
|
if page == self.fail_page:
|
||||||
|
raise RuntimeError("暂时无法加载")
|
||||||
|
second = bool(query.get("keyword") or query.get("patient_name"))
|
||||||
|
offset = 1000 if second else 0
|
||||||
|
total = 4 if second else 34
|
||||||
|
start = (page - 1) * query["page_size"]
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": offset + index,
|
||||||
|
"diagnosis_id": offset + index,
|
||||||
|
"source_patient_id": index + 2000,
|
||||||
|
"patient_id": index + 2000,
|
||||||
|
"patient_name": f"患者{offset + index} · {self.revision}",
|
||||||
|
"status": 1,
|
||||||
|
"status_desc": "待接诊",
|
||||||
|
"appointment_id": offset + index,
|
||||||
|
"appointment_status": 1,
|
||||||
|
"appointment_date": date.today().isoformat(),
|
||||||
|
"appointment_time": "09:00-09:30",
|
||||||
|
"patient_phone": "13800001234",
|
||||||
|
"doctor_name": "测试医生",
|
||||||
|
"doctor_id": 30,
|
||||||
|
"diagnosis_confirmed": True,
|
||||||
|
"appointments": [],
|
||||||
|
}
|
||||||
|
for index in range(start + 1, min(total, start + query["page_size"]) + 1)
|
||||||
|
]
|
||||||
|
result = {"lists": rows, "count": total}
|
||||||
|
if page == 1:
|
||||||
|
result["extend"] = {
|
||||||
|
"status_count": {"1": total, "3": 7},
|
||||||
|
"date_counts": {"today": total, "tomorrow": 9},
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
list_appointments = _list
|
||||||
|
list_consultations = _list
|
||||||
|
|
||||||
|
|
||||||
|
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
application.processEvents()
|
||||||
|
QTest.qWait(35)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=["appointments", "consultations"])
|
||||||
|
def queue_page(request: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
module = appointments if request.param == "appointments" else consultations
|
||||||
|
monkeypatch.setattr(module, "run_async", _inline)
|
||||||
|
monkeypatch.setattr(consultations, "DiagnosisDialog", _DiagnosisDialog)
|
||||||
|
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||||
|
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||||
|
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||||
|
repository = _Repository()
|
||||||
|
page_class = appointments.AppointmentsPage if module is appointments else consultations.ConsultationsPage
|
||||||
|
page = page_class(repository, permissions={"*"}, current_user={"role_id": 1})
|
||||||
|
page.resize(1280, 800)
|
||||||
|
page.show()
|
||||||
|
page.poll_timer.stop()
|
||||||
|
_settle(application)
|
||||||
|
yield page, repository, module
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def _scroll_bottom(page: Any, application: QApplication) -> None:
|
||||||
|
scrollbar = page.table.verticalScrollBar()
|
||||||
|
assert scrollbar.maximum() > 0
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_appends_and_preserves_selection(queue_page: Any, application: QApplication) -> None:
|
||||||
|
page, repository, _module = queue_page
|
||||||
|
assert page.table.rowCount() == 15
|
||||||
|
page.table.selectRow(6)
|
||||||
|
if hasattr(page, "table_host"):
|
||||||
|
model = page.table_host.model
|
||||||
|
model.setData(model.index(6, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
|
||||||
|
assert page.table.rowCount() == 30
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert page.table.current_data()["id"] == 7
|
||||||
|
assert page.table.verticalScrollBar().value() > 0
|
||||||
|
if hasattr(page, "table_host"):
|
||||||
|
assert [row["id"] for row in page.table_host.selected_records()] == [7]
|
||||||
|
else:
|
||||||
|
assert page._status_counts[3] == 7
|
||||||
|
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||||
|
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
assert page.table.rowCount() == 34
|
||||||
|
assert len({row["id"] for row in page.pager.rows}) == 34
|
||||||
|
assert not page.pager.has_more
|
||||||
|
assert "已全部加载" in page.pager.summary_label.text()
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_refresh_keeps_the_loaded_prefix(queue_page: Any, application: QApplication) -> None:
|
||||||
|
page, repository, _module = queue_page
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
page.table.selectRow(19)
|
||||||
|
old_scroll = page.table.verticalScrollBar().value()
|
||||||
|
repository.calls.clear()
|
||||||
|
repository.revision = 2
|
||||||
|
page.refresh(silent=True)
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert page.table.rowCount() == 30
|
||||||
|
assert page.table.current_data()["id"] == 20
|
||||||
|
assert page.table.current_data()["patient_name"].endswith(" · 2")
|
||||||
|
assert page.table.verticalScrollBar().value() == old_scroll
|
||||||
|
if isinstance(page, appointments.AppointmentsPage):
|
||||||
|
assert page._status_counts[3] == 7
|
||||||
|
assert page.date_buttons["tomorrow"].text().endswith(" 9")
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_change_supersedes_pending_append(
|
||||||
|
queue_page: Any, application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
page, _repository, module = queue_page
|
||||||
|
jobs: list[tuple[Any, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def deferred(function: Any, **callbacks: Any) -> None:
|
||||||
|
jobs.append((function, callbacks))
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "run_async", deferred)
|
||||||
|
# Reconfigure the shared controller to use the deferred runner, then finish
|
||||||
|
# that refresh before simulating a slow next page.
|
||||||
|
page.refresh(silent=True)
|
||||||
|
function, callbacks = jobs.pop()
|
||||||
|
callbacks["on_success"](function())
|
||||||
|
page.pager.load_more()
|
||||||
|
append_function, append_callbacks = jobs.pop()
|
||||||
|
stale_result = deepcopy(append_function())
|
||||||
|
|
||||||
|
search = page.patient_input if module is appointments else page.keyword_edit
|
||||||
|
search.setText("第二组")
|
||||||
|
page.refresh(silent=True)
|
||||||
|
assert len(jobs) == 1
|
||||||
|
function, callbacks = jobs.pop()
|
||||||
|
assert function()["lists"][0]["id"] == 1001
|
||||||
|
callbacks["on_success"](function())
|
||||||
|
append_callbacks["on_success"](stale_result)
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
assert page.table.rowCount() == 4
|
||||||
|
assert [row["id"] for row in page.pager.rows] == [1001, 1002, 1003, 1004]
|
||||||
|
assert page.pager.page == 1
|
||||||
|
assert not page.pager.loading
|
||||||
|
assert page.table.verticalScrollBar().value() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_append_retries_without_losing_rows(queue_page: Any, application: QApplication) -> None:
|
||||||
|
page, repository, _module = queue_page
|
||||||
|
repository.fail_page = 2
|
||||||
|
_scroll_bottom(page, application)
|
||||||
|
assert page.table.rowCount() == 15
|
||||||
|
assert page.pager.page == 1
|
||||||
|
assert page.pager.retry_button.isVisible()
|
||||||
|
calls = len(repository.calls)
|
||||||
|
_settle(application)
|
||||||
|
assert len(repository.calls) == calls
|
||||||
|
|
||||||
|
repository.fail_page = None
|
||||||
|
page.pager.retry_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.rowCount() == 30
|
||||||
|
assert page.pager.page == 2
|
||||||
|
assert page.pager.retry_button.isHidden()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_footer_is_compact_without_page_controls(queue_page: Any, application: QApplication) -> None:
|
||||||
|
page, _repository, _module = queue_page
|
||||||
|
for height in (768, 960):
|
||||||
|
page.resize(1280, height)
|
||||||
|
_settle(application)
|
||||||
|
assert isinstance(page.pager, InfiniteList)
|
||||||
|
assert page.pager.height() == 24
|
||||||
|
assert not page.pager.findChildren(QComboBox)
|
||||||
|
assert not page.pager.findChildren(QSpinBox)
|
||||||
|
content = page.page_scroll.widget() if hasattr(page, "page_scroll") else page
|
||||||
|
assert content.layout().contentsMargins().bottom() == 8
|
||||||
@@ -10,6 +10,8 @@ from typing import Any
|
|||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from PySide6.QtCore import QElapsedTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -398,12 +400,19 @@ def test_appointments_page_default_query_is_today_pending(
|
|||||||
page.refresh()
|
page.refresh()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
completed = QElapsedTimer()
|
||||||
|
completed.start()
|
||||||
|
while page.table.rowCount() == 0 and completed.elapsed() < 2_000:
|
||||||
|
QTest.qWait(10)
|
||||||
|
|
||||||
filters = page._query_filters()
|
filters = page._query_filters()
|
||||||
assert filters["status"] == 1
|
assert filters["status"] == 1
|
||||||
assert filters["include_status_counts"] == 1
|
assert filters["include_status_counts"] == 1
|
||||||
assert filters["start_date"] == filters["end_date"]
|
assert filters["start_date"] == filters["end_date"]
|
||||||
assert "diag_scope_relax" not in filters
|
assert "diag_scope_relax" not in filters
|
||||||
assert page.table.rowCount() >= 1
|
assert page.table.rowCount() >= 1
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_demo_appointment_status_counts_respect_date_scope() -> None:
|
def test_demo_appointment_status_counts_respect_date_scope() -> None:
|
||||||
@@ -464,8 +473,7 @@ def test_appointment_multiline_cells_receive_enough_row_height(
|
|||||||
assert appointment_text.count("\n") == 2
|
assert appointment_text.count("\n") == 2
|
||||||
assert "2026-08-11 14:30" in appointment_text
|
assert "2026-08-11 14:30" in appointment_text
|
||||||
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
|
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
|
||||||
assert 60 <= page.table.rowHeight(0) <= 66
|
assert page.table.rowHeight(0) >= required
|
||||||
assert page.table.rowHeight(0) >= min(required, 66)
|
|
||||||
assert page.table.item(0, 4).toolTip() == appointment_text
|
assert page.table.item(0, 4).toolTip() == appointment_text
|
||||||
page.close()
|
page.close()
|
||||||
|
|
||||||
@@ -854,9 +862,8 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
|||||||
permissions=PermissionSet(["*"]),
|
permissions=PermissionSet(["*"]),
|
||||||
current_user={"id": 1001, "role_id": 1},
|
current_user={"id": 1001, "role_id": 1},
|
||||||
)
|
)
|
||||||
# 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter,
|
# Approved shared chrome: 208 px rail, 76 px topbar, no outer gutter.
|
||||||
# and 62 px top bar leaves a 1161x680 page viewport.
|
page.resize(1158, 692)
|
||||||
page.resize(1161, 680)
|
|
||||||
page.show()
|
page.show()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
rows = [
|
rows = [
|
||||||
@@ -886,10 +893,12 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
|
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
|
||||||
# 与其余列表页一致的“面包屑 + 标题 + 副标题”页头。
|
# Compact title and folded filters leave more room for the patient queue.
|
||||||
assert page.header.height() == 62
|
assert page.header.height() >= page.header.minimumSizeHint().height()
|
||||||
assert page.filter_panel.height() <= 84
|
assert page.header.height() <= 44
|
||||||
assert all(60 <= height <= 66 for height in heights)
|
assert page.filter_panel.isHidden()
|
||||||
|
assert all(60 <= height <= 84 for height in heights)
|
||||||
|
assert all(page.table.cellWidget(row, 4).height() >= page.table.cellWidget(row, 4).minimumSizeHint().height() for row in range(page.table.rowCount()))
|
||||||
assert page.table.viewport().height() // max(heights) >= 4
|
assert page.table.viewport().height() // max(heights) >= 4
|
||||||
assert page.pager.isVisibleTo(page)
|
assert page.pager.isVisibleTo(page)
|
||||||
assert page.content_layout.count() == 1
|
assert page.content_layout.count() == 1
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Native Qt interaction and layout checks for the approved appointment surface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import date
|
||||||
|
from itertools import combinations
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, QRect, QSize, Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QTabBar,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from doctor_workstation.ui import shell as shell_module
|
||||||
|
from doctor_workstation.ui.pages import appointments as appointments_module
|
||||||
|
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||||||
|
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.rows = [
|
||||||
|
{
|
||||||
|
"id": identifier,
|
||||||
|
"patient_name": f"测试患者{identifier}",
|
||||||
|
"patient_phone": "13800001234",
|
||||||
|
"gender": 1,
|
||||||
|
"age": 36,
|
||||||
|
"status": 1,
|
||||||
|
"status_desc": "待接诊",
|
||||||
|
"appointment_date": date.today().isoformat(),
|
||||||
|
"appointment_time": "09:00-09:30",
|
||||||
|
"doctor_name": "测试医生",
|
||||||
|
"doctor_id": 21,
|
||||||
|
"diagnosis_id": identifier + 100,
|
||||||
|
"source_patient_id": identifier + 200,
|
||||||
|
"diagnosis_confirmed": True,
|
||||||
|
"channel_name": "测试渠道",
|
||||||
|
}
|
||||||
|
for identifier in (401, 402, 403)
|
||||||
|
]
|
||||||
|
self.queries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
self.queries.append(kwargs)
|
||||||
|
return {
|
||||||
|
"lists": deepcopy(self.rows),
|
||||||
|
"count": len(self.rows),
|
||||||
|
"extend": {"status_count": {"1": len(self.rows)}},
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_departments(self) -> list[dict[str, Any]]:
|
||||||
|
return [{"id": 10, "name": "测试部门", "children": []}]
|
||||||
|
|
||||||
|
|
||||||
|
class _QuietPage(QWidget):
|
||||||
|
def __init__(self, _repository: Any, *, parent: QWidget, **_kwargs: Any) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
application.processEvents()
|
||||||
|
QTest.qWait(5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def window_factory(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
def run_inline(function: Any, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args: Any, **_kwargs: Any):
|
||||||
|
pytest.fail("The appointment visual tests must stay offline")
|
||||||
|
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
monkeypatch.setattr(appointments_module, "run_async", run_inline)
|
||||||
|
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||||
|
navigation = [
|
||||||
|
NavigationItem("appointments", "挂号列表", "号", AppointmentsPage, ("doctor.appointment/lists",)),
|
||||||
|
NavigationItem("reception", "接诊台", "◎", _QuietPage, ("doctor.appointment/lists",)),
|
||||||
|
NavigationItem("patients", "我的患者", "患", _QuietPage, ("firstvisit.myPatient/lists",)),
|
||||||
|
NavigationItem("prescriptions", "已开处方", "笺", _QuietPage, ("tcm.prescription/lists",)),
|
||||||
|
NavigationItem("legacy_reference", "参考页", "参", _QuietPage, ()),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
shell_module, "_resolve_navigation",
|
||||||
|
lambda *_args, **_kwargs: [(item, item.title) for item in navigation],
|
||||||
|
)
|
||||||
|
windows = []
|
||||||
|
|
||||||
|
def create(*, admin: bool = False, width: int = 1536, height: int = 960):
|
||||||
|
repository = _Repository()
|
||||||
|
shell = ShellWindow(
|
||||||
|
repository,
|
||||||
|
{"user": {"name": "测试医生", "role_id": 3 if admin else 1}, "demo_mode": True},
|
||||||
|
permissions={"*"},
|
||||||
|
)
|
||||||
|
windows.append(shell)
|
||||||
|
shell.resize(width, height)
|
||||||
|
shell.show()
|
||||||
|
_settle(application)
|
||||||
|
page = shell.pages["appointments"]
|
||||||
|
page.poll_timer.stop()
|
||||||
|
return shell, page, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for shell in windows:
|
||||||
|
shell.close()
|
||||||
|
shell.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def _selector(page: AppointmentsPage, row: int) -> QCheckBox:
|
||||||
|
host = page.table.cellWidget(row, 0)
|
||||||
|
assert host is not None
|
||||||
|
selector = host.findChild(QCheckBox)
|
||||||
|
assert selector is not None
|
||||||
|
return selector
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_selection_matches(page: AppointmentsPage) -> None:
|
||||||
|
table = page.table
|
||||||
|
selected = {index.row() for index in table.selectionModel().selectedRows()}
|
||||||
|
assert selected == {table.currentRow()}
|
||||||
|
assert sum(_selector(page, row).isChecked() for row in range(table.rowCount())) == 1
|
||||||
|
for row in range(table.rowCount()):
|
||||||
|
assert _selector(page, row).isChecked() == (row in selected)
|
||||||
|
assert table.cellWidget(row, 0).property("selected") == (row in selected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_selector_tracks_initial_row_click_repeat_click_and_refresh(
|
||||||
|
application: QApplication, window_factory
|
||||||
|
) -> None:
|
||||||
|
shell, page, repository = window_factory()
|
||||||
|
assert page.table.rowCount() == 3
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
checkbox = _selector(page, 1)
|
||||||
|
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.currentRow() == 1
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
|
||||||
|
# Clicking the selected checkbox cannot leave the selected patient unchecked.
|
||||||
|
QTest.mouseClick(checkbox, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
assert page.table.currentRow() == 1
|
||||||
|
|
||||||
|
target = page.table.item(2, 2)
|
||||||
|
QTest.mouseClick(
|
||||||
|
page.table.viewport(), Qt.MouseButton.LeftButton,
|
||||||
|
pos=page.table.visualItemRect(target).center(),
|
||||||
|
)
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.currentRow() == 2
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
selected_id = page.table.current_data()["id"]
|
||||||
|
|
||||||
|
shell.refresh_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.current_data()["id"] == selected_id
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
|
||||||
|
# Changed server data rebuilds the cells, unlike unchanged polling.
|
||||||
|
repository.rows[0]["channel_name"] = "更新后的测试渠道"
|
||||||
|
shell.refresh_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.current_data()["id"] == selected_id
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
|
||||||
|
|
||||||
|
def test_selector_selects_its_visible_patient_after_sorting(
|
||||||
|
application: QApplication, window_factory
|
||||||
|
) -> None:
|
||||||
|
_shell, page, _repository = window_factory()
|
||||||
|
# A user can change sorting after cell widgets have already been installed.
|
||||||
|
first_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||||
|
header = page.table.horizontalHeader()
|
||||||
|
header_position = QPoint(
|
||||||
|
header.sectionViewportPosition(1) + header.sectionSize(1) // 2,
|
||||||
|
header.height() // 2,
|
||||||
|
)
|
||||||
|
for _ in range(2):
|
||||||
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=header_position)
|
||||||
|
_settle(application)
|
||||||
|
if page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"] != first_id:
|
||||||
|
break
|
||||||
|
expected_id = page.table.item(0, 0).data(Qt.ItemDataRole.UserRole)["id"]
|
||||||
|
assert expected_id != first_id
|
||||||
|
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.current_data()["id"] == expected_id
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
QTest.mouseClick(_selector(page, 0), Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.table.current_data()["id"] == expected_id
|
||||||
|
_assert_selection_matches(page)
|
||||||
|
|
||||||
|
|
||||||
|
def _rect(widget: QWidget, ancestor: QWidget) -> QRect:
|
||||||
|
return QRect(widget.mapTo(ancestor, QPoint()), widget.size())
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_filter_layout(page: AppointmentsPage) -> None:
|
||||||
|
types = (QPushButton, QComboBox, QLineEdit, QTabBar, QLabel)
|
||||||
|
controls = [widget for widget in page.filter_panel.findChildren(QWidget)
|
||||||
|
if isinstance(widget, types) and widget.isVisible()]
|
||||||
|
panel = page.filter_panel.rect()
|
||||||
|
for widget in controls:
|
||||||
|
assert panel.contains(_rect(widget, page.filter_panel)), widget.objectName()
|
||||||
|
for first, second in combinations(controls, 2):
|
||||||
|
if first.isAncestorOf(second) or second.isAncestorOf(first):
|
||||||
|
continue
|
||||||
|
overlap = _rect(first, page).intersected(_rect(second, page))
|
||||||
|
assert overlap.isEmpty(), (first.objectName(), second.objectName(), overlap)
|
||||||
|
assert not _rect(page.filter_panel, page).intersects(_rect(page.table_card, page))
|
||||||
|
assert page.table.viewport().height() > 80
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("admin", [False, True], ids=["doctor", "admin"])
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1536, 960), (1366, 768), (1024, 640)])
|
||||||
|
def test_more_filters_fit_exact_window_and_preserve_all_columns(
|
||||||
|
application: QApplication, window_factory, admin: bool, width: int, height: int
|
||||||
|
) -> None:
|
||||||
|
shell, page, _repository = window_factory(admin=admin, width=width, height=height)
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
_settle(application)
|
||||||
|
for expanded in (False, True, False):
|
||||||
|
if page.more_filters_button.isChecked() != expanded:
|
||||||
|
QTest.mouseClick(page.more_filters_button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert shell.size() == QSize(width, height)
|
||||||
|
assert page.advanced_filters.isVisible() == expanded
|
||||||
|
assert page.dept_filter.isVisible() == expanded
|
||||||
|
assert page.doctor_input.isVisible() == (expanded and admin)
|
||||||
|
assert page.custom_date_button.isVisible() == expanded
|
||||||
|
assert page.reset_filter_button.isVisible() == expanded
|
||||||
|
_assert_filter_layout(page)
|
||||||
|
assert page.table.columnCount() == 11
|
||||||
|
assert all(not page.table.isColumnHidden(column) for column in range(11))
|
||||||
|
assert [page.table.horizontalHeaderItem(column).text() for column in range(11)] == [
|
||||||
|
"", "ID", "患者", "性别 / 年龄", "挂号信息", "确认", "复诊", "助理", "开方", "未服务天数", "IM 问诊",
|
||||||
|
]
|
||||||
|
if width == 1024:
|
||||||
|
assert page.table.horizontalScrollBar().maximum() > 0
|
||||||
|
page.table.horizontalScrollBar().setValue(page.table.horizontalScrollBar().maximum())
|
||||||
|
_settle(application)
|
||||||
|
right = page.table.columnViewportPosition(10) + page.table.columnWidth(10)
|
||||||
|
assert right <= page.table.viewport().width()
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_typography_remains_compact_and_chrome_restores(
|
||||||
|
application: QApplication, window_factory
|
||||||
|
) -> None:
|
||||||
|
shell, page, _repository = window_factory()
|
||||||
|
original_qss = application.styleSheet()
|
||||||
|
assert page.header.title_label.font().pixelSize() == 20
|
||||||
|
assert page.patient_input.font().pixelSize() == 14
|
||||||
|
assert page.table.font().pixelSize() == 14
|
||||||
|
assert page.table.item(0, 2).font().pixelSize() == 14
|
||||||
|
assert page.header.subtitle_label.font().pixelSize() == 13
|
||||||
|
assert page.more_filters_button.font().pixelSize() == 13
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert shell.navigate("reception")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert shell.navigate("patients")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert shell.navigate("prescriptions")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert shell.navigate("legacy_reference")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 190
|
||||||
|
assert shell.topbar.height() == 62
|
||||||
|
assert shell.workspace.pos() == QPoint(203, 13)
|
||||||
|
assert shell.fold_button.isVisible()
|
||||||
|
assert not shell.menu_sidebar_action.isVisible()
|
||||||
|
for widget, stylesheet in shell._legacy_chrome_styles.items():
|
||||||
|
assert widget.styleSheet() == stylesheet
|
||||||
|
assert shell.navigate("appointments")
|
||||||
|
_settle(application)
|
||||||
|
assert shell.sidebar.width() == 208
|
||||||
|
assert shell.topbar.height() == 76
|
||||||
|
assert page.header.title_label.font().pixelSize() == 20
|
||||||
|
assert application.styleSheet() == original_qss
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Native Qt coverage for presentation-only filter folding on both queues."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, QRect, Qt, Signal
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QPushButton, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui import shell as shell_module
|
||||||
|
from doctor_workstation.ui.pages import appointments, consultations
|
||||||
|
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class _Dialog(QWidget):
|
||||||
|
saved = Signal()
|
||||||
|
|
||||||
|
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
||||||
|
class _Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _list(self, **query: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(query)
|
||||||
|
rows = [{
|
||||||
|
"id": index, "diagnosis_id": index, "patient_id": index + 2000,
|
||||||
|
"source_patient_id": index + 2000, "patient_name": f"演示患者{index:02d}",
|
||||||
|
"patient_phone": "13800001234", "gender": 2, "age": 38,
|
||||||
|
"status": 1, "status_desc": "待接诊", "doctor_name": "演示医生",
|
||||||
|
"doctor_id": 30, "assistant_name": "演示医助", "has_appointment": True,
|
||||||
|
"appointment_id": index + 1000, "appointment_status": 1,
|
||||||
|
"appointment_date": date.today().isoformat(), "appointment_time": "09:00-09:30",
|
||||||
|
"diagnosis_confirmed": True, "has_prescription": False,
|
||||||
|
"appointments": [{"id": index + 1000, "status": 1,
|
||||||
|
"doctor_name": "演示医生", "time_text": "09:00-09:30"}],
|
||||||
|
} for index in range(1, 16)]
|
||||||
|
return {"lists": rows, "count": 15, "extend": {"status_count": {"1": 15}}}
|
||||||
|
|
||||||
|
list_appointments = _list
|
||||||
|
list_consultations = _list
|
||||||
|
|
||||||
|
|
||||||
|
def _inline(function: Any, *, on_success=None, on_error=None, on_finished=None) -> None:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error is not None:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished is not None:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
QTest.qWait(10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def list_window(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr(appointments, "run_async", _inline)
|
||||||
|
monkeypatch.setattr(consultations, "run_async", _inline)
|
||||||
|
monkeypatch.setattr(consultations, "DiagnosisDialog", _Dialog)
|
||||||
|
monkeypatch.setattr(consultations.ConsultationsPage, "_load_filter_options", lambda self: None)
|
||||||
|
monkeypatch.setattr(consultations.ConsultationsPage, "_refresh_counts", lambda self: None)
|
||||||
|
monkeypatch.setattr(appointments.AppointmentsPage, "_load_departments", lambda self: None)
|
||||||
|
monkeypatch.setattr(shell_module.motion, "reduced_motion", lambda: True)
|
||||||
|
navigation = [
|
||||||
|
NavigationItem("appointments", "挂号列表", "号", appointments.AppointmentsPage,
|
||||||
|
("doctor.appointment/lists",)),
|
||||||
|
NavigationItem("consultations", "问诊列表", "询", consultations.ConsultationsPage,
|
||||||
|
("tcm.diagnosis/lists",)),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(shell_module, "_resolve_navigation",
|
||||||
|
lambda *_args, **_kwargs: [(item, item.title) for item in navigation])
|
||||||
|
repository = _Repository()
|
||||||
|
window = ShellWindow(repository, {"user": {"name": "演示医生", "role_id": 1},
|
||||||
|
"demo_mode": True}, permissions={"*"})
|
||||||
|
yield window, repository
|
||||||
|
window.close()
|
||||||
|
window.deleteLater()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["appointments", "consultations"])
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1366, 768), (1536, 960)])
|
||||||
|
def test_folding_reclaims_rows_and_retains_query_refresh_and_navigation_state(
|
||||||
|
application: QApplication, list_window: Any, kind: str, width: int, height: int,
|
||||||
|
) -> None:
|
||||||
|
window, repository = list_window
|
||||||
|
window.resize(width, height)
|
||||||
|
window.show()
|
||||||
|
assert window.navigate(kind)
|
||||||
|
_settle(application)
|
||||||
|
page = window.pages[kind]
|
||||||
|
page.poll_timer.stop()
|
||||||
|
panel = page.filter_panel if kind == "appointments" else page.filters_card
|
||||||
|
header = page.header if kind == "appointments" else page.page_header
|
||||||
|
search = page.patient_input if kind == "appointments" else page.patient_name_edit
|
||||||
|
nested = page.more_filters_button if kind == "appointments" else page.more_filter_button
|
||||||
|
action = page.toolbar_edit_button if kind == "appointments" else page.add_button
|
||||||
|
disclosure = page.filter_disclosure
|
||||||
|
filters = page._query_filters if kind == "appointments" else page._filters
|
||||||
|
assert not disclosure.expanded
|
||||||
|
assert panel.isHidden() and not search.isVisible()
|
||||||
|
assert header.height() <= 44
|
||||||
|
assert not header.subtitle_label.isVisible()
|
||||||
|
assert disclosure.button.isVisible() and action.isVisible()
|
||||||
|
assert 30 <= disclosure.button.height() <= 34
|
||||||
|
assert window.refresh_button.isVisible()
|
||||||
|
assert any(button.isVisible() and button.text() == "刷新"
|
||||||
|
for button in page.findChildren(QPushButton))
|
||||||
|
collapsed_height = page.table.viewport().height()
|
||||||
|
capture_dir = os.environ.get("COLLAPSIBLE_LIST_SCREENSHOTS")
|
||||||
|
if capture_dir:
|
||||||
|
Path(capture_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-collapsed.png"))
|
||||||
|
|
||||||
|
calls_before = len(repository.calls)
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert len(repository.calls) == calls_before
|
||||||
|
assert panel.isVisible() and search.isVisible()
|
||||||
|
assert collapsed_height >= page.table.viewport().height() + 100
|
||||||
|
assert panel.rect().contains(QRect(search.mapTo(panel, QPoint()), search.size()))
|
||||||
|
assert search.parentWidget().rect().contains(search.geometry())
|
||||||
|
assert not page.advanced_filters.isVisible()
|
||||||
|
if capture_dir:
|
||||||
|
assert window.grab().save(str(Path(capture_dir) / f"{kind}-{width}-expanded.png"))
|
||||||
|
(Path(capture_dir) / f"{kind}-{width}-metrics.json").write_text(
|
||||||
|
json.dumps({"window": [width, height], "header_height": header.height(),
|
||||||
|
"toggle_height": disclosure.button.height(),
|
||||||
|
"collapsed_viewport_height": collapsed_height,
|
||||||
|
"expanded_viewport_height": page.table.viewport().height(),
|
||||||
|
"viewport_gain": collapsed_height - page.table.viewport().height()},
|
||||||
|
indent=2), encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
search.setText("演示患者")
|
||||||
|
query_button = (page.findChild(QPushButton, "AppointmentSearchButton")
|
||||||
|
if kind == "appointments" else page.search_button)
|
||||||
|
QTest.mouseClick(query_button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert len(repository.calls) > calls_before
|
||||||
|
assert repository.calls[-1]["patient_name"] == "演示患者"
|
||||||
|
QTest.mouseClick(nested, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.advanced_filters.isVisible()
|
||||||
|
query = dict(filters())
|
||||||
|
calls_before = len(repository.calls)
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert len(repository.calls) == calls_before
|
||||||
|
assert filters() == query
|
||||||
|
assert search.text() == "演示患者"
|
||||||
|
assert panel.isHidden()
|
||||||
|
assert page.table.viewport().height() == collapsed_height
|
||||||
|
|
||||||
|
window.resize(width - 50, height - 20)
|
||||||
|
window.refresh_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert not disclosure.expanded and panel.isHidden()
|
||||||
|
assert filters() == query
|
||||||
|
other = "consultations" if kind == "appointments" else "appointments"
|
||||||
|
assert window.navigate(other)
|
||||||
|
assert window.navigate(kind)
|
||||||
|
_settle(application)
|
||||||
|
assert not disclosure.expanded and panel.isHidden()
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
_settle(application)
|
||||||
|
assert page.advanced_filters.isVisible() and nested.isChecked()
|
||||||
|
window.refresh_button.click()
|
||||||
|
_settle(application)
|
||||||
|
assert disclosure.expanded and panel.isVisible()
|
||||||
|
assert filters() == query
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Page disclosures reclaim list space without changing queries or tab state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, QPoint, QRect, Qt, QTimer
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import patients as patients_module
|
||||||
|
from doctor_workstation.ui.pages.patients import PatientsPage
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def _result(self, kind: str, query: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
self.calls.append((kind, deepcopy(query)))
|
||||||
|
return {
|
||||||
|
"lists": [{
|
||||||
|
"id": 101 + index, "diagnosis_id": 501 + index,
|
||||||
|
"patient_id": 301 + index, "patient_name": f"患者 {index + 1}",
|
||||||
|
"order_no": f"TEST-20260907-{index + 1}", "queue_no": index + 1,
|
||||||
|
"appointment_status": 1, "queue_status": "waiting",
|
||||||
|
"queue_status_text": "等待中", "doctor_name": "测试医生",
|
||||||
|
} for index in range(4)],
|
||||||
|
"count": 4,
|
||||||
|
"extend": {
|
||||||
|
"scope": {"label": "本人患者"}, "schedule_mode": "ownership",
|
||||||
|
"summary": {"waiting": 4, "today": 4, "orders": 4},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_patients(self, **query: Any) -> dict[str, Any]:
|
||||||
|
return self._result("patients", query)
|
||||||
|
|
||||||
|
def patient_orders(self, **query: Any) -> dict[str, Any]:
|
||||||
|
return self._result("orders", query)
|
||||||
|
|
||||||
|
def patient_progress(self, **query: Any) -> dict[str, Any]:
|
||||||
|
return self._result("progress", query)
|
||||||
|
|
||||||
|
|
||||||
|
def settle(application: QApplication) -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(application)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page_factory(application, monkeypatch):
|
||||||
|
def immediate(function, *, on_success=None, on_error=None, on_finished=None):
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
def reject_network(*_args, **_kwargs):
|
||||||
|
pytest.fail("Disclosure tests must use local fixture data")
|
||||||
|
|
||||||
|
monkeypatch.setattr(patients_module, "run_async", immediate)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect", reject_network)
|
||||||
|
monkeypatch.setattr(socket.socket, "connect_ex", reject_network)
|
||||||
|
monkeypatch.setattr(socket, "create_connection", reject_network)
|
||||||
|
opened = []
|
||||||
|
|
||||||
|
def create(width=1328, height=884):
|
||||||
|
repository = Repository()
|
||||||
|
page = PatientsPage(repository, PermissionSet(["*"]))
|
||||||
|
opened.append(page)
|
||||||
|
page.resize(width, height)
|
||||||
|
page.show()
|
||||||
|
settle(application)
|
||||||
|
return page, repository
|
||||||
|
|
||||||
|
yield create
|
||||||
|
for page in opened:
|
||||||
|
for timer in page.findChildren(QTimer):
|
||||||
|
timer.stop()
|
||||||
|
page.close()
|
||||||
|
page.deleteLater()
|
||||||
|
settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def regions(page, index):
|
||||||
|
return (
|
||||||
|
(page.patient_workspace.search_toolbar, page.patient_workspace.filter_card,
|
||||||
|
page.patient_workspace.summary_strip),
|
||||||
|
(page.order_workspace.filter_card, page.order_workspace.summary_strip),
|
||||||
|
(page.progress_workspace.overview_card, page.progress_workspace.schedule_card),
|
||||||
|
)[index]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("index", [0, 1, 2], ids=["patients", "orders", "progress"])
|
||||||
|
def test_tabs_default_collapsed_and_keyboard_expansion_gives_space_to_lists(
|
||||||
|
application, page_factory, index
|
||||||
|
):
|
||||||
|
page, repository = page_factory()
|
||||||
|
page.tabs.setCurrentIndex(index)
|
||||||
|
settle(application)
|
||||||
|
workspace = page.tabs.currentWidget()
|
||||||
|
table = workspace.queue_table if index == 2 else workspace.table
|
||||||
|
disclosure = page.filter_disclosure
|
||||||
|
assert not disclosure.expanded
|
||||||
|
assert all(widget.isHidden() for widget in regions(page, index))
|
||||||
|
assert sum(item.button.isVisible() for item in page.filter_disclosures) == 1
|
||||||
|
assert disclosure.button.text() == disclosure.button.accessibleName() == "展开筛选"
|
||||||
|
assert disclosure.button.height() == 32
|
||||||
|
assert page.header.height() <= 48
|
||||||
|
assert not page.header.subtitle_label.isVisible()
|
||||||
|
assert page.refresh_button.isVisible() and page.tabs.tabBar().isVisible()
|
||||||
|
collapsed_height = table.viewport().height()
|
||||||
|
before = deepcopy(repository.calls)
|
||||||
|
disclosure.button.setFocus()
|
||||||
|
QTest.keyClick(disclosure.button, Qt.Key.Key_Space)
|
||||||
|
settle(application)
|
||||||
|
assert disclosure.expanded and disclosure.button.text() == "收起筛选"
|
||||||
|
assert all(widget.isVisible() for widget in regions(page, index))
|
||||||
|
assert collapsed_height >= table.viewport().height() + 100
|
||||||
|
assert repository.calls == before
|
||||||
|
QTest.mouseClick(disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
assert table.viewport().height() == collapsed_height
|
||||||
|
assert table.isVisible() and workspace.pager.isVisible()
|
||||||
|
assert repository.calls == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("index", [0, 1], ids=["patient-filters", "order-filters"])
|
||||||
|
def test_search_values_survive_collapse_refresh_and_tab_switch(
|
||||||
|
application, page_factory, index
|
||||||
|
):
|
||||||
|
page, repository = page_factory()
|
||||||
|
page.tabs.setCurrentIndex(index)
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
settle(application)
|
||||||
|
workspace = page.tabs.currentWidget()
|
||||||
|
workspace.keyword_edit.setText(" 林青 ")
|
||||||
|
if index == 0:
|
||||||
|
QTest.mouseClick(workspace.custom_date_button, Qt.MouseButton.LeftButton)
|
||||||
|
else:
|
||||||
|
workspace.use_dates.setChecked(True)
|
||||||
|
workspace.rx_audit.setCurrentIndex(2)
|
||||||
|
workspace.start_date.setDate(QDate(2026, 9, 1))
|
||||||
|
workspace.end_date.setDate(QDate(2026, 9, 7))
|
||||||
|
QTest.mouseClick(workspace.search_button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
query = deepcopy(repository.calls[-1])
|
||||||
|
before = deepcopy(repository.calls)
|
||||||
|
page.filter_disclosure.set_expanded(False)
|
||||||
|
settle(application)
|
||||||
|
assert repository.calls == before
|
||||||
|
assert workspace.keyword_edit.text() == " 林青 "
|
||||||
|
assert workspace.start_date.date() == QDate(2026, 9, 1)
|
||||||
|
assert workspace.end_date.date() == QDate(2026, 9, 7)
|
||||||
|
QTest.mouseClick(page.refresh_button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
assert repository.calls[-1] == query
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
page.tabs.setCurrentIndex(2)
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
page.tabs.setCurrentIndex(index)
|
||||||
|
settle(application)
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert repository.calls[-1] == query
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
settle(application)
|
||||||
|
assert workspace.keyword_edit.isVisible() and workspace.start_date.isEnabled()
|
||||||
|
assert (workspace.custom_date_button.isChecked() if index == 0 else
|
||||||
|
workspace.rx_audit.currentIndex() == 2 and workspace.use_dates.isChecked())
|
||||||
|
page.tabs.setCurrentIndex(2)
|
||||||
|
assert page.filter_disclosure.expanded
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("width", "height"), [(1328, 884), (1158, 692), (816, 564)])
|
||||||
|
def test_resize_keeps_collapsed_regions_hidden_and_queue_reachable(
|
||||||
|
application, page_factory, width, height
|
||||||
|
):
|
||||||
|
page, _repository = page_factory()
|
||||||
|
page.resize(width, height)
|
||||||
|
for index in range(3):
|
||||||
|
page.tabs.setCurrentIndex(index)
|
||||||
|
settle(application)
|
||||||
|
workspace = page.tabs.currentWidget()
|
||||||
|
assert all(widget.isHidden() for widget in regions(page, index))
|
||||||
|
for widget in (page.filter_disclosure.button, page.refresh_button):
|
||||||
|
assert page.rect().contains(QRect(widget.mapTo(page, QPoint()), widget.size()))
|
||||||
|
assert workspace.scroll.verticalScrollBar().maximum() == 0
|
||||||
|
assert workspace.pager.isVisibleTo(page)
|
||||||
|
if index == 2:
|
||||||
|
assert workspace.splitter.minimumHeight() == 222
|
||||||
|
assert workspace.queue_card.isVisible()
|
||||||
|
assert workspace.queue_card.height() == workspace.splitter.height()
|
||||||
|
page.hide()
|
||||||
|
page.show()
|
||||||
|
settle(application)
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert all(widget.isHidden() for widget in regions(page, 2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapsed_refresh_coalesces_identical_in_flight_queries(
|
||||||
|
application, page_factory, monkeypatch
|
||||||
|
):
|
||||||
|
page, _repository = page_factory()
|
||||||
|
pending = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
patients_module, "run_async",
|
||||||
|
lambda function, **callbacks: pending.append((function, callbacks)),
|
||||||
|
)
|
||||||
|
page.refresh()
|
||||||
|
page.refresh()
|
||||||
|
assert len(pending) == 1
|
||||||
|
function, callbacks = pending[0]
|
||||||
|
callbacks["on_success"](function())
|
||||||
|
settle(application)
|
||||||
|
assert page.patient_workspace.table.rowCount() == 4
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert not page.patient_workspace.search_toolbar.isVisible()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QDate, QPoint, Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.pages import reception as reception_module
|
||||||
|
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(application):
|
||||||
|
for _ in range(8):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page(application, monkeypatch):
|
||||||
|
monkeypatch.setattr(reception_module, "run_async", lambda *_args, **_kwargs: None)
|
||||||
|
widget = ReceptionPage(object(), PermissionSet(["*"]))
|
||||||
|
widget.resize(1280, 800)
|
||||||
|
widget.show()
|
||||||
|
widget.poll_timer.stop()
|
||||||
|
widget.detail_stack.setCurrentIndex(1)
|
||||||
|
settle(application)
|
||||||
|
yield widget
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_fold_reclaims_queue_space_and_keeps_clinical_workspace(page, application):
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert page.filter_disclosure.button.text() == "展开筛选"
|
||||||
|
assert page.refresh_button.isVisible()
|
||||||
|
assert page.queue_filter_summary.isVisible()
|
||||||
|
assert not page.queue_filter_panel.isVisible()
|
||||||
|
assert not page.queue_date_button.isVisible()
|
||||||
|
assert not page.search_edit.isVisible()
|
||||||
|
assert all(not button.isVisible() for button in page._queue_filter_buttons.values())
|
||||||
|
assert page.detail_tabs.isVisible()
|
||||||
|
assert page.patient_name_label.isVisible()
|
||||||
|
assert page.clinical_info_group.isVisible()
|
||||||
|
clinical_geometry = page.detail_stack.geometry()
|
||||||
|
collapsed_height = page.queue_stack.height()
|
||||||
|
|
||||||
|
QTest.mouseClick(page.filter_disclosure.button, Qt.MouseButton.LeftButton)
|
||||||
|
settle(application)
|
||||||
|
assert page.filter_disclosure.expanded
|
||||||
|
assert page.filter_disclosure.button.text() == "收起筛选"
|
||||||
|
assert page.queue_date_button.isVisible()
|
||||||
|
assert page.search_edit.isVisible()
|
||||||
|
assert all(button.isVisible() for button in page._queue_filter_buttons.values())
|
||||||
|
assert collapsed_height - page.queue_stack.height() >= 64
|
||||||
|
assert page.detail_stack.geometry() == clinical_geometry
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_keeps_queries_patient_draft_loaded_pages_and_resize_state(page, application, monkeypatch):
|
||||||
|
requests = []
|
||||||
|
monkeypatch.setattr(page, "_request_queue_page", lambda query, **options: requests.append((query, options)))
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
page.queue_date_button._pick_date(QDate(2026, 8, 3))
|
||||||
|
page._queue_filter_buttons[None].click()
|
||||||
|
page.search_edit.setText(" 折叠测试患者 ")
|
||||||
|
QTest.keyClick(page.search_edit, Qt.Key.Key_Return)
|
||||||
|
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||||
|
assert requests[-1][0]["status"] is None
|
||||||
|
assert requests[-1][0]["start_date"] == requests[-1][0]["end_date"] == "2026-08-03"
|
||||||
|
assert page.queue_filter_summary.text() == "2026-08-03 · 全部状态 · 姓名已筛选"
|
||||||
|
assert "折叠测试患者" in page.queue_filter_summary.toolTip()
|
||||||
|
|
||||||
|
page._selected_appointment_id = 51
|
||||||
|
page._queue_records = [{"id": index} for index in range(1, 31)]
|
||||||
|
page._queue_page = 2
|
||||||
|
page._queue_total = 40
|
||||||
|
page.note_edit.setPlainText("尚未保存的接诊备注")
|
||||||
|
page.detail_tabs.setCurrentIndex(4)
|
||||||
|
query_snapshot = dict(page._queue_query)
|
||||||
|
request_count = len(requests)
|
||||||
|
loading = page._queue_loading
|
||||||
|
|
||||||
|
for expanded in (False, True, False):
|
||||||
|
page.filter_disclosure.set_expanded(expanded)
|
||||||
|
page.resize(816 if expanded else 1280, 800)
|
||||||
|
settle(application)
|
||||||
|
assert page.filter_disclosure.expanded is expanded
|
||||||
|
assert page.queue_filter_panel.isVisible() is expanded
|
||||||
|
assert len(requests) == request_count
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
assert page._queue_page == 2
|
||||||
|
assert len(page._queue_records) == 30
|
||||||
|
assert page._queue_loading is loading
|
||||||
|
assert page._queue_query == query_snapshot
|
||||||
|
assert page.search_edit.text() == " 折叠测试患者 "
|
||||||
|
assert page.note_edit.toPlainText() == "尚未保存的接诊备注"
|
||||||
|
assert page.detail_tabs.currentIndex() == 4
|
||||||
|
|
||||||
|
page.refresh(workspace_refresh=True)
|
||||||
|
assert not page.filter_disclosure.expanded
|
||||||
|
assert requests[-1][0]["patient_name"] == "折叠测试患者"
|
||||||
|
assert requests[-1][0]["status"] is None
|
||||||
|
assert requests[-1][0]["start_date"] == "2026-08-03"
|
||||||
|
assert requests[-1][0]["page_size"] == 30
|
||||||
|
assert page._selected_appointment_id == 51
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width", [816, 1280])
|
||||||
|
def test_expanded_queue_controls_fit_at_supported_page_widths(page, application, width):
|
||||||
|
page.filter_disclosure.set_expanded(True)
|
||||||
|
page.resize(width, 800)
|
||||||
|
page._update_queue_filter_counts({"extend": {"status_count": {"1": 23, "2": 11, "3": 42}}}, [])
|
||||||
|
settle(application)
|
||||||
|
panel = page.queue_panel
|
||||||
|
controls = [
|
||||||
|
page.refresh_button,
|
||||||
|
page.filter_disclosure.button,
|
||||||
|
page.queue_date_button,
|
||||||
|
page.search_edit,
|
||||||
|
*page._queue_filter_buttons.values(),
|
||||||
|
]
|
||||||
|
for widget in controls:
|
||||||
|
position = widget.mapTo(panel, QPoint())
|
||||||
|
assert position.x() >= 0
|
||||||
|
assert position.x() + widget.width() <= panel.width()
|
||||||
|
assert widget.width() >= widget.minimumSizeHint().width()
|
||||||
|
assert page.queue_date_button.geometry().right() < page.search_edit.geometry().left()
|
||||||
|
assert page.filter_disclosure.button.height() == page.search_edit.height() == 32
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Permission-scoped department loading and diagnosis HTTP filter contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
|
||||||
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
|
from doctor_workstation.ui.pages import consultations as module
|
||||||
|
|
||||||
|
TREE = [
|
||||||
|
{"id": 10, "name": "医助部", "children": [
|
||||||
|
{"id": "11", "name": "一组", "children": [
|
||||||
|
{"id": 12, "name": "专病组", "children": []},
|
||||||
|
]},
|
||||||
|
]},
|
||||||
|
{"id": 20, "name": "另一部门", "children": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, departments: Any = TREE) -> None:
|
||||||
|
self.departments = departments
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.calls.append((endpoint, dict(params or {})))
|
||||||
|
if endpoint == "dept.dept/all":
|
||||||
|
if isinstance(self.departments, Exception):
|
||||||
|
raise self.departments
|
||||||
|
return self.departments
|
||||||
|
return {"lists": [], "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class DetailStub(QWidget):
|
||||||
|
saved = Signal()
|
||||||
|
|
||||||
|
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
|
||||||
|
# Test only this page's controls; loading a global stylesheet is unrelated.
|
||||||
|
client = Client()
|
||||||
|
result = module.ConsultationsPage(
|
||||||
|
RemoteDoctorRepository(client),
|
||||||
|
current_user={"department_id": 99, "department_name": "个人所属部门"},
|
||||||
|
)
|
||||||
|
yield result, client
|
||||||
|
result.poll_timer.stop()
|
||||||
|
result.close()
|
||||||
|
result.deleteLater()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def immediate(function: Any, *, on_success: Any = None, on_error: Any = None,
|
||||||
|
on_finished: Any = None, **_kwargs: Any) -> None:
|
||||||
|
try:
|
||||||
|
value = function()
|
||||||
|
except Exception as error:
|
||||||
|
if on_error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
if on_success:
|
||||||
|
on_success(value)
|
||||||
|
finally:
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [TREE, {"lists": TREE}, {"tree": TREE}, {"data": {"lists": TREE}}])
|
||||||
|
def test_repository_scoped_tree_shapes(payload: Any) -> None:
|
||||||
|
client = Client(payload)
|
||||||
|
assert RemoteDoctorRepository(client).list_departments(apply_data_scope=True) == TREE
|
||||||
|
assert client.calls == [("dept.dept/all", {"apply_data_scope": 1})]
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_malformed_is_not_a_permission_empty_result() -> None:
|
||||||
|
with pytest.raises(ApiProtocolError):
|
||||||
|
RemoteDoctorRepository(Client({"unexpected": 1})).list_departments(apply_data_scope=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repository_maps_legacy_department_key_and_preserves_canonical() -> None:
|
||||||
|
client = Client()
|
||||||
|
repository = RemoteDoctorRepository(client)
|
||||||
|
repository.list_consultations(department_id=12)
|
||||||
|
assert client.calls[-1][1]["assistant_dept_id"] == 12
|
||||||
|
assert "department_id" not in client.calls[-1][1]
|
||||||
|
repository.list_consultations(department_id=12, assistant_dept_id=20)
|
||||||
|
assert client.calls[-1][1]["assistant_dept_id"] == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_options_search_and_selected_id_reach_list_and_counts(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
widget._load_departments()
|
||||||
|
combo = widget.department_combo
|
||||||
|
assert combo.count() == 5
|
||||||
|
assert combo.itemText(3) == "医助部 / 一组 / 专病组"
|
||||||
|
assert combo.findData(99) == -1
|
||||||
|
assert combo.isEditable()
|
||||||
|
combo.completer().setCompletionPrefix("专病")
|
||||||
|
assert combo.completer().completionCount() == 1
|
||||||
|
combo.setCurrentIndex(combo.findData(12))
|
||||||
|
widget._search()
|
||||||
|
calls = [params for endpoint, params in client.calls if endpoint == "tcm.diagnosis/lists"]
|
||||||
|
assert calls
|
||||||
|
assert all(params["assistant_dept_id"] == 12 for params in calls)
|
||||||
|
assert all("department_id" not in params for params in calls)
|
||||||
|
assert calls[-1]["page_size"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_unselected_search_text_never_becomes_an_id_or_silently_searches_all(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
widget._load_departments()
|
||||||
|
widget.department_combo.setEditText("不存在的部门")
|
||||||
|
widget._search()
|
||||||
|
assert not any(endpoint == "tcm.diagnosis/lists" for endpoint, _ in client.calls)
|
||||||
|
widget.department_combo.setEditText("")
|
||||||
|
widget._search()
|
||||||
|
assert widget._shared_filters()["assistant_dept_id"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_empty_has_no_profile_fallback_and_can_retry(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
client.departments = []
|
||||||
|
widget._load_departments()
|
||||||
|
assert widget.department_combo.count() == 1
|
||||||
|
assert widget.department_combo.currentData() == ""
|
||||||
|
assert widget.department_combo.currentText() == "暂无可选部门"
|
||||||
|
assert not widget.department_combo.isEnabled()
|
||||||
|
assert not widget.department_retry_button.isHidden()
|
||||||
|
client.departments = TREE
|
||||||
|
widget.department_retry_button.click()
|
||||||
|
assert widget.department_combo.isEnabled()
|
||||||
|
assert widget.department_combo.findData(12) > 0
|
||||||
|
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_error_stays_scoped_and_retry_recovers(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
client.departments = ApiBusinessError("无权限访问部门")
|
||||||
|
widget._load_departments()
|
||||||
|
assert not widget._departments_loaded
|
||||||
|
assert not widget._departments_loading
|
||||||
|
assert widget.department_combo.currentText() == "部门加载失败"
|
||||||
|
assert "无权限" in widget.department_combo.toolTip()
|
||||||
|
assert not widget.department_retry_button.isHidden()
|
||||||
|
client.departments = TREE
|
||||||
|
widget.department_retry_button.click()
|
||||||
|
assert widget._departments_loaded
|
||||||
|
assert widget.department_retry_button.isHidden()
|
||||||
|
assert all(params == {"apply_data_scope": 1} for _, params in client.calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_dedup_and_stale_success_or_failure_cannot_override_selection(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, _client = page
|
||||||
|
jobs: list[dict[str, Any]] = []
|
||||||
|
monkeypatch.setattr(module, "run_async", lambda function, **callbacks: jobs.append(callbacks))
|
||||||
|
widget._load_departments()
|
||||||
|
widget._load_departments()
|
||||||
|
assert len(jobs) == 1
|
||||||
|
widget._load_departments(force=True)
|
||||||
|
assert len(jobs) == 2
|
||||||
|
jobs[1]["on_success"]([(12, "当前部门")])
|
||||||
|
widget.department_combo.setCurrentIndex(1)
|
||||||
|
jobs[0]["on_success"]([(99, "旧部门")])
|
||||||
|
jobs[0]["on_error"](RuntimeError("旧请求失败"))
|
||||||
|
assert widget.department_combo.currentData() == 12
|
||||||
|
assert widget.department_combo.findData(99) == -1
|
||||||
|
assert widget._departments_loaded
|
||||||
|
assert widget.department_combo.isEnabled()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_of_options_preserves_selected_id(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, _client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
widget._load_departments()
|
||||||
|
widget.department_combo.setCurrentIndex(widget.department_combo.findData(12))
|
||||||
|
widget._load_departments(force=True)
|
||||||
|
assert widget.department_combo.currentData() == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_repository_is_not_called_without_scope(page: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
widget, _client = page
|
||||||
|
monkeypatch.setattr(module, "run_async", immediate)
|
||||||
|
calls: list[bool] = []
|
||||||
|
|
||||||
|
class OldRepository:
|
||||||
|
def list_departments(self):
|
||||||
|
calls.append(True)
|
||||||
|
return TREE
|
||||||
|
|
||||||
|
widget.repository = OldRepository()
|
||||||
|
widget._load_departments()
|
||||||
|
assert calls == []
|
||||||
|
assert not widget._departments_loaded
|
||||||
|
assert not widget.department_retry_button.isHidden()
|
||||||
@@ -365,9 +365,11 @@ def test_refresh_generation_ignores_late_results(
|
|||||||
return object()
|
return object()
|
||||||
|
|
||||||
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)
|
||||||
page.refresh(silent=True)
|
# Identical in-flight requests are deduplicated; a new query supersedes one.
|
||||||
|
page.keyword_edit.setText("新患者")
|
||||||
|
page.refresh(silent=True)
|
||||||
|
|
||||||
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
|
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
|
||||||
callbacks[0]["on_success"]({"lists": [_row(id=901, diagnosis_id=901)], "count": 1})
|
callbacks[0]["on_success"]({"lists": [_row(id=901, diagnosis_id=901)], "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,240 @@
|
|||||||
|
"""Critical-path request counts and late metadata safety, without global Qt styling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
|
from doctor_workstation.ui.dialogs import diagnosis as module
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, delay: float = 0) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
self.delay = delay
|
||||||
|
|
||||||
|
def get(self, endpoint: str, params: dict[str, Any]) -> Any:
|
||||||
|
self.calls.append((endpoint, params))
|
||||||
|
time.sleep(self.delay)
|
||||||
|
if endpoint == "config/dict":
|
||||||
|
return {
|
||||||
|
key: [{"name": "口干", "value": "dry"}, {"name": "口苦", "value": "bitter"}]
|
||||||
|
for key in params["type"].split(",")
|
||||||
|
}
|
||||||
|
if endpoint.endswith("Detail") or endpoint.endswith("/detail"):
|
||||||
|
return {
|
||||||
|
"id": params["id"], "patient_id": params["id"] + 1000,
|
||||||
|
"patient_name": f"Patient {params['id']}", "appetite": ["dry"],
|
||||||
|
"diagnosis_type": "first_visit", "chief_complaint": "Original",
|
||||||
|
}
|
||||||
|
return {"lists": [], "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def queued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
|
||||||
|
jobs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def enqueue(function: Any, **callbacks: Any) -> object:
|
||||||
|
jobs.append({"function": function, **callbacks})
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "run_async", enqueue)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def complete(job: dict[str, Any]) -> None:
|
||||||
|
job["on_success"](job["function"]())
|
||||||
|
if job.get("on_finished"):
|
||||||
|
job["on_finished"]()
|
||||||
|
|
||||||
|
|
||||||
|
def dialog_for(client: Client) -> module.DiagnosisDialog:
|
||||||
|
return module.DiagnosisDialog(
|
||||||
|
RemoteDoctorRepository(client), permissions=PermissionSet(["*"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_unlocks_before_single_batch_and_preserves_draft(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
client = Client()
|
||||||
|
dialog = dialog_for(client)
|
||||||
|
try:
|
||||||
|
dialog.open_for(501, editable=True, seed={"patient_name": "Unverified"})
|
||||||
|
assert dialog.isVisible()
|
||||||
|
assert dialog.drawer_loading.isVisibleTo(dialog)
|
||||||
|
assert dialog.edit_fields["patient_name"].isReadOnly()
|
||||||
|
assert not dialog.save_button.isEnabled()
|
||||||
|
assert len(queued) == 1
|
||||||
|
complete(queued[0])
|
||||||
|
assert client.calls == [("tcm.diagnosis/detail", {"id": 501})]
|
||||||
|
assert dialog._authoritative_detail_loaded
|
||||||
|
assert dialog.save_button.isEnabled()
|
||||||
|
assert not dialog.drawer_loading.isVisibleTo(dialog)
|
||||||
|
assert len(queued) == 2
|
||||||
|
dialog.edit_fields["chief_complaint"].setPlainText("Unsaved draft")
|
||||||
|
dialog.edit_fields["appetite"].setPlainText("bitter")
|
||||||
|
complete(queued[1])
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
assert client.calls[1][0] == "config/dict"
|
||||||
|
assert len(client.calls[1][1]["type"].split(",")) == 16
|
||||||
|
assert dialog.edit_fields["appetite"].toPlainText() == "bitter"
|
||||||
|
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Unsaved draft"
|
||||||
|
assert dialog._loaded_tabs == set()
|
||||||
|
assert not any("getCallRecords" in endpoint for endpoint, _params in client.calls)
|
||||||
|
dialog._ensure_tab_loaded("basic")
|
||||||
|
dialog.refresh_permissions()
|
||||||
|
assert len(queued) == 2
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("close", [False, True])
|
||||||
|
def test_late_detail_and_dictionary_callbacks_cannot_touch_another_opening(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]], close: bool
|
||||||
|
) -> None:
|
||||||
|
dialog = dialog_for(Client())
|
||||||
|
try:
|
||||||
|
dialog.open_for(501, editable=True)
|
||||||
|
detail_job = queued[0]
|
||||||
|
complete(detail_job)
|
||||||
|
metadata_job = queued[1]
|
||||||
|
if close:
|
||||||
|
dialog.close()
|
||||||
|
else:
|
||||||
|
dialog.open_for(502, editable=True)
|
||||||
|
complete(queued[2])
|
||||||
|
dialog.edit_fields["chief_complaint"].setPlainText("Keep current draft")
|
||||||
|
before = dialog.edit_fields["patient_name"].toPlainText()
|
||||||
|
complete(detail_job)
|
||||||
|
complete(metadata_job)
|
||||||
|
assert dialog.edit_fields["patient_name"].toPlainText() == before
|
||||||
|
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep current draft"
|
||||||
|
if not close:
|
||||||
|
assert before == "Patient 502"
|
||||||
|
assert dialog._patient_id == 1502
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dictionary_failure_does_not_relock_valid_detail_or_erase_choices(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
dialog = dialog_for(Client())
|
||||||
|
try:
|
||||||
|
dialog.open_for(501, editable=True)
|
||||||
|
complete(queued[0])
|
||||||
|
queued[1]["on_error"](RuntimeError("Delayed metadata unavailable"))
|
||||||
|
assert dialog._authoritative_detail_loaded
|
||||||
|
assert dialog.save_button.isEnabled()
|
||||||
|
assert dialog.edit_fields["appetite"].toPlainText() == "dry"
|
||||||
|
assert not dialog.drawer_loading.isVisibleTo(dialog)
|
||||||
|
dialog._retry_current()
|
||||||
|
assert len(queued) == 3
|
||||||
|
complete(queued[2])
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactive_basic_tab_defers_dictionary_request_until_selected(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
client = Client()
|
||||||
|
dialog = dialog_for(client)
|
||||||
|
try:
|
||||||
|
notes_index = next(
|
||||||
|
i for i in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(i) == "notes"
|
||||||
|
)
|
||||||
|
dialog.tabs.setCurrentIndex(notes_index)
|
||||||
|
dialog.open_for(501, editable=True)
|
||||||
|
complete(queued[0])
|
||||||
|
assert dialog._current_tab_key() == "notes"
|
||||||
|
assert dialog._dictionary_requested_generation != dialog._generation
|
||||||
|
assert len(client.calls) == 1
|
||||||
|
# The only secondary request is for the active notes tab, not all tabs.
|
||||||
|
assert len(queued) == 2
|
||||||
|
dialog.tabs.setCurrentIndex(0)
|
||||||
|
assert len(queued) == 3
|
||||||
|
complete(queued[2])
|
||||||
|
assert client.calls[-1][0] == "config/dict"
|
||||||
|
dialog.tabs.setCurrentIndex(notes_index)
|
||||||
|
dialog.tabs.setCurrentIndex(0)
|
||||||
|
assert len(queued) == 3
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("retry_from_banner", [False, True])
|
||||||
|
def test_dictionary_error_survives_other_tab_success_and_retries_correct_request(
|
||||||
|
application: QApplication, queued: list[dict[str, Any]], retry_from_banner: bool,
|
||||||
|
) -> None:
|
||||||
|
client = Client()
|
||||||
|
dialog = dialog_for(client)
|
||||||
|
try:
|
||||||
|
dialog.open_for(501, editable=True)
|
||||||
|
complete(queued[0])
|
||||||
|
metadata_job = queued[1]
|
||||||
|
notes_index = next(i for i in range(dialog.tabs.count())
|
||||||
|
if dialog.tabs.tabBar().tabData(i) == "notes")
|
||||||
|
dialog.tabs.setCurrentIndex(notes_index)
|
||||||
|
notes_job = queued[2]
|
||||||
|
metadata_job["on_error"](RuntimeError("Metadata unavailable"))
|
||||||
|
complete(notes_job)
|
||||||
|
assert dialog.drawer_banner.label.text() == dialog._dictionary_error_message
|
||||||
|
assert dialog.drawer_banner.label.text().startswith("病历选项加载失败")
|
||||||
|
dialog.edit_fields["chief_complaint"].setPlainText("Keep draft across retry")
|
||||||
|
if retry_from_banner:
|
||||||
|
dialog.drawer_banner.action_requested.emit()
|
||||||
|
else:
|
||||||
|
dialog.tabs.setCurrentIndex(0)
|
||||||
|
assert len(queued) == 4
|
||||||
|
complete(queued[3])
|
||||||
|
assert client.calls[-1][0] == "config/dict"
|
||||||
|
assert not dialog._dictionary_error_message
|
||||||
|
assert dialog.edit_fields["chief_complaint"].toPlainText() == "Keep draft across retry"
|
||||||
|
assert dialog._authoritative_detail_loaded
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_controlled_latency_removes_sixteen_round_trips_from_detail_gate(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
client = Client(delay=0.02)
|
||||||
|
dialog = dialog_for(client)
|
||||||
|
try:
|
||||||
|
types = sorted({"diagnosis_type", *(row[0] for row in module._CHOICE_DICTIONARIES.values())})
|
||||||
|
started = time.perf_counter()
|
||||||
|
dialog.repository.get_diagnosis_detail(501)
|
||||||
|
for key in types:
|
||||||
|
dialog.repository.get_dictionary(key)
|
||||||
|
old_time = time.perf_counter() - started
|
||||||
|
old_calls = len(client.calls)
|
||||||
|
client.calls.clear()
|
||||||
|
started = time.perf_counter()
|
||||||
|
detail = dialog._load_bundle(501, "edit")
|
||||||
|
new_time = time.perf_counter() - started
|
||||||
|
assert detail["detail"]["id"] == 501
|
||||||
|
assert len(client.calls) == 1
|
||||||
|
dialog._load_dictionary_choices()
|
||||||
|
assert len(client.calls) == 2
|
||||||
|
assert old_calls == 17
|
||||||
|
assert new_time < old_time / 4
|
||||||
|
print(f"controlled 20 ms RTT: old gate={old_time:.3f}s/17 calls; "
|
||||||
|
f"new gate={new_time:.3f}s/1 call; total new=2 calls")
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
@@ -356,8 +356,11 @@ def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete(
|
|||||||
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
|
assert thumb._apply_payload(_png_bytes(192, 96), thumb._generation)
|
||||||
assert thumb.property("loadState") == "ready"
|
assert thumb.property("loadState") == "ready"
|
||||||
assert thumb._rendered_pixmap.size() == QSize(64, 64)
|
assert thumb._rendered_pixmap.size() == QSize(64, 64)
|
||||||
|
previews: list[tuple[list[str], int]] = []
|
||||||
|
timeline.preview_images_requested.connect(lambda sources, index: previews.append((sources, index)))
|
||||||
thumb.click()
|
thumb.click()
|
||||||
assert opened == [tongue_url]
|
assert opened == []
|
||||||
|
assert previews == [([tongue_url], 0)]
|
||||||
|
|
||||||
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
|
remove_buttons = timeline.findChildren(QPushButton, "DiagnosisAttachmentRemove")
|
||||||
assert len(remove_buttons) == 2
|
assert len(remove_buttons) == 2
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QBuffer, QCoreApplication, QEvent, QIODevice, QPoint, Qt
|
||||||
|
from PySide6.QtGui import QColor, QDesktopServices, QFont, QImage, QPainter
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from test_diagnosis_drawer_visual import (
|
||||||
|
ActionRepository,
|
||||||
|
_open_dialog,
|
||||||
|
_select_tab,
|
||||||
|
)
|
||||||
|
from test_diagnosis_drawer_visual import (
|
||||||
|
application as application,
|
||||||
|
)
|
||||||
|
from test_diagnosis_drawer_visual import (
|
||||||
|
immediate_async as immediate_async,
|
||||||
|
)
|
||||||
|
from test_diagnosis_media_thumbnail_visual import (
|
||||||
|
_FakeManager,
|
||||||
|
_hold_remote_load,
|
||||||
|
_png_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
from doctor_workstation.ui.diagnosis_drawer import _RemoteImageButton
|
||||||
|
from doctor_workstation.ui.diagnosis_media import ImagePreviewDialog
|
||||||
|
from doctor_workstation.ui.theme import _register_preferred_cjk_fonts
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_image() -> bytes:
|
||||||
|
image = QImage(1600, 1200, QImage.Format.Format_ARGB32)
|
||||||
|
image.fill(QColor("#F4E3D8"))
|
||||||
|
painter = QPainter(image)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QColor("#C37B85"))
|
||||||
|
painter.drawEllipse(440, 160, 720, 900)
|
||||||
|
painter.setPen(QColor("#67434A"))
|
||||||
|
painter.setFont(QFont("Arial", 36))
|
||||||
|
painter.drawText(image.rect(), Qt.AlignmentFlag.AlignCenter, "SYNTHETIC TEST IMAGE")
|
||||||
|
painter.end()
|
||||||
|
buffer = QBuffer()
|
||||||
|
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||||
|
assert image.save(buffer, "PNG")
|
||||||
|
return bytes(buffer.data())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["edit", "readonly"])
|
||||||
|
def test_real_note_thumbnail_click_keeps_drawer_and_previews_all_eight_images(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch, mode: str,
|
||||||
|
) -> None:
|
||||||
|
"""Exercise the wired drawer click, not just the standalone lightbox class."""
|
||||||
|
monkeypatch.setattr(_RemoteImageButton, "load_url", _hold_remote_load)
|
||||||
|
external: list[str] = []
|
||||||
|
monkeypatch.setattr(QDesktopServices, "openUrl", lambda url: external.append(url.toString()) or True)
|
||||||
|
application.setFont(QFont(_register_preferred_cjk_fonts(), 10))
|
||||||
|
payload = _synthetic_image()
|
||||||
|
|
||||||
|
def offline_request(preview: ImagePreviewDialog, target: str) -> None:
|
||||||
|
preview._invalidate_request()
|
||||||
|
preview.apply_payload(payload, preview._generation)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ImagePreviewDialog, "_request", offline_request)
|
||||||
|
dialog = _open_dialog(application, (1440, 900), mode=mode, repository=ActionRepository())
|
||||||
|
urls = [f"https://media.example.invalid/tongue-{index}.jpg" for index in range(8)]
|
||||||
|
if mode == "edit":
|
||||||
|
_select_tab(dialog, "notes")
|
||||||
|
dialog._fill_notes([{"id": 7001, "note_date": "2026-09-08", "content": "合成测试图片", "tongue_images": urls}])
|
||||||
|
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
timeline = dialog.drawer_notes_timeline if mode == "edit" else dialog._notes_timelines[0]
|
||||||
|
application.processEvents()
|
||||||
|
thumbs = timeline.findChildren(_RemoteImageButton, "DiagnosisTongueThumb")
|
||||||
|
assert len(thumbs) == 8
|
||||||
|
for thumb in thumbs:
|
||||||
|
assert thumb._apply_payload(payload, thumb._generation)
|
||||||
|
original_parent = dialog.parentWidget()
|
||||||
|
original_generation = dialog._generation
|
||||||
|
for index in (0, 7, 3):
|
||||||
|
QTest.mouseClick(thumbs[index], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
|
||||||
|
application.processEvents()
|
||||||
|
assert external == [], "Thumbnail click must not launch the system browser"
|
||||||
|
preview = dialog._image_preview
|
||||||
|
assert isinstance(preview, ImagePreviewDialog)
|
||||||
|
assert preview.isVisible() and preview.parentWidget() is dialog
|
||||||
|
assert dialog.isVisible() and dialog.parentWidget() is original_parent
|
||||||
|
assert dialog._generation == original_generation
|
||||||
|
assert preview.current_source == urls[index]
|
||||||
|
assert preview.sources == urls
|
||||||
|
assert not preview.canvas.pixmap().isNull()
|
||||||
|
fitted_width = preview.canvas.pixmap().width()
|
||||||
|
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert preview.canvas.pixmap().width() == 1600 > fitted_width
|
||||||
|
QTest.mouseClick(preview.next_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert preview.current_source == urls[(index + 1) % 8]
|
||||||
|
QTest.mouseClick(preview.previous_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert preview.current_source == urls[index]
|
||||||
|
if mode == "edit" and index == 7:
|
||||||
|
output = Path(__file__).resolve().parents[2] / "artifacts/patient-render-image-fix"
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
assert dialog.grab().save(str(output / "notes-eight-thumbnails.png"))
|
||||||
|
QTest.mouseClick(preview.zoom_button, Qt.MouseButton.LeftButton)
|
||||||
|
application.processEvents()
|
||||||
|
assert preview.grab().save(str(output / "notes-image-preview.png"))
|
||||||
|
if index == 3:
|
||||||
|
QTest.keyClick(preview, Qt.Key.Key_Escape)
|
||||||
|
else:
|
||||||
|
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog.isVisible() and timeline.isVisible()
|
||||||
|
assert dialog._image_preview is None
|
||||||
|
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
assert external == []
|
||||||
|
QTest.mouseClick(thumbs[4], Qt.MouseButton.LeftButton, pos=QPoint(24, 32))
|
||||||
|
preview = dialog._image_preview
|
||||||
|
QTest.mouseClick(preview.external_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert external == [urls[4]]
|
||||||
|
dialog.reject()
|
||||||
|
application.processEvents()
|
||||||
|
assert dialog._image_preview is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("close_with_escape", [False, True])
|
||||||
|
def test_preview_close_cancels_pending_response(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch, close_with_escape: bool,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(ImagePreviewDialog, "_request", lambda self, target: None)
|
||||||
|
preview = ImagePreviewDialog(["https://media.example.invalid/pending.jpg"])
|
||||||
|
manager = _FakeManager(preview)
|
||||||
|
manager.queue(_png_bytes(80, 60))
|
||||||
|
preview._manager = manager
|
||||||
|
monkeypatch.undo()
|
||||||
|
preview.reload_current()
|
||||||
|
reply = manager.replies[-1]
|
||||||
|
generation = preview._generation
|
||||||
|
preview.show()
|
||||||
|
if close_with_escape:
|
||||||
|
QTest.keyClick(preview, Qt.Key.Key_Escape)
|
||||||
|
else:
|
||||||
|
QTest.mouseClick(preview.close_button, Qt.MouseButton.LeftButton)
|
||||||
|
assert reply.aborted
|
||||||
|
assert preview._generation > generation
|
||||||
|
reply.finished.emit()
|
||||||
|
assert preview._pixmap.isNull()
|
||||||
|
application.processEvents()
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint, Qt
|
||||||
|
from PySide6.QtGui import QFont
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QToolButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.ui.diagnosis_index_widgets import (
|
||||||
|
DiagnosisTableHost,
|
||||||
|
_blue_appointment_layout,
|
||||||
|
_blue_appointment_text,
|
||||||
|
_blue_font,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.reception_style import body_family, heading_family
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(application: QApplication) -> None:
|
||||||
|
for _ in range(12):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(identifier: int = 501) -> dict:
|
||||||
|
return {
|
||||||
|
"id": identifier, "diagnosis_id": identifier, "patient_id": identifier + 1000,
|
||||||
|
"patient_name": "林晓岚", "gender": 2, "age": 46, "doctor_name": "陈医生(演示)",
|
||||||
|
"appointment_id": identifier + 2000, "has_appointment": 1, "appointment_status": 1,
|
||||||
|
"appointment_date": "2026-09-05", "appointment_time": "09:00-09:30",
|
||||||
|
"appointments": [
|
||||||
|
{"id": identifier + 2000, "status": 1, "appointment_date": "2026-09-05"},
|
||||||
|
{"id": identifier + 1900, "status": 3, "appointment_date": "2026-08-06"},
|
||||||
|
],
|
||||||
|
"diagnosis_confirmed": 1, "assistant_name": "周医助", "has_prescription": 0,
|
||||||
|
"unserved_days": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_display_fallback_requires_exact_current_appointment_id(application: QApplication) -> None:
|
||||||
|
row = _row()
|
||||||
|
before = copy.deepcopy(row)
|
||||||
|
assert _blue_appointment_text(row, row["appointments"][0]) == (
|
||||||
|
"陈医生(演示)", "2026-09-05 09:00-09:30",
|
||||||
|
)
|
||||||
|
assert _blue_appointment_text(row, row["appointments"][1]) == (
|
||||||
|
"—", "2026-08-06 时间 —",
|
||||||
|
)
|
||||||
|
nested = {"id": row["appointment_id"], "doctor_name": "原医生",
|
||||||
|
"appointment_date": "2026-09-07", "time_text": "2026-09-07 11:00-11:30"}
|
||||||
|
assert _blue_appointment_text(row, nested) == ("原医生", "2026-09-07 11:00-11:30")
|
||||||
|
assert _blue_appointment_text(row, {}) == ("—", "时间 —")
|
||||||
|
for missing_id in (0, "", None):
|
||||||
|
assert _blue_appointment_text({**row, "appointment_id": missing_id}, {}) == ("—", "时间 —")
|
||||||
|
assert row == before
|
||||||
|
legacy = DiagnosisTableHost()
|
||||||
|
blue = DiagnosisTableHost(tech_blue=True)
|
||||||
|
for host in (legacy, blue):
|
||||||
|
host.set_rows([row])
|
||||||
|
assert "陈医生" not in legacy.model.index(0, 4).data()
|
||||||
|
assert blue.model.index(0, 4).data().splitlines() == [
|
||||||
|
"陈医生(演示) · 2026-09-05 09:00-09:30", "— · 2026-08-06 时间 —",
|
||||||
|
]
|
||||||
|
assert blue.model.index(0, 9).data() == "—"
|
||||||
|
blue.set_rows([{**row, "appointments": [], "appointment_id": None}])
|
||||||
|
assert blue.model.index(0, 4).data() == "— · 时间 —"
|
||||||
|
blue.set_rows([{**row, "appointments": []}])
|
||||||
|
assert blue.model.index(0, 4).data() == "陈医生(演示) · 2026-09-05 09:00-09:30"
|
||||||
|
legacy.close()
|
||||||
|
blue.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_metrics_are_opt_in_and_expand_for_complete_content(application: QApplication) -> None:
|
||||||
|
blue = DiagnosisTableHost(tech_blue=True)
|
||||||
|
legacy = DiagnosisTableHost()
|
||||||
|
row = _row()
|
||||||
|
single = _row(502)
|
||||||
|
single["appointments"] = single["appointments"][:1]
|
||||||
|
long = copy.deepcopy(single)
|
||||||
|
long["id"] = long["diagnosis_id"] = 503
|
||||||
|
long["appointments"][0]["doctor_name"] = "超长完整医生姓名以及完整门诊部门需要换行" * 2
|
||||||
|
long["latest_appointment_channel_text"] = "完整保留的挂号渠道说明" * 4
|
||||||
|
long["has_prescription"] = 1
|
||||||
|
long["followup_time_text"] = "2026-10-15 09:00-09:30"
|
||||||
|
long["followup_doctor_name"] = "复诊医生姓名"
|
||||||
|
long["followup_rx_voided"] = True
|
||||||
|
blue.set_rows([row, single, long])
|
||||||
|
legacy.set_rows([row])
|
||||||
|
assert blue.model.columnCount() == 12
|
||||||
|
assert [blue.main.columnWidth(i) for i in range(10)] == [48, 70, 82, 102, 244, 90, 84, 90, 88, 122]
|
||||||
|
assert blue.main.horizontalHeader().height() == 41
|
||||||
|
assert legacy.main.horizontalHeader().height() == 39
|
||||||
|
assert blue.main.rowHeight(0) == 108
|
||||||
|
assert blue.main.rowHeight(1) == 72
|
||||||
|
assert blue.main.rowHeight(2) > 110
|
||||||
|
assert legacy.main.rowHeight(0) == 96
|
||||||
|
for index in range(3):
|
||||||
|
assert blue.main.rowHeight(index) == blue.fixed.rowHeight(index)
|
||||||
|
assert _blue_font(14).family() == body_family()
|
||||||
|
assert _blue_font(14, medium=True).family() == heading_family()
|
||||||
|
assert _blue_font(14, medium=True).weight() == QFont.Weight.Medium
|
||||||
|
assert _blue_font(13).pixelSize() == 13
|
||||||
|
blue.close()
|
||||||
|
legacy.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tech_blue", [False, True])
|
||||||
|
def test_geometry_has_no_minimum_height_feedback_and_tracks_both_scroll_axes(
|
||||||
|
application: QApplication, tech_blue: bool,
|
||||||
|
) -> None:
|
||||||
|
window = QWidget()
|
||||||
|
layout = QVBoxLayout(window)
|
||||||
|
layout.setContentsMargins(7, 7, 7, 7)
|
||||||
|
host = DiagnosisTableHost(tech_blue=tech_blue)
|
||||||
|
layout.addWidget(host, 1)
|
||||||
|
footer = QLabel("始终可达的分页")
|
||||||
|
footer.setFixedHeight(42)
|
||||||
|
layout.addWidget(footer)
|
||||||
|
host.set_rows([_row(i) for i in range(40)])
|
||||||
|
window.resize(1280, 640)
|
||||||
|
window.show()
|
||||||
|
_settle(application)
|
||||||
|
minimum = window.minimumSizeHint().height()
|
||||||
|
for width, height in ((780, 480), (1440, 780), (800, 540), (1280, 640)) * 3:
|
||||||
|
window.resize(width, height)
|
||||||
|
_settle(application)
|
||||||
|
# The previous implementation wrote the available height into the
|
||||||
|
# fixed child's minimum and enlarged every ancestor on each relayout.
|
||||||
|
assert window.size().height() == height
|
||||||
|
assert window.minimumSizeHint().height() == minimum
|
||||||
|
assert host.fixed.minimumHeight() == 0
|
||||||
|
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||||
|
assert host.main.viewport().mapTo(host, QPoint()).y() == host.fixed.viewport().mapTo(host, QPoint()).y()
|
||||||
|
main_scroll, fixed_scroll = host.main.verticalScrollBar(), host.fixed.verticalScrollBar()
|
||||||
|
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||||
|
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||||
|
_settle(application)
|
||||||
|
assert main_scroll.value() == fixed_scroll.value()
|
||||||
|
fixed_scroll.setValue(fixed_scroll.maximum())
|
||||||
|
_settle(application)
|
||||||
|
assert main_scroll.value() == fixed_scroll.value()
|
||||||
|
frozen_x = host.fixed.x()
|
||||||
|
host.main.horizontalScrollBar().setValue(host.main.horizontalScrollBar().maximum())
|
||||||
|
_settle(application)
|
||||||
|
assert host.fixed.x() == frozen_x
|
||||||
|
assert host.fixed.geometry().right() <= host.rect().right()
|
||||||
|
assert footer.mapTo(window, QPoint()).y() + footer.height() <= window.height()
|
||||||
|
window.close()
|
||||||
|
_settle(application)
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkbox_toggle_and_select_all_do_not_change_current_patient(application: QApplication) -> None:
|
||||||
|
host = DiagnosisTableHost(tech_blue=True)
|
||||||
|
host.resize(1300, 420)
|
||||||
|
host.set_rows([_row(501), _row(502)])
|
||||||
|
host.show()
|
||||||
|
_settle(application)
|
||||||
|
host.main.selectRow(1)
|
||||||
|
box = host.main.visualRect(host.model.index(1, 0)).center()
|
||||||
|
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||||
|
assert [row["id"] for row in host.selected_records()] == [502]
|
||||||
|
QTest.mouseClick(host.main.viewport(), Qt.MouseButton.LeftButton, pos=box)
|
||||||
|
assert host.selected_records() == []
|
||||||
|
assert host.main.currentRow() == 1
|
||||||
|
header = host.main.horizontalHeader()
|
||||||
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||||
|
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||||
|
assert host.main.currentRow() == 1
|
||||||
|
host.main.selectRow(0)
|
||||||
|
assert {row["id"] for row in host.selected_records()} == {501, 502}
|
||||||
|
QTest.mouseClick(header.viewport(), Qt.MouseButton.LeftButton, pos=QPoint(20, 20))
|
||||||
|
assert host.selected_records() == []
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_appointment_cancel_hit_targets_and_video_gates(application: QApplication) -> None:
|
||||||
|
host = DiagnosisTableHost(tech_blue=True, action_policy={
|
||||||
|
"view": True, "edit": True, "appointment_cancel": True, "video_call": True,
|
||||||
|
})
|
||||||
|
row = _row()
|
||||||
|
# Make the second entry cancellable and the first doctor wrap: hit regions
|
||||||
|
# must follow measured content, not a legacy fixed 42-pixel multiplier.
|
||||||
|
row["appointments"][0]["doctor_name"] = "医生与门诊信息完整保留" * 3
|
||||||
|
row["appointments"][1]["status"] = 4
|
||||||
|
host.set_rows([row])
|
||||||
|
host.resize(1300, 500)
|
||||||
|
host.show()
|
||||||
|
_settle(application)
|
||||||
|
received = []
|
||||||
|
host.appointment_cancel_requested.connect(lambda record, identifier: received.append((record["id"], identifier)))
|
||||||
|
overlay = host.main.indexWidget(host.model.index(0, 4))
|
||||||
|
buttons = overlay.findChildren(QToolButton)
|
||||||
|
assert len(buttons) == 2
|
||||||
|
entries, total = _blue_appointment_layout(row, overlay.width())
|
||||||
|
for position, button in enumerate(buttons):
|
||||||
|
assert button.y() == max(9, (overlay.height() - total) // 2) + entries[position][0]
|
||||||
|
assert button.geometry().bottom() < overlay.height()
|
||||||
|
button.click()
|
||||||
|
assert received == [(501, 2501), (501, 2401)]
|
||||||
|
more = host.fixed.indexWidget(host.model.index(0, 11)).findChild(QToolButton, "DiagnosisRowMore")
|
||||||
|
assert "取消挂号" not in [action.text() for action in more.menu().actions()]
|
||||||
|
assert not host.fixed.indexWidget(host.model.index(0, 10)).findChildren(QToolButton)
|
||||||
|
row["video_call_hint"] = {"state": "live"}
|
||||||
|
host.set_rows([row])
|
||||||
|
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||||
|
assert video is not None and video.isEnabled()
|
||||||
|
row["patient_id"] = 0
|
||||||
|
host.set_rows([row])
|
||||||
|
video = host.fixed.indexWidget(host.model.index(0, 10)).findChild(QToolButton)
|
||||||
|
assert video is not None and not video.isEnabled()
|
||||||
|
host.action_policy["appointment_cancel"] = False
|
||||||
|
host.set_rows([row])
|
||||||
|
assert host.main.indexWidget(host.model.index(0, 4)) is None
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_shell_navigation_has_no_fixed_height_stack_overflow(tmp_path: Path) -> None:
|
||||||
|
script = tmp_path / "shell_navigation.py"
|
||||||
|
script.write_text('''
|
||||||
|
import socket
|
||||||
|
from unittest.mock import patch
|
||||||
|
from PySide6.QtCore import Qt, QThreadPool
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from doctor_workstation.services import DemoDoctorRepository
|
||||||
|
from doctor_workstation.ui import ShellWindow, apply_theme
|
||||||
|
app = QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
def settle():
|
||||||
|
for _ in range(12):
|
||||||
|
QThreadPool.globalInstance().waitForDone(1000)
|
||||||
|
app.processEvents()
|
||||||
|
with patch.object(socket.socket, "connect", side_effect=RuntimeError("offline test")):
|
||||||
|
repo = DemoDoctorRepository()
|
||||||
|
session = repo.login(repo.DEMO_ACCOUNT, repo.DEMO_PASSWORD)
|
||||||
|
shell = ShellWindow(repo, {"session": session, "demo_mode": True}, permissions=session.permissions)
|
||||||
|
shell.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
||||||
|
shell.resize(1536, 960)
|
||||||
|
shell.show()
|
||||||
|
settle()
|
||||||
|
for route in ("consultations", "appointments", "consultations"):
|
||||||
|
shell.navigate(route)
|
||||||
|
settle()
|
||||||
|
host = shell.pages["consultations"].table_host
|
||||||
|
for width, height in ((1024, 640), (1536, 960), (1366, 768)):
|
||||||
|
shell.resize(width, height)
|
||||||
|
settle()
|
||||||
|
assert host.main.viewport().height() == host.fixed.viewport().height()
|
||||||
|
assert host.fixed.minimumHeight() == 0
|
||||||
|
assert shell.height() == height
|
||||||
|
shell.close()
|
||||||
|
settle()
|
||||||
|
print("native navigation geometry stable")
|
||||||
|
''', encoding="utf-8")
|
||||||
|
env = {**os.environ, "QT_QPA_PLATFORM": "offscreen", "DOCTOR_SMOKE_TEST": "1",
|
||||||
|
"PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
|
||||||
|
result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace", env=env, timeout=90)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
assert "native navigation geometry stable" in result.stdout
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Regressions for the blank strip between query results and pinned actions."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from doctor_workstation.ui.diagnosis_index_widgets import DiagnosisTableHost
|
||||||
|
from doctor_workstation.ui.theme import apply_theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(app):
|
||||||
|
for _ in range(4):
|
||||||
|
app.processEvents()
|
||||||
|
QTest.qWait(15)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(count):
|
||||||
|
return [{"id": i + 1, "patient_name": f"演示患者{i + 1}", "age": 55,
|
||||||
|
"gender": 1, "has_appointment": 0, "diagnosis_confirmed": 1}
|
||||||
|
for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("blue", [True, False])
|
||||||
|
def test_filter_results_fill_viewport_through_scrollbar_and_window_changes(application, blue):
|
||||||
|
host = DiagnosisTableHost(tech_blue=blue, action_policy={"view": True, "edit": True})
|
||||||
|
host.resize(1650, 680)
|
||||||
|
host.show()
|
||||||
|
for width, count in ((1650, 70), (1650, 2), (1900, 2), (900, 2),
|
||||||
|
(1900, 0), (1900, 2), (1366, 80), (1650, 2)):
|
||||||
|
host.resize(width, 680)
|
||||||
|
host.set_rows(rows(count))
|
||||||
|
settle(application)
|
||||||
|
main = host.main
|
||||||
|
viewport = main.viewport()
|
||||||
|
base_width = sum(host.LEFT_WIDTHS)
|
||||||
|
assert main.horizontalHeader().length() == max(base_width, viewport.width())
|
||||||
|
assert all(main.columnWidth(i) >= minimum for i, minimum in enumerate(host.LEFT_WIDTHS))
|
||||||
|
assert main.viewport().height() == host.fixed.viewport().height()
|
||||||
|
assert main.verticalScrollBar().maximum() == host.fixed.verticalScrollBar().maximum()
|
||||||
|
if viewport.width() >= base_width:
|
||||||
|
assert main.horizontalScrollBar().maximum() == 0
|
||||||
|
if count:
|
||||||
|
main.selectRow(1)
|
||||||
|
settle(application)
|
||||||
|
row = main.visualRect(host.model.index(1, 9))
|
||||||
|
point = QPoint(viewport.width() - 4, row.bottom() - 5)
|
||||||
|
# A real data cell, rather than the QTableView's blank background,
|
||||||
|
# must occupy the entire strip leading into the pinned actions.
|
||||||
|
assert main.indexAt(point).row() == 1
|
||||||
|
assert main.indexAt(point).column() == 9
|
||||||
|
if blue:
|
||||||
|
capture = viewport.grab()
|
||||||
|
scale = capture.devicePixelRatio()
|
||||||
|
pixel = QPoint(round(point.x() * scale), round(point.y() * scale))
|
||||||
|
assert capture.toImage().pixelColor(pixel).name() == "#eaf2ff"
|
||||||
|
else:
|
||||||
|
assert main.horizontalScrollBar().maximum() > 0
|
||||||
|
main.horizontalScrollBar().setValue(main.horizontalScrollBar().maximum())
|
||||||
|
settle(application)
|
||||||
|
assert main.visualRect(host.model.index(0, 9)).right() < viewport.width()
|
||||||
|
host.close()
|
||||||
|
host.deleteLater()
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import QPoint
|
||||||
|
from PySide6.QtGui import QPalette
|
||||||
|
from PySide6.QtWidgets import QApplication, QDialog, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui import apply_theme
|
||||||
|
from doctor_workstation.ui.appointment_drawer import APPOINTMENT_DRAWER_QSS, _SlotCard
|
||||||
|
from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS, DiagnosisTabWidget
|
||||||
|
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
|
||||||
|
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||||
|
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
apply_theme(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def settle(application: QApplication) -> None:
|
||||||
|
for _ in range(10):
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width", [600, 760, 860])
|
||||||
|
def test_prescription_form_keeps_date_gender_and_controls_readable(
|
||||||
|
application: QApplication, width: int
|
||||||
|
) -> None:
|
||||||
|
host = QWidget()
|
||||||
|
host.resize(width, 640)
|
||||||
|
host.show()
|
||||||
|
editor = PrescriptionEditorDialog(
|
||||||
|
SimpleNamespace(list_medicines=lambda **kwargs: {"lists": [], "count": 0}),
|
||||||
|
{"diagnosis_id": 501, "patient_name": "林晓岚", "prescription_type": "浓缩水丸"},
|
||||||
|
parent=host,
|
||||||
|
)
|
||||||
|
editor.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
display = editor.date_edit._display
|
||||||
|
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||||
|
for button in (editor.gender_male, editor.gender_female):
|
||||||
|
assert button.width() >= button.sizeHint().width()
|
||||||
|
controls = [editor.patient_name, editor.age, editor.date_edit, editor.prescription_type, editor.times_per_day]
|
||||||
|
assert len({control.height() for control in controls}) == 1
|
||||||
|
assert editor.body_content.width() == editor.body_scroll.viewport().width()
|
||||||
|
assert editor.body_scroll.horizontalScrollBar().maximum() == 0
|
||||||
|
footer_position = editor.footer.mapToGlobal(QPoint())
|
||||||
|
editor.body_scroll.verticalScrollBar().setValue(editor.body_scroll.verticalScrollBar().maximum())
|
||||||
|
settle(application)
|
||||||
|
assert editor.footer.mapToGlobal(QPoint()) == footer_position
|
||||||
|
assert editor.save_button.visibleRegion().boundingRect() == editor.save_button.rect()
|
||||||
|
# Changing prescription type keeps its conditional fields reachable after reflow.
|
||||||
|
editor.prescription_type.setCurrentText("饮片")
|
||||||
|
settle(application)
|
||||||
|
assert not editor._main_decoction_field.isHidden()
|
||||||
|
assert editor._main_bag_field.isHidden()
|
||||||
|
if width == 860:
|
||||||
|
for resized_width in (600, 860):
|
||||||
|
editor.resize(resized_width, 640)
|
||||||
|
settle(application)
|
||||||
|
assert display.width() >= display.fontMetrics().horizontalAdvance(display.text()) + 15
|
||||||
|
assert editor._main_decoction_field.width() >= editor.need_decoction.minimumSizeHint().width() + 104
|
||||||
|
finally:
|
||||||
|
editor.close()
|
||||||
|
host.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_overflow_rail_leaves_selected_tab_underline_visible(application: QApplication) -> None:
|
||||||
|
tabs = DiagnosisTabWidget()
|
||||||
|
tabs.setObjectName("DiagnosisDrawerTabs")
|
||||||
|
tabs.setStyleSheet(DIAGNOSIS_QSS)
|
||||||
|
for title in ("病历", "医生备注", "日常记录", "处方", "业务订单", "视频录制回放", "聊天", "指派医助记录", "挂号记录"):
|
||||||
|
tabs.addTab(QWidget(), title)
|
||||||
|
tabs.resize(614, 480)
|
||||||
|
tabs.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
selected = tabs.tabBar().tabRect(tabs.currentIndex())
|
||||||
|
selected.moveTopLeft(tabs.tabBar().pos() + selected.topLeft())
|
||||||
|
assert tabs.tab_scrollbar.isVisible()
|
||||||
|
assert not selected.intersects(tabs.tab_scrollbar.geometry())
|
||||||
|
tabs.tab_scrollbar.setValue(tabs.count() - 1)
|
||||||
|
assert tabs.currentIndex() == tabs.count() - 1
|
||||||
|
finally:
|
||||||
|
tabs.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_appointment_slot_labels_follow_checked_and_disabled_states(application: QApplication) -> None:
|
||||||
|
dialog = QDialog()
|
||||||
|
dialog.setObjectName("AppointmentDrawerOverlay")
|
||||||
|
dialog.setStyleSheet(APPOINTMENT_DRAWER_QSS)
|
||||||
|
layout = QVBoxLayout(dialog)
|
||||||
|
card = _SlotCard("09:00-09:30", "可约")
|
||||||
|
card.setProperty("appointmentSlot", True)
|
||||||
|
card.setProperty("availability", "available")
|
||||||
|
card.setCheckable(True)
|
||||||
|
layout.addWidget(card)
|
||||||
|
dialog.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
for checked, expected in ((True, "#ffffff"), (False, "#1a1c1f")):
|
||||||
|
card.setChecked(checked)
|
||||||
|
settle(application)
|
||||||
|
assert card.time_label.palette().color(QPalette.ColorRole.WindowText).name() == expected
|
||||||
|
card.setChecked(True)
|
||||||
|
card.setEnabled(False)
|
||||||
|
settle(application)
|
||||||
|
assert card.time_label.palette().color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText).name() == "#8e8f90"
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_report_columns_keep_headings_at_the_same_text_baseline(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
def immediate(function, **kwargs):
|
||||||
|
kwargs["on_success"](function())
|
||||||
|
if kwargs.get("on_finished"):
|
||||||
|
kwargs["on_finished"]()
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_module, "run_async", immediate)
|
||||||
|
report = {"summary": "复核参考", "possible_symptoms": ["乏力", "纳差", "睡眠不安"], "main_indications": "脾气不足", "efficacy": ["健脾", "益气", "养阴"], "suitable_people": ["需由医师辨证确认"]}
|
||||||
|
repository = SimpleNamespace(list_prescription_template_ai_reports=lambda template_id: {"prescription_id": template_id, "reports": [{"report_id": 1, "model_key": "qwen", "report": report}]})
|
||||||
|
dialog = PrescriptionAiReportDialog(repository, PermissionSet(["*"]))
|
||||||
|
dialog.open_for({"id": 1, "prescription_name": "测试方", "herbs": []})
|
||||||
|
dialog.show()
|
||||||
|
settle(application)
|
||||||
|
try:
|
||||||
|
labels = {label.text(): label for label in dialog.findChildren(QLabel) if label.objectName() == "PrescriptionAiSectionTitle"}
|
||||||
|
for left, right in (("可能症状与证候", "主治方向"), ("主要功效", "可能适用人群")):
|
||||||
|
# QLabel vertically centers text: compare the centers, not just widget origins.
|
||||||
|
centers = [labels[text].mapTo(dialog, labels[text].rect().center()).y() for text in (left, right)]
|
||||||
|
assert abs(centers[0] - centers[1]) <= 1
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtTest import QTest
|
||||||
|
from PySide6.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from doctor_workstation.core import PermissionSet
|
||||||
|
from doctor_workstation.ui.dialogs import ai_consult_picker, diagnosis, prescription
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def application() -> QApplication:
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def run(function: Any, *, on_success: Any, on_error: Any, on_finished: Any = None) -> None:
|
||||||
|
try:
|
||||||
|
result = function()
|
||||||
|
except Exception as error:
|
||||||
|
on_error(error)
|
||||||
|
else:
|
||||||
|
on_success(result)
|
||||||
|
if on_finished:
|
||||||
|
on_finished()
|
||||||
|
|
||||||
|
for module in (ai_consult_picker, diagnosis, prescription):
|
||||||
|
monkeypatch.setattr(module, "run_async", run)
|
||||||
|
|
||||||
|
|
||||||
|
class ListRepository:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
self.fail_page = 0
|
||||||
|
|
||||||
|
def _list(self, **filters: Any) -> dict[str, Any]:
|
||||||
|
self.calls.append(filters)
|
||||||
|
page, size = filters["page_no"], filters["page_size"]
|
||||||
|
if page == self.fail_page:
|
||||||
|
raise RuntimeError("暂时无法加载")
|
||||||
|
start = (page - 1) * size + 1
|
||||||
|
return {
|
||||||
|
"lists": [
|
||||||
|
{
|
||||||
|
"id": number,
|
||||||
|
"diagnosis_id": number,
|
||||||
|
"patient_id": 1000 + number,
|
||||||
|
"patient_name": f"患者{number}",
|
||||||
|
"prescription_id": 77,
|
||||||
|
"prescription_name": f"处方{number}",
|
||||||
|
"herbs": [{"name": "白术", "dosage": 10}],
|
||||||
|
"order_no": f"ORDER{number}",
|
||||||
|
}
|
||||||
|
for number in range(start, min(start + size, 46))
|
||||||
|
],
|
||||||
|
"count": 45,
|
||||||
|
}
|
||||||
|
|
||||||
|
list_prescription_templates = _list
|
||||||
|
list_prescription_orders = _list
|
||||||
|
list_ai_patient_options = _list
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_to_bottom(table: Any) -> None:
|
||||||
|
scrollbar = table.verticalScrollBar()
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
QTest.qWait(70)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["template", "order", "ai"])
|
||||||
|
def test_scroll_appends_and_retains_selected_business_identity(
|
||||||
|
application: QApplication, immediate_async: None, kind: str
|
||||||
|
) -> None:
|
||||||
|
repository = ListRepository()
|
||||||
|
if kind == "template":
|
||||||
|
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||||
|
controller = dialog.infinite_list
|
||||||
|
elif kind == "order":
|
||||||
|
dialog = prescription.PrescriptionOrderListDialog(repository, prescription_id=77)
|
||||||
|
controller = dialog.infinite_list
|
||||||
|
else:
|
||||||
|
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||||
|
controller = dialog.pager
|
||||||
|
dialog.resize(980, 520)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
page_size = controller.page_size
|
||||||
|
assert dialog.table.rowCount() == page_size
|
||||||
|
assert not any(
|
||||||
|
button.text() in {"上一页", "下一页"} for button in dialog.findChildren(QPushButton)
|
||||||
|
)
|
||||||
|
dialog.table.selectRow(2)
|
||||||
|
scroll_to_bottom(dialog.table)
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert dialog.table.rowCount() == page_size * 2
|
||||||
|
assert dialog.table.currentRow() == 2
|
||||||
|
assert all(
|
||||||
|
dialog.table.item(row, column).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||||
|
for row in range(dialog.table.rowCount())
|
||||||
|
for column in range(dialog.table.columnCount())
|
||||||
|
)
|
||||||
|
repository.fail_page = 3
|
||||||
|
scroll_to_bottom(dialog.table)
|
||||||
|
assert controller.retry_button.isVisible()
|
||||||
|
assert dialog.table.rowCount() == page_size * 2
|
||||||
|
repository.fail_page = 0
|
||||||
|
controller.retry_button.click()
|
||||||
|
assert dialog.table.rowCount() == 45
|
||||||
|
assert not controller.has_more
|
||||||
|
assert dialog.table.currentRow() == 2
|
||||||
|
if kind == "template":
|
||||||
|
dialog.accept()
|
||||||
|
assert dialog.selected_template()["id"] == 3
|
||||||
|
elif kind == "ai":
|
||||||
|
dialog.accept()
|
||||||
|
assert dialog.selected_target().diagnosis_id == 3
|
||||||
|
assert dialog.selected_target().patient_id == 1003
|
||||||
|
else:
|
||||||
|
assert dialog.table.item(2, 0).data(Qt.ItemDataRole.UserRole)["id"] == 3
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_query_supersedes_pending_load_and_keeps_creator_scope(
|
||||||
|
application: QApplication, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
pending: list[tuple[Any, dict[str, Any]]] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
prescription, "run_async", lambda function, **callbacks: pending.append((function, callbacks))
|
||||||
|
)
|
||||||
|
repository = ListRepository()
|
||||||
|
dialog = prescription.TemplateImportDialog(repository, 42)
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
dialog.name_edit.setText("白术")
|
||||||
|
dialog.formula_combo.setCurrentIndex(1)
|
||||||
|
dialog.search()
|
||||||
|
assert len(pending) == 2
|
||||||
|
latest_function, latest_callbacks = pending[1]
|
||||||
|
latest_callbacks["on_success"](latest_function())
|
||||||
|
old_function, old_callbacks = pending[0]
|
||||||
|
old_callbacks["on_success"](old_function())
|
||||||
|
old_callbacks["on_error"](RuntimeError("过期错误"))
|
||||||
|
assert dialog.table.rowCount() == 15
|
||||||
|
assert not dialog.banner.isVisible()
|
||||||
|
assert repository.calls[0] == {
|
||||||
|
"page_no": 1,
|
||||||
|
"page_size": 15,
|
||||||
|
"prescription_name": "白术",
|
||||||
|
"formula_type": "主方",
|
||||||
|
"prescribing_creator_id": 42,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_picker_continues_when_first_page_has_no_valid_diagnosis_targets(
|
||||||
|
application: QApplication, immediate_async: None
|
||||||
|
) -> None:
|
||||||
|
class Repository(ListRepository):
|
||||||
|
def list_ai_patient_options(self, **filters: Any) -> dict[str, Any]:
|
||||||
|
result = self._list(**filters)
|
||||||
|
if filters["page_no"] == 1:
|
||||||
|
for row in result["lists"]:
|
||||||
|
row["id"] = -row["id"]
|
||||||
|
row["diagnosis_id"] = row["id"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
repository = Repository()
|
||||||
|
dialog = ai_consult_picker.AiConsultTargetDialog(repository, PermissionSet(["*"]))
|
||||||
|
dialog.show()
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
QTest.qWait(100)
|
||||||
|
assert [call["page_no"] for call in repository.calls] == [1, 2]
|
||||||
|
assert dialog.table.rowCount() == 20
|
||||||
|
assert dialog.table.item(0, 3).text() == "21"
|
||||||
|
assert dialog.table.isVisible()
|
||||||
|
assert not dialog.empty_state.isVisible()
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnosis_readonly_orders_scroll_and_switching_patient_resets_scope(
|
||||||
|
application: QApplication, immediate_async: None
|
||||||
|
) -> None:
|
||||||
|
repository = ListRepository()
|
||||||
|
dialog = diagnosis.DiagnosisDialog(
|
||||||
|
repository, permissions=PermissionSet(["tcm.diagnosis/patientOrders"])
|
||||||
|
)
|
||||||
|
dialog.open_for(
|
||||||
|
501, authoritative_detail={"id": 501, "patient_id": 301, "patient_name": "患者一"}
|
||||||
|
)
|
||||||
|
application.processEvents()
|
||||||
|
try:
|
||||||
|
table = dialog._table_registry["orders"][0]
|
||||||
|
dialog.readonly_scroll.ensureWidgetVisible(table)
|
||||||
|
assert table.rowCount() == 10
|
||||||
|
assert dialog.orders_list.parentWidget() is dialog._readonly_sections["orders"]
|
||||||
|
table.selectRow(2)
|
||||||
|
scroll_to_bottom(table)
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
assert table.currentRow() == 2
|
||||||
|
assert table.item(2, 0).data(Qt.ItemDataRole.CheckStateRole) is None
|
||||||
|
assert repository.calls[-1] == {
|
||||||
|
"page_no": 2,
|
||||||
|
"page_size": 10,
|
||||||
|
"patient_id": 301,
|
||||||
|
"context_diagnosis_id": 501,
|
||||||
|
"scene": "diagnosis_edit",
|
||||||
|
}
|
||||||
|
dialog.open_for(
|
||||||
|
502, authoritative_detail={"id": 502, "patient_id": 302, "patient_name": "患者二"}
|
||||||
|
)
|
||||||
|
assert table.rowCount() == 10
|
||||||
|
assert repository.calls[-1]["page_no"] == 1
|
||||||
|
assert repository.calls[-1]["patient_id"] == 302
|
||||||
|
assert repository.calls[-1]["context_diagnosis_id"] == 502
|
||||||
|
finally:
|
||||||
|
dialog.close()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user