Compare commits

..
Author SHA1 Message Date
long 9a6146e25a docs: record restored EJ medicine count 2026-09-10 12:19:53 +08:00
long 068f3f8b06 docs: record EJ medicine sync execution 2026-09-10 12:17:06 +08:00
long 6862547a9d docs: record EJ sync production deployment 2026-09-10 12:12:06 +08:00
long 192c31fb8c docs: verify EJ medicine restore behavior 2026-09-10 11:48:56 +08:00
long eec3204408 docs: define EJ medicine restore semantics 2026-09-10 11:43:22 +08:00
long d3d66970b2 docs: finalize EJ sync branch evidence 2026-09-10 11:11:26 +08:00
long 2a0e49dbc9 docs: record EJ incremental sync verification 2026-09-10 11:11:06 +08:00
long 17e9e7b6b6 feat: add non-destructive EJ medicine sync 2026-09-10 11:09:26 +08:00
Your Name 27fbef9321 gengx 2026-09-09 15:52:46 +08:00
Your Name cb10e75ead 更新 2026-09-09 15:47:48 +08:00
Your Name bd5d5c5f08 更新 2026-09-09 14:08:11 +08:00
Your Name 083fd857e8 更新 2026-09-09 14:04:09 +08:00
Your Name 9be2e18ea6 更新 2026-09-09 12:25:20 +08:00
Your Name 691c749f34 Merge branch 'master' into UI-09-03 2026-09-09 12:19:39 +08:00
Your Name 40319eea41 更新 2026-09-09 12:18:17 +08:00
gr c1f7287331 igengx 2026-09-09 10:48:05 +08:00
gr 9d91ff90ae ;rgb:0000/0000/0000 2026-09-09 10:02:43 +08:00
gr 1b8e166b33 i更新 2026-09-09 09:58:12 +08:00
Your Name 0cea43b027 Merge branch 'master' into UI-09-03 2026-09-08 16:16:25 +08:00
long d0e2422155 revert(iam): 统一登录 - 撤销 IAM Hub 接入 2026-09-08 16:14:59 +08:00
long 9206d5ea62 docs: record ZYT IAM integration evidence 2026-09-08 13:42:22 +08:00
long bd0a3d3b96 fix: complete IAM login after OIDC callback 2026-09-08 13:34:18 +08:00
long a5f4c4472d feat: integrate ZYT admin with IAM Hub 2026-09-08 13:26:41 +08:00
Your Name 9ef6eb8d67 更新 2026-09-07 12:30:42 +08:00
Your Name d5164b7369 更新 2026-09-07 10:07:47 +08:00
Your Name cf3fbdc5ef 更新 2026-09-03 18:05:32 +08:00
Your Name 928f72ec3d 更新 2026-09-03 16:03:41 +08:00
Your Name b4c11881b4 更新 2026-09-01 18:12:39 +08:00
Your Name 58ffde808f 更新 2026-09-01 17:49:29 +08:00
Your Name 486acc465d 更新 2026-09-01 17:18:42 +08:00
gr 5e22d423d4 更新 2026-08-28 14:07:30 +08:00
gr 7f1ed49cc8 更新 2026-08-28 14:06:56 +08:00
737 changed files with 49968 additions and 18078 deletions
+1
View File
@@ -32,3 +32,4 @@ app/.test-tmp-stream/
/.spool /.spool
TUICallKit-Vue3/.env TUICallKit-Vue3/.env
/.codegraph /.codegraph
app/artifacts/
+475 -423
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
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,/)
assert.match(read(caller), /appointmentId: Number\(row\.id\)/)
}
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',
'views/first_visit/my_patients/index.vue', 'views/tcm/diagnosis/index.vue', 'views/tcm/diagnosis/index_h5.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 })
+5 -2
View File
@@ -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
+64
View File
@@ -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 })
} }
@@ -317,6 +362,25 @@ export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 }) return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
} }
export type WecomPromotionMemberSyncStatus = 'synced' | 'pending' | 'failed' | 'blocked'
export interface WecomPromotionMemberSyncResult {
pool_id: number
sync_status: WecomPromotionMemberSyncStatus
sync_error: string
sync_queued: boolean
range_userids: string[]
range_department_ids: string[]
}
export function wecomPromotionSyncMemberRange(params: { pool_id: number }) {
return request.post<WecomPromotionMemberSyncResult>({
url: '/firstvisit.wecomPromotion/syncMemberRange',
params,
timeout: 120000
}, { ignoreCancelToken: true, isOpenRetry: false })
}
export function wecomPromotionRemoteLinkDetail(params: { id: number }) { export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params }) return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
} }
+5
View File
@@ -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' })
+49 -6
View File
@@ -275,14 +275,52 @@ export function generateOrderQrcode(params: any) {
// ========== IM / 企业微信聊天记录 ========== // ========== IM / 企业微信聊天记录 ==========
/** 腾讯云 IM 单聊漫游消息(诊单维度:患者 patient_* 与医生 doctor_* */ export interface ImChatMessagesResponse {
export function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }) { lists: any[]
return request.get({ url: '/tcm.diagnosis/getImChatMessages', params }) patient_im_id?: string
patient_name?: string
sync_error?: string
} }
/** 触发后台异步同步:从腾讯云 IM 拉取诊单聊天记录入归档表,请求即返回 */ export interface ImChatSyncProgress {
export function triggerImChatSync(data: { diagnosis_id: number }) { sync_token?: string
return request.post({ url: '/tcm.diagnosis/triggerImChatSync', data }) completed: boolean
phase?: 'checking_accounts' | 'syncing' | 'completed'
checked_accounts?: number
candidate_accounts?: number
skipped_accounts?: number
inserted: number
processed_peers: number
total_peers: number
error?: string
errors?: string[]
}
/** 保留接口错误原因,并由面板决定何时提示同步完成。 */
function unwrapImChatResponse<T>(response: { code: number; data: T; msg?: string }): T {
if (Number(response?.code) !== 1) {
const detail = response?.data as any
throw new Error(response?.msg || detail?.error || detail?.message || '聊天记录请求失败')
}
return response.data
}
/** 当前患者所有诊单的 IM 归档消息。 */
export async function getImChatMessages(params: { diagnosis_id: number; only_archived?: 0 | 1 }): Promise<ImChatMessagesResponse> {
const response = await request.get(
{ url: '/tcm.diagnosis/getImChatMessages', params, timeout: 30000 },
{ isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
)
return unwrapImChatResponse<ImChatMessagesResponse>(response)
}
/** 同步一页云端消息,继续传入返回的 token,直到 completed 为 true。 */
export async function triggerImChatSync(data: { diagnosis_id: number; sync_token?: string; scope?: 'current' }): Promise<ImChatSyncProgress> {
const response = await request.post(
{ url: '/tcm.diagnosis/triggerImChatSync', data, timeout: 30000 },
{ isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }
)
return unwrapImChatResponse<ImChatSyncProgress>(response)
} }
// 获取企业微信聊天记录 // 获取企业微信聊天记录
@@ -424,6 +462,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
+138 -9
View File
@@ -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>
@@ -52,18 +55,19 @@
<ImagePicker /> <ImagePicker />
<FilePicker /> <FilePicker />
<!-- 仅在 TUICallKit 初始化成功后展示音视频入口避免初始化登录未完成错误 --> <!-- 仅在 TUICallKit 初始化成功后展示音视频入口避免初始化登录未完成错误 -->
<AudioCallPicker v-if="isCallReady" /> <AudioCallPicker v-if="isCallReady && canAudioCall" />
<!-- 视频接通后 doBindCallRoom 后端 bindCallRoom 会调 CreateCloudRecordingRecordParams.RecordMode=2混流/合流 TrtcCloudRecordingService --> <!-- 视频接通后 doBindCallRoom 后端 bindCallRoom 会调 CreateCloudRecordingRecordParams.RecordMode=2混流/合流 TrtcCloudRecordingService -->
<VideoCallPicker v-if="isCallReady" /> <VideoCallPicker v-if="isCallReady && canVideoCall" />
<!-- 群组视频通话基于 TUICallKitServer.calls多人通话入口 --> <!-- 群组视频通话基于 TUICallKitServer.calls多人通话入口 -->
<el-button <el-button
v-if="isCallReady" v-if="isCallReady && canVideoCall"
size="small" size="small"
type="primary" type="primary"
@click="startGroupVideoCall" @click="startGroupVideoCall"
> >
群视频 群视频
</el-button> </el-button>
<span v-if="appointmentType === 'text'" class="text-consultation-hint">图文问诊不支持音视频通话</span>
</div> </div>
</template> </template>
</MessageInput> </MessageInput>
@@ -135,11 +139,15 @@ import {
bindCallRoom, bindCallRoom,
endCall, endCall,
getCallSignature, getCallSignature,
triggerImChatSync,
startCall startCall
} from '@/api/tcm' } from '@/api/tcm'
import { CallLocalRecorder } from '@/utils/call-local-recorder' import { CallLocalRecorder } from '@/utils/call-local-recorder'
import { createImChatArchiveTrigger } from '@/utils/im-chat-archive-trigger'
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, canAppointmentVideoCall } from '@/utils/appointment-type'
import { checkAppointmentOutgoingCall, registerAppointmentCallGuard } from '@/utils/appointment-call-guard'
import { import {
formatTUICallUserError, formatTUICallUserError,
getTUICallPackageArrearsMessage, getTUICallPackageArrearsMessage,
@@ -180,6 +188,18 @@ 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 appointmentId = ref(0)
const confirmedTypeLabel = ref('')
const appointmentTypeLabel = computed(() => confirmedTypeLabel.value || appointmentTypeDescription(appointmentType.value))
const serverAllowsVideo = ref(false)
const serverAllowsAudio = ref(false)
const callDisabledReason = ref('正在确认挂号类型')
const canVideoCall = computed(() => serverAllowsVideo.value && canAppointmentVideoCall(appointmentType.value))
const canAudioCall = computed(() => serverAllowsAudio.value && (canAppointmentVideoCall(appointmentType.value) || appointmentType.value === 'phone'))
let releaseAppointmentCallGuard: (() => void) | undefined
let chatContextVersion = 0
let outgoingCallType = 2
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('正在初始化...')
@@ -191,6 +211,68 @@ const { login, logout } = useLoginState()
const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState() const { setActiveConversation, createC2CConversation, activeConversation } = useConversationListState()
const userStore = useUserStore() const userStore = useUserStore()
const syncChatArchive = createImChatArchiveTrigger(triggerImChatSync, () => {
console.warn('[chat-dialog] 聊天记录归档未完成,可在诊单聊天记录中重试同步')
})
let chatArchiveTimer: ReturnType<typeof setInterval> | undefined
let chatArchiveDebounce: ReturnType<typeof setTimeout> | undefined
function queueChatArchive() {
const id = diagnosisId.value
if (!visible.value || !isReady.value || !id) return
if (chatArchiveDebounce) clearTimeout(chatArchiveDebounce)
chatArchiveDebounce = setTimeout(() => {
chatArchiveDebounce = undefined
void syncChatArchive(id)
}, 500)
}
function stopChatArchiveWatch(flush = false) {
if (chatArchiveTimer) clearInterval(chatArchiveTimer)
if (chatArchiveDebounce) clearTimeout(chatArchiveDebounce)
chatArchiveTimer = undefined
chatArchiveDebounce = undefined
if (flush && diagnosisId.value && isReady.value) void syncChatArchive(diagnosisId.value)
TUIChatEngine?.chat?.off?.(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, queueChatArchive)
}
function startChatArchiveWatch() {
stopChatArchiveWatch()
TUIChatEngine?.chat?.on?.(TencentCloudChat.EVENT.CONVERSATION_LIST_UPDATED, queueChatArchive)
chatArchiveTimer = setInterval(queueChatArchive, 15000)
queueChatArchive()
}
function syncAppointmentCallPolicy(res: {
appointment_type?: string | null; appointment_type_desc?: string;
appointment_id?: number; can_video_call?: boolean; can_audio_call?: boolean; call_disabled_reason?: string
}) {
if ('appointment_type' in res) appointmentType.value = res.appointment_type
confirmedTypeLabel.value = res.appointment_type_desc || ''
serverAllowsVideo.value = res.can_video_call === true
serverAllowsAudio.value = res.can_audio_call === true
callDisabledReason.value = res.call_disabled_reason || '当前挂号不支持该通话方式'
if (Number(res.appointment_id) > 0) appointmentId.value = Number(res.appointment_id)
}
async function prepareAppointmentCall(callType: number) {
const contextVersion = chatContextVersion
const allowed = () => callType === 1 ? canAudioCall.value : canVideoCall.value
if (!visible.value || !allowed()) {
const message = appointmentType.value === 'text' ? '图文问诊不支持音视频通话' : callDisabledReason.value
feedback.msgWarning(message)
throw new Error(message)
}
// 发起前重新读取服务端挂号,防止打开聊天后问诊类型已被修改。
const res = await getCallSignature({
patient_id: patientId.value, diagnosis_id: diagnosisId.value, appointment_id: appointmentId.value
})
if (!visible.value || chatContextVersion !== contextVersion) throw new Error('当前问诊已切换,请重新发起通话')
syncAppointmentCallPolicy(res)
if (!allowed()) {
feedback.msgWarning(callDisabledReason.value)
throw new Error(callDisabledReason.value)
}
outgoingCallType = callType
}
/** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */ /** TUICallKit CallStatus(与 @tencentcloud/call-uikit-vue 一致) */
const CALL_STATUS_IDLE = 'idle' const CALL_STATUS_IDLE = 'idle'
const CALL_STATUS_CALLING = 'calling' const CALL_STATUS_CALLING = 'calling'
@@ -622,6 +704,7 @@ function installTUICallKitRoomHooks() {
const original = server[methodName] const original = server[methodName]
if (typeof original !== 'function') return if (typeof original !== 'function') return
server[methodName] = async function patched(...args: unknown[]) { server[methodName] = async function patched(...args: unknown[]) {
await checkAppointmentOutgoingCall(args[0])
const result = await original.apply(server, args) const result = await original.apply(server, args)
await flushAndBindRoomAfterTUICallApi(methodName) await flushAndBindRoomAfterTUICallApi(methodName)
return result return result
@@ -689,7 +772,8 @@ async function ensureCallRecordStarted() {
await startCall({ await startCall({
diagnosis_id: diagnosisId.value, diagnosis_id: diagnosisId.value,
patient_id: patientId.value, patient_id: patientId.value,
call_type: 2 appointment_id: appointmentId.value,
call_type: outgoingCallType
}) })
} catch (e) { } catch (e) {
console.warn('[chat-dialog] startCall 记录失败', e) console.warn('[chat-dialog] startCall 记录失败', e)
@@ -933,6 +1017,9 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
stopChatArchiveWatch(true)
chatContextVersion++
releaseAppointmentCallGuard?.()
clearLocalRecordingStartTimer() clearLocalRecordingStartTimer()
localCallRecorder.reset() localCallRecorder.reset()
tearDownCallRoomBinding?.() tearDownCallRoomBinding?.()
@@ -995,7 +1082,8 @@ watch(() => activeConversation.value, async (newConversation) => {
try { try {
const res = await getCallSignature({ const res = await getCallSignature({
patient_id: newConversation.userProfile.userID.replace("patient_", ""), patient_id: newConversation.userProfile.userID.replace("patient_", ""),
diagnosis_id: diagnosisId.value diagnosis_id: diagnosisId.value,
appointment_id: appointmentId.value
}) })
syncLochostVodFromSignature(res) syncLochostVodFromSignature(res)
patientUserId.value = res.patientUserId patientUserId.value = res.patientUserId
@@ -1010,13 +1098,23 @@ 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; appointmentId?: number; appointmentType?: string | null }) => {
stopChatArchiveWatch(true)
const contextVersion = ++chatContextVersion
releaseAppointmentCallGuard?.()
releaseAppointmentCallGuard = registerAppointmentCallGuard(prepareAppointmentCall)
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
appointmentId.value = Number(data.appointmentId || 0)
confirmedTypeLabel.value = ''
serverAllowsVideo.value = false
serverAllowsAudio.value = false
callDisabledReason.value = '正在确认挂号类型'
patientId.value = data.patientId patientId.value = data.patientId
diagnosisId.value = data.diagnosisId || null diagnosisId.value = data.diagnosisId || null
error.value = '' error.value = ''
@@ -1029,8 +1127,11 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 获取医生的签名信息 // 获取医生的签名信息
const res = await getCallSignature({ const res = await getCallSignature({
patient_id: data.patientId, patient_id: data.patientId,
diagnosis_id: data.diagnosisId || 0 diagnosis_id: data.diagnosisId || 0,
appointment_id: appointmentId.value
}) })
if (contextVersion !== chatContextVersion || !visible.value) return
syncAppointmentCallPolicy(res)
syncLochostVodFromSignature(res) syncLochostVodFromSignature(res)
assistant_ids.value = res.assistant_id assistant_ids.value = res.assistant_id
console.log('后端返回的签名数据:', res) console.log('后端返回的签名数据:', res)
@@ -1116,7 +1217,9 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 等待 UIKit 初始化完成后创建并激活会话 // 等待 UIKit 初始化完成后创建并激活会话
setTimeout(async () => { setTimeout(async () => {
if (contextVersion !== chatContextVersion || !visible.value) return
isReady.value = true isReady.value = true
startChatArchiveWatch()
// 使用 nextTick 确保组件已渲染 // 使用 nextTick 确保组件已渲染
await nextTick() await nextTick()
@@ -1157,6 +1260,10 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
// 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者 // 群组视频通话(多人通话):当前医生作为发起方,默认先邀请当前会话患者
const startGroupVideoCall = async () => { const startGroupVideoCall = async () => {
if (!canVideoCall.value) {
feedback.msgWarning(appointmentType.value === 'text' ? '图文问诊不支持视频通话' : callDisabledReason.value)
return
}
if (!isCallReady.value) { if (!isCallReady.value) {
feedback.msgWarning('通话服务初始化中,请稍后再试') feedback.msgWarning('通话服务初始化中,请稍后再试')
return return
@@ -1173,7 +1280,8 @@ const startGroupVideoCall = async () => {
console.warn('assistant_id 为空,尝试重新获取') console.warn('assistant_id 为空,尝试重新获取')
const res = await getCallSignature({ const res = await getCallSignature({
patient_id: patientId.value!, patient_id: patientId.value!,
diagnosis_id: diagnosisId.value diagnosis_id: diagnosisId.value,
appointment_id: appointmentId.value
}) })
syncLochostVodFromSignature(res) syncLochostVodFromSignature(res)
assistant_ids.value = res.assistant_id assistant_ids.value = res.assistant_id
@@ -1353,6 +1461,9 @@ const onHeaderMouseUp = () => {
} }
const handleClose = async () => { const handleClose = async () => {
stopChatArchiveWatch(true)
chatContextVersion++
releaseAppointmentCallGuard?.()
unbindImHangupMessageListener() unbindImHangupMessageListener()
// 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示) // 关闭时如有通话中/拨通中,先结束本地录制再挂断(不依赖悬浮窗是否显示)
if (isCallReady.value) { if (isCallReady.value) {
@@ -1426,6 +1537,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;
@@ -1510,6 +1633,12 @@ defineExpose({ open })
} }
/* 消息工具栏样式 */ /* 消息工具栏样式 */
.text-consultation-hint {
color: #909399;
font-size: 12px;
line-height: 20px;
}
.message-toolbar { .message-toolbar {
display: flex; display: flex;
gap: 8px; gap: 8px;
+23 -8
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
type OutgoingCallGuard = (callType: number) => Promise<void>
// 通话 SDK 为全局单例;使用当前窗口的校验器,避免首个组件闭包保留旧患者类型。
let activeGuard: OutgoingCallGuard | undefined
export function registerAppointmentCallGuard(guard: OutgoingCallGuard): () => void {
activeGuard = guard
return () => {
if (activeGuard === guard) activeGuard = undefined
}
}
export async function checkAppointmentOutgoingCall(params: unknown): Promise<void> {
const guard = activeGuard
if (!guard) throw new Error('请先打开本次挂号的聊天窗口')
const type = params && typeof params === 'object' && 'type' in params ? Number(params.type) : 2
await guard(type === 1 ? 1 : 2)
if (activeGuard !== guard) throw new Error('当前问诊已切换,请重新发起通话')
}
+13
View File
@@ -0,0 +1,13 @@
/** 展示当前挂号的问诊方式;旧记录的空值默认视频。 */
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 '未知'
}
/** 图文及未知问诊方式不提供视频;只有已存在挂号的历史空值沿用视频。 */
export function canAppointmentVideoCall(value?: string | null): boolean {
return value == null || value.trim() === '' || value === 'video'
}
@@ -0,0 +1,42 @@
/** 合并聊天窗口的收发事件;每次同步只接受后端读取的云端消息,不上报客户端正文。 */
export function createImChatArchiveTrigger(
syncPage: (params: { diagnosis_id: number; scope: 'current'; sync_token?: string }) => Promise<{
sync_token?: string; completed: boolean; errors?: string[]
}>,
onError: (error: unknown) => void = () => {}
) {
const jobs = new Map<number, { again: boolean; promise: Promise<void> }>()
return (diagnosisId: number): Promise<void> => {
if (!Number.isInteger(diagnosisId) || diagnosisId <= 0) return Promise.resolve()
const pending = jobs.get(diagnosisId)
if (pending) {
pending.again = true
return pending.promise
}
const job = { again: false, promise: Promise.resolve() }
jobs.set(diagnosisId, job)
job.promise = (async () => {
try {
do {
job.again = false
let token: string | undefined
for (;;) {
const result = await syncPage({ diagnosis_id: diagnosisId, scope: 'current', sync_token: token })
if (result.completed) {
if (result.errors?.length) throw new Error(result.errors.join(''))
break
}
if (!result.sync_token) throw new Error('聊天记录同步未返回进度')
token = result.sync_token
}
// 同步期间收到新消息时再追一次,覆盖新消息晚于本轮首页的情况。
} while (job.again)
} catch (error) {
onError(error)
} finally {
jobs.delete(diagnosisId)
}
})()
return job.promise
}
}
+245
View File
@@ -0,0 +1,245 @@
import { reactive } from 'vue'
import type { ImChatMessagesResponse, ImChatSyncProgress } from '@/api/tcm'
type NoticeKind = 'success' | 'warning' | 'error'
type TimerHandle = ReturnType<typeof setTimeout>
interface HistoryDependencies {
load: (diagnosisId: number) => Promise<ImChatMessagesResponse>
sync: (diagnosisId: number, token?: string) => Promise<ImChatSyncProgress>
notify?: (kind: NoticeKind, message: string) => void
now?: () => number
setTimer?: (callback: () => void, delay: number) => TimerHandle
clearTimer?: (timer: TimerHandle) => void
visible?: boolean
}
interface Session {
diagnosisId: number
generation: number
archiveRequest?: Promise<boolean>
syncRequest?: Promise<void>
}
/** Each visible diagnosis owns its requests and one timer; late responses cannot update another patient. */
export function createImChatHistory(deps: HistoryDependencies) {
const state = reactive({
rows: [] as any[],
patientImId: '',
patientName: '',
loading: false,
syncing: false,
hasLoaded: false,
readError: '',
syncError: '',
partialErrors: [] as string[],
inserted: 0,
processedPeers: 0,
totalPeers: 0,
phase: '',
checkedAccounts: 0,
candidateAccounts: 0,
skippedAccounts: 0,
lastSyncAt: null as number | null
})
const now = deps.now ?? Date.now
const setTimer = deps.setTimer ?? setTimeout
const clearTimer = deps.clearTimer ?? clearTimeout
let diagnosisId = 0
let generation = 0
let visible = deps.visible ?? true
let disposed = false
let session: Session | undefined
let timer: TimerHandle | undefined
function current(target: Session) {
return !disposed && visible && target === session && target.generation === generation && target.diagnosisId === diagnosisId
}
function clearScheduledSync() {
if (timer !== undefined) clearTimer(timer)
timer = undefined
}
function invalidate() {
generation++
clearScheduledSync()
session = undefined
state.loading = false
state.syncing = false
}
async function loadArchived(target: Session, freshAfterPending = false): Promise<boolean> {
if (!current(target)) return false
if (target.archiveRequest) {
const result = await target.archiveRequest
if (!freshAfterPending || !current(target)) return result
return loadArchived(target)
}
state.loading = true
const request = Promise.resolve().then(async () => {
if (!current(target)) return false
try {
const response = await deps.load(target.diagnosisId)
if (!current(target)) return false
if (!Array.isArray(response?.lists)) throw new Error('聊天记录接口返回的数据不完整')
state.rows = response.lists
state.patientImId = response.patient_im_id || ''
state.patientName = response.patient_name || ''
state.hasLoaded = true
state.readError = ''
return true
} catch (error) {
if (current(target)) state.readError = imChatErrorMessage(error, '读取聊天记录失败')
return false
}
})
target.archiveRequest = request
try {
return await request
} finally {
if (target.archiveRequest === request) target.archiveRequest = undefined
if (current(target)) state.loading = false
}
}
function scheduleSync(target: Session) {
clearScheduledSync()
if (!current(target)) return
timer = setTimer(() => {
timer = undefined
if (current(target)) void sync(target)
}, 30000)
}
async function sync(target: Session, manual = false): Promise<void> {
if (!current(target)) return
if (target.syncRequest) return target.syncRequest
clearScheduledSync()
state.syncing = true
state.syncError = ''
state.partialErrors = []
state.inserted = 0
state.processedPeers = 0
state.totalPeers = 0
state.phase = 'checking_accounts'
state.checkedAccounts = 0
state.candidateAccounts = 0
state.skippedAccounts = 0
const request = Promise.resolve().then(async () => {
let token: string | undefined
let pagesSinceRefresh = 0
let lastRefreshAt = now()
const errors = new Set<string>()
try {
while (current(target)) {
const progress = await deps.sync(target.diagnosisId, token)
if (!current(target)) return
if (typeof progress?.completed !== 'boolean') throw new Error('同步接口未返回有效的完成状态')
state.inserted = Number(progress.inserted) || 0
state.processedPeers = Number(progress.processed_peers) || 0
state.totalPeers = Number(progress.total_peers) || 0
state.phase = progress.phase || 'syncing'
state.checkedAccounts = Number(progress.checked_accounts) || 0
state.candidateAccounts = Number(progress.candidate_accounts) || 0
state.skippedAccounts = Number(progress.skipped_accounts) || 0
for (const error of [progress.error, ...(progress.errors || [])]) {
if (typeof error === 'string' && error.trim()) errors.add(error)
}
state.partialErrors = [...errors]
if (progress.completed) {
const refreshed = await loadArchived(target, true)
if (!current(target)) return
state.lastSyncAt = now()
if (manual) {
if (errors.size) deps.notify?.('warning', `同步完成,部分会话失败:${[...errors].join('')}`)
else if (!refreshed) deps.notify?.('warning', `同步已完成,但读取聊天记录失败:${state.readError}`)
else deps.notify?.('success', `聊天记录已更新,本次新增 ${state.inserted}`)
}
return
}
if (!progress.sync_token) throw new Error('同步接口未返回继续同步所需的进度标识')
token = progress.sync_token
pagesSinceRefresh++
if (pagesSinceRefresh >= 3 || now() - lastRefreshAt >= 2000) {
await loadArchived(target, true)
pagesSinceRefresh = 0
lastRefreshAt = now()
}
}
} catch (error) {
if (!current(target)) return
state.syncError = imChatErrorMessage(error, '同步聊天记录失败')
if (manual) deps.notify?.('error', state.syncError)
}
})
target.syncRequest = request
try {
await request
} finally {
if (target.syncRequest === request) target.syncRequest = undefined
if (current(target)) {
state.syncing = false
scheduleSync(target)
}
}
}
function start() {
if (disposed || !visible || !diagnosisId) return
const target: Session = { diagnosisId, generation }
session = target
void loadArchived(target)
void sync(target)
}
function setDiagnosis(value: number) {
const nextId = Number(value) > 0 ? Number(value) : 0
if (disposed || (nextId === diagnosisId && session)) return
invalidate()
diagnosisId = nextId
state.rows = []
state.patientImId = ''
state.patientName = ''
state.hasLoaded = false
state.readError = ''
state.syncError = ''
state.partialErrors = []
state.inserted = 0
state.processedPeers = 0
state.totalPeers = 0
state.phase = ''
state.checkedAccounts = 0
state.candidateAccounts = 0
state.skippedAccounts = 0
state.lastSyncAt = null
start()
}
function setVisible(value: boolean) {
if (disposed || value === visible) return
visible = value
invalidate()
if (visible) start()
}
function dispose() {
disposed = true
invalidate()
}
return {
state,
setDiagnosis,
setVisible,
dispose,
reload: () => session ? loadArchived(session) : Promise.resolve(false),
sync: () => session ? sync(session, true) : Promise.resolve()
}
}
export function imChatErrorMessage(error: unknown, fallback: string): string {
if (typeof error === 'string' && error.trim()) return error
const detail = error as any
return detail?.response?.data?.msg || detail?.msg || detail?.error || detail?.message || fallback
}
+43 -15
View File
@@ -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>
@@ -179,6 +179,7 @@ export function logActionText(act: string) {
patch_rx_patient: '处方患者信息', patch_rx_patient: '处方患者信息',
patch_rx_usage: '服用参数', patch_rx_usage: '服用参数',
update_amount: '修改订单金额', update_amount: '修改订单金额',
edit_time: '修改创建时间',
complete: '完成订单', complete: '完成订单',
refund: '退款', refund: '退款',
manual_log: '手工备注', manual_log: '手工备注',
@@ -42,6 +42,13 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="挂号类型">
<el-select v-model="queryParams.appointment_type" placeholder="全部" clearable class="!w-[140px]">
<el-option label="全部" value="" />
<el-option label="视频问诊" value="video" />
<el-option label="图文问诊" value="text" />
</el-select>
</el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]"> <el-select v-model="queryParams.status" placeholder="全部" clearable class="!w-[130px]">
<el-option label="已预约" :value="1" /> <el-option label="已预约" :value="1" />
@@ -115,7 +122,9 @@
<div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div> <div class="text-gray-500">{{ formatHm(row.appointment_time) }} · {{ row.period_desc }}</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="类型" prop="appointment_type_desc" width="100" /> <el-table-column label="挂号类型" width="110">
<template #default="{ row }">{{ appointmentTypeDescription(row.appointment_type) }}</template>
</el-table-column>
<el-table-column label="渠道" min-width="130" show-overflow-tooltip> <el-table-column label="渠道" min-width="130" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
<div class="text-sm">{{ row.channel_source_desc || '—' }}</div> <div class="text-sm">{{ row.channel_source_desc || '—' }}</div>
@@ -178,11 +187,10 @@
<el-radio-button label="all">全天</el-radio-button> <el-radio-button label="all">全天</el-radio-button>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="问诊类型" required> <el-form-item label="挂号类型" required>
<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>
@@ -293,6 +301,7 @@
import DaterangePicker from '@/components/daterange-picker/index.vue' import DaterangePicker from '@/components/daterange-picker/index.vue'
import { getDictData } from '@/api/app' import { getDictData } from '@/api/app'
import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor' import { appointmentAdminEdit, appointmentBatchEditChannel, appointmentLists } from '@/api/doctor'
import { appointmentTypeDescription } from '@/utils/appointment-type'
import { getAssistants } from '@/api/tcm' import { getAssistants } from '@/api/tcm'
import { usePaging } from '@/hooks/usePaging' import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback' import feedback from '@/utils/feedback'
@@ -303,6 +312,7 @@ const queryParams = reactive({
patient_name: '', patient_name: '',
doctor_name: '', doctor_name: '',
assistant_id: undefined as number | undefined, assistant_id: undefined as number | undefined,
appointment_type: '',
status: '' as number | '', status: '' as number | '',
channel_source: '' as string channel_source: '' as string
}) })
@@ -313,6 +323,7 @@ const queryInit = {
patient_name: '', patient_name: '',
doctor_name: '', doctor_name: '',
assistant_id: undefined as number | undefined, assistant_id: undefined as number | undefined,
appointment_type: '',
status: '' as number | '', status: '' as number | '',
channel_source: '' channel_source: ''
} }
@@ -1585,7 +1585,7 @@ import DaterangePicker from '@/components/daterange-picker/index.vue'
import MedicineNameSelect from '@/components/medicine-name-select/index.vue' import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
import PrescriptionSlip from '@/components/prescription-slip/index.vue' import PrescriptionSlip from '@/components/prescription-slip/index.vue'
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue' import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue' import { computed, onMounted, onActivated, onDeactivated, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import jsPDF from 'jspdf' import jsPDF from 'jspdf'
@@ -3882,6 +3882,17 @@ const handleDelete = async (id: number) => {
getLists() getLists()
} }
// 从订单页返回缓存的处方列表时,重新读取退款后的占用状态。
let refreshOnReturn = false
onDeactivated(() => {
refreshOnReturn = true
})
onActivated(() => {
if (!refreshOnReturn) return
refreshOnReturn = false
getLists()
})
onMounted(async () => { onMounted(async () => {
await loadRoleOptions() await loadRoleOptions()
await loadRegionData() await loadRegionData()
@@ -551,6 +551,12 @@
link link
@click="openEdit(row)" @click="openEdit(row)"
>编辑</el-button> >编辑</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
type="primary"
link
@click="orderTimeDialogRef?.open(row)"
>修改创建时间</el-button>
<el-button <el-button
v-if="canQuickTrackRow(row)" v-if="canQuickTrackRow(row)"
v-perms="['tcm.prescriptionOrder/ddcode']" v-perms="['tcm.prescriptionOrder/ddcode']"
@@ -650,6 +656,11 @@
</div> </div>
</template> </template>
<template #header-actions="{ detail }"> <template #header-actions="{ detail }">
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(detail)"
>修改创建时间</el-button>
<gancao-submission-reconcile-button <gancao-submission-reconcile-button
:order="detail" :order="detail"
@resolved="handleGancaoSubmissionResolved" @resolved="handleGancaoSubmissionResolved"
@@ -1511,6 +1522,8 @@
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
<!-- 订单退款须填写原因 --> <!-- 订单退款须填写原因 -->
<el-dialog <el-dialog
v-model="refundOrderDialogVisible" v-model="refundOrderDialogVisible"
@@ -2032,6 +2045,7 @@ import { useRoute } from 'vue-router'
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue' import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue' import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue' import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue' import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { import {
TCM_ASSISTANT_ROLE_ID, TCM_ASSISTANT_ROLE_ID,
@@ -3088,6 +3102,12 @@ function canUploadPharmacyRow(row: {
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ─── // ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>() const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
async function onOrderTimeSaved(id: number) {
getLists()
await detailDrawerRef.value?.refreshIfCurrent(id)
}
/** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh() */ /** 当前详情数据(组件内部 ref 的桥接;可原地修改属性,整体刷新请用 detailDrawerRef.refresh() */
const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null) const detailData = computed(() => (detailDrawerRef.value?.detail ?? null) as Record<string, any> | null)
@@ -475,6 +475,11 @@
size="small" size="small"
@click="openEdit(row)" @click="openEdit(row)"
>编辑</el-button> >编辑</el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(row)"
>修改创建时间</el-button>
<el-button <el-button
v-if="canQuickTrackRow(row)" v-if="canQuickTrackRow(row)"
v-perms="['tcm.prescriptionOrder/ddcode']" v-perms="['tcm.prescriptionOrder/ddcode']"
@@ -592,6 +597,11 @@
> >
撤回支付审核 撤回支付审核
</el-button> </el-button>
<el-button
v-perms="['tcm.prescriptionOrder/editTime']"
size="small"
@click="orderTimeDialogRef?.open(detailData)"
>修改创建时间</el-button>
<el-button <el-button
v-if="canShipRow(detailData)" v-if="canShipRow(detailData)"
v-perms="['tcm.prescriptionOrder/ship']" v-perms="['tcm.prescriptionOrder/ship']"
@@ -2568,6 +2578,7 @@
<!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 --> <!-- consumer/prescription/index诊单 edit 同一套界面只读诊单与全部分页签 -->
<TcmDiagnosisEditView ref="diagnosisViewRef" /> <TcmDiagnosisEditView ref="diagnosisViewRef" />
<prescription-order-time-dialog ref="orderTimeDialogRef" @saved="onOrderTimeSaved" />
</div> </div>
</template> </template>
@@ -2586,6 +2597,7 @@ import {
User User
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import ListTimeFilter from '@/components/list-time-filter/index.vue' import ListTimeFilter from '@/components/list-time-filter/index.vue'
import PrescriptionOrderTimeDialog from './components/PrescriptionOrderTimeDialog.vue'
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue' import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
import { useListTimeFilter } from '@/hooks/useListTimeFilter' import { useListTimeFilter } from '@/hooks/useListTimeFilter'
import { import {
@@ -3514,6 +3526,14 @@ function canUpdateAmount(row: { id?: number; fulfillment_status?: number } | nul
const detailVisible = ref(false) const detailVisible = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const detailData = ref<Record<string, any> | null>(null) const detailData = ref<Record<string, any> | null>(null)
const orderTimeDialogRef = ref<InstanceType<typeof PrescriptionOrderTimeDialog>>()
async function onOrderTimeSaved(id: number) {
getLists()
if (detailVisible.value && Number(detailData.value?.id) === id) {
await openDetail(id)
}
}
const detailServicePackageText = computed(() => const detailServicePackageText = computed(() =>
formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value) formatServicePackageLabels(detailData.value?.service_package, servicePackageOptions.value)
@@ -3838,7 +3858,8 @@ function logActionText(act: string) {
patch_rx_patient: '处方患者信息', patch_rx_patient: '处方患者信息',
update_amount: '修改订单金额', update_amount: '修改订单金额',
complete: '完成订单', complete: '完成订单',
refund: '退款' refund: '退款',
edit_time: '修改创建时间'
} }
return m[act] || act return m[act] || act
} }
+279 -1
View File
@@ -102,6 +102,23 @@
@keyup.enter="resetPage" @keyup.enter="resetPage"
/> />
</el-form-item> </el-form-item>
<el-form-item label="渠道">
<el-select
v-model="queryParams.add_way"
clearable
filterable
placeholder="选择或搜索添加渠道"
style="width: 240px"
@change="resetPage"
>
<el-option
v-for="option in ADD_WAY_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="添加时间"> <el-form-item label="添加时间">
<el-date-picker <el-date-picker
v-model="addTimeRange" v-model="addTimeRange"
@@ -235,6 +252,32 @@
<span v-else class="text-gray-400"></span> <span v-else class="text-gray-400"></span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="添加渠道" min-width="160">
<template #default="{ row }">
<div v-if="customerAddSources(row).length" class="flex items-center gap-1">
<el-tooltip
v-for="source in customerAddSources(row).slice(0, 1)"
:key="source.key"
:content="addSourceTooltip(source)"
placement="top"
>
<span class="inline-block max-w-[150px] truncate align-middle">
{{ source.label }}
</span>
</el-tooltip>
<el-tooltip
v-if="customerAddSources(row).length > 1"
:content="remainingAddSourcesTooltip(row)"
placement="top"
>
<span class="text-primary whitespace-nowrap cursor-help">
{{ customerAddSources(row).length - 1 }}
</span>
</el-tooltip>
</div>
<span v-else class="text-gray-400">未记录</span>
</template>
</el-table-column>
<el-table-column label="添加时间" width="160"> <el-table-column label="添加时间" width="160">
<template #default="{ row }"> <template #default="{ row }">
{{ formatTime(firstExternalAddTime(row)) }} {{ formatTime(firstExternalAddTime(row)) }}
@@ -245,9 +288,19 @@
{{ formatTime(row.update_time) }} {{ formatTime(row.update_time) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="viewDetail(row)">查看详情</el-button> <el-button type="primary" link @click="viewDetail(row)">查看详情</el-button>
<el-button
v-perms="['qywx.customer/delete']"
type="danger"
link
:loading="deletingCustomerId === Number(row.id)"
:disabled="deletingCustomerId !== null"
@click="handleDelete(row)"
>
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -501,6 +554,21 @@
<el-descriptions-item label="添加时间" :span="2"> <el-descriptions-item label="添加时间" :span="2">
{{ formatTime(firstExternalAddTime(currentCustomer)) }} {{ formatTime(firstExternalAddTime(currentCustomer)) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="添加渠道" :span="2">
<div v-if="customerAddSources(currentCustomer).length" class="flex flex-wrap gap-1">
<el-tooltip
v-for="source in customerAddSources(currentCustomer)"
:key="source.key"
:content="addSourceTooltip(source)"
placement="top"
>
<el-tag size="small" type="info" effect="plain">
{{ source.label }}
</el-tag>
</el-tooltip>
</div>
<span v-else class="text-gray-400">未记录</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2"> <el-descriptions-item label="更新时间" :span="2">
{{ formatTime(currentCustomer.update_time) }} {{ formatTime(currentCustomer.update_time) }}
</el-descriptions-item> </el-descriptions-item>
@@ -550,6 +618,7 @@ import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback' import feedback from '@/utils/feedback'
import { import {
qywxCustomerLists, qywxCustomerLists,
qywxCustomerDelete,
qywxCustomerSync, qywxCustomerSync,
qywxCustomerStats, qywxCustomerStats,
qywxSyncSettingsGet, qywxSyncSettingsGet,
@@ -563,6 +632,7 @@ const syncing = ref(false)
const showSyncSettings = ref(false) const showSyncSettings = ref(false)
const showDetail = ref(false) const showDetail = ref(false)
const currentCustomer = ref<any>(null) const currentCustomer = ref<any>(null)
const deletingCustomerId = ref<number | null>(null)
const stats = reactive({ const stats = reactive({
total: 0, total: 0,
@@ -594,6 +664,7 @@ const syncSettings = reactive({
const queryParams = reactive<{ const queryParams = reactive<{
name: string name: string
follow_user: string follow_user: string
add_way: number | ''
tag_ids: string[] tag_ids: string[]
add_time_start: string add_time_start: string
add_time_end: string add_time_end: string
@@ -601,6 +672,7 @@ const queryParams = reactive<{
}>({ }>({
name: '', name: '',
follow_user: '', follow_user: '',
add_way: '',
tag_ids: [], tag_ids: [],
add_time_start: '', add_time_start: '',
add_time_end: '', add_time_end: '',
@@ -639,6 +711,22 @@ interface TagStatsPayload {
groups: TagGroup[] groups: TagGroup[]
} }
interface AddChannel {
state: string
label: string
source_type: 'promotion_pool' | 'state'
pool_id: number
user_id: string
event_time: number
}
interface AddSource extends AddChannel {
key: string
add_way: number | null
channel_label: string
staff_name: string
}
const tagStats = reactive<TagStatsPayload>({ const tagStats = reactive<TagStatsPayload>({
total_tags: 0, total_tags: 0,
total_relations: 0, total_relations: 0,
@@ -850,6 +938,7 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
function handleReset() { function handleReset() {
queryParams.name = '' queryParams.name = ''
queryParams.follow_user = '' queryParams.follow_user = ''
queryParams.add_way = ''
queryParams.tag_ids = [] queryParams.tag_ids = []
queryParams.add_time_start = '' queryParams.add_time_start = ''
queryParams.add_time_end = '' queryParams.add_time_end = ''
@@ -964,6 +1053,34 @@ function viewDetail(row: any) {
showDetail.value = true showDetail.value = true
} }
async function handleDelete(row: Record<string, any>) {
const id = Number(row.id)
if (!Number.isInteger(id) || id <= 0 || deletingCustomerId.value !== null) return
const customerName = String(row.name || row.external_userid || '该客户')
try {
await feedback.confirm(
`确定删除企业微信客户“${customerName}”吗?此操作仅删除系统内的同步记录,不会删除企业微信中的客户关系;后续重新同步时可能再次出现。`
)
} catch {
return
}
deletingCustomerId.value = id
try {
await qywxCustomerDelete({ id })
if (pager.page > 1 && pager.lists.length === 1) {
pager.page -= 1
}
await Promise.all([getLists(), loadStats(), loadTagStats()])
feedback.msgSuccess('删除成功')
} catch (e: any) {
feedback.msgError(e?.message || e?.msg || '删除失败')
} finally {
deletingCustomerId.value = null
}
}
/** 列表接口会写入 admin_nameadmin.work_wechat_userid = userid */ /** 列表接口会写入 admin_nameadmin.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
} }
@@ -125,6 +125,11 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="挂号类型" min-width="110">
<template #default="{ row }">
{{ Number(row.appointment_id) > 0 ? appointmentTypeDescription(row.appointment_type) : '—' }}
</template>
</el-table-column>
<el-table-column label="预约时间" min-width="165"> <el-table-column label="预约时间" min-width="165">
<template #default="{ row }"> <template #default="{ row }">
<span :class="['appointment-time', { empty: !row.appointment_time_text }]"> <span :class="['appointment-time', { empty: !row.appointment_time_text }]">
@@ -325,6 +330,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { appointmentTypeDescription } from '@/utils/appointment-type'
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue' import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -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,17 +58,56 @@
</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="Refresh"
:loading="syncingBatchMembers"
:disabled="!selectedPoolIds.length || memberSyncBusy || togglingMemberId > 0 || deletingPoolId > 0"
@click="syncSelectedMemberRanges"
>批量同步成员范围</el-button>
<el-button
:icon="Edit"
:disabled="!selectedManageablePoolCount || memberSyncBusy"
@click="openBatchConfigDialog()"
>批量修改方案</el-button>
<el-button <el-button
:icon="User" :icon="User"
:disabled="!selectedPoolIds.length" :disabled="!selectedManageablePoolCount || memberSyncBusy"
@click="openAccessDialog()" @click="openAccessDialog()"
>批量设置访问操作</el-button> >批量设置访问操作</el-button>
<el-button type="primary" :icon="Plus" @click="openPoolDialog()">新建分流方案</el-button> <el-button type="primary" :icon="Plus" :disabled="memberSyncBusy" @click="openPoolDialog()">新建分流方案</el-button>
</div> </div>
</div> </div>
<section v-if="operationResults.length" class="member-sync-results" aria-live="polite">
<strong>本次保存与企微同步结果</strong>
<p v-if="syncingBatchMembers" role="status">正在同步企微 {{ batchSyncProgress.completed }} / {{ batchSyncProgress.total }} 个方案请等待完成</p>
<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="memberSyncBusy"
@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="allSelectablePoolsSelected"
:indeterminate="someSelectablePoolsSelected"
:disabled="!selectablePoolIds.length || memberSyncBusy"
aria-label="全选可操作的分流方案"
@change="toggleAllPoolSelection"
>全选</el-checkbox>
<span>可选 {{ selectablePoolIds.length }} </span>
</div>
<div <div
v-for="pool in overview.pools" v-for="pool in overview.pools"
:key="pool.id" :key="pool.id"
@@ -77,7 +116,7 @@
> >
<el-checkbox <el-checkbox
:model-value="selectedPoolIds.includes(Number(pool.id))" :model-value="selectedPoolIds.includes(Number(pool.id))"
:disabled="!pool.can_manage_access" :disabled="(!pool.can_manage_access && !pool.can_operate) || memberSyncBusy"
:aria-label="`选择方案 ${pool.name}`" :aria-label="`选择方案 ${pool.name}`"
@change="(checked) => togglePoolSelection(pool, checked)" @change="(checked) => togglePoolSelection(pool, checked)"
@click.stop @click.stop
@@ -111,11 +150,12 @@
</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 || memberSyncBusy || 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" :disabled="memberSyncBusy" @click="openAccessDialog([Number(selectedPool.id)])">设置访问操作</el-button>
<el-button v-if="selectedPool.can_operate" :icon="Edit" @click="openPoolDialog(selectedPool)">编辑方案</el-button> <el-button v-if="selectedPool.can_operate" :icon="Edit" :disabled="memberSyncBusy" @click="openPoolDialog(selectedPool)">编辑方案</el-button>
<el-button v-if="selectedPool.can_delete" type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0" @click="removePool(selectedPool)">删除</el-button> <el-button v-if="selectedPool.can_delete" type="danger" plain :icon="Delete" :loading="deletingPoolId === Number(selectedPool.id)" :disabled="deletingPoolId > 0 || memberSyncBusy" @click="removePool(selectedPool)">删除</el-button>
</div> </div>
</div> </div>
@@ -135,10 +175,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 +197,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,16 +214,17 @@
</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 || memberSyncBusy" :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">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="selectedPool.can_operate" type="primary" link @click="openMemberDialog(row)">编辑规则</el-button> <el-button v-if="selectedPool.can_operate" type="primary" link :disabled="memberSyncBusy" @click="openMemberDialog(row)">编辑规则</el-button>
</template> </template>
</el-table-column> </el-table-column>
<template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template> <template #empty><el-empty :image-size="72" description="编辑方案并选择获客医助" /></template>
</el-table> </el-table>
</div>
</div> </div>
</div> </div>
<el-empty v-else description="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接"> <el-empty v-else description="创建方案并选择多个医助,保存后自动生成一个企业微信官方获客链接">
@@ -429,6 +480,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 +721,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 +729,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 +757,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 +812,24 @@ 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 syncingBatchMembers = ref(false)
const memberSyncBusy = computed(() => syncingBatchMembers.value || syncingPoolId.value > 0 || savingBatchConfig.value)
const batchSyncProgress = reactive({ completed: 0, total: 0 })
type MemberOperationResult = WecomPromotionBatchUpdatePoolResult & { sync_only?: boolean }
const operationResults = ref<MemberOperationResult[]>([])
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 +850,122 @@ 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 selectablePoolIds = computed(() => overview.pools
.filter((pool: any) => pool.can_manage_access || pool.can_operate)
.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 selectedSelectablePoolCount = computed(() => selectablePoolIds.value.filter((id) => selectedPoolIds.value.includes(id)).length)
const allSelectablePoolsSelected = computed(() => selectablePoolIds.value.length > 0
&& selectedSelectablePoolCount.value === selectablePoolIds.value.length)
const someSelectablePoolsSelected = computed(() => selectedSelectablePoolCount.value > 0
&& selectedSelectablePoolCount.value < selectablePoolIds.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))
@@ -650,14 +983,12 @@ const customerMemberOptions = computed(() => customerStats.member_options.length
department_name: Array.isArray(item.dept_names) ? item.dept_names.join(' / ') : (item.department_name || item.dept_name || '') department_name: Array.isArray(item.dept_names) ? item.dept_names.join(' / ') : (item.department_name || item.dept_name || '')
}))) })))
watch(() => overview.pools.map((item: any) => `${Number(item.id)}:${item.can_manage_access ? 1 : 0}`).join(','), () => { watch(() => overview.pools.map((item: any) => `${Number(item.id)}:${item.can_manage_access ? 1 : 0}:${item.can_operate ? 1 : 0}`).join(','), () => {
const ids = overview.pools.map((item: any) => Number(item.id)) const ids = overview.pools.map((item: any) => Number(item.id))
const manageableIds = new Set(overview.pools const selectableIds = new Set(selectablePoolIds.value)
.filter((item: any) => item.can_manage_access)
.map((item: any) => Number(item.id)))
if (!selectedPoolId.value || !ids.includes(selectedPoolId.value)) selectedPoolId.value = ids[0] if (!selectedPoolId.value || !ids.includes(selectedPoolId.value)) selectedPoolId.value = ids[0]
if (!selectedInstallPoolId.value || !ids.includes(selectedInstallPoolId.value)) selectedInstallPoolId.value = ids[0] if (!selectedInstallPoolId.value || !ids.includes(selectedInstallPoolId.value)) selectedInstallPoolId.value = ids[0]
selectedPoolIds.value = selectedPoolIds.value.filter((id) => manageableIds.has(id)) selectedPoolIds.value = selectedPoolIds.value.filter((id) => selectableIds.has(id))
}, { immediate: true }) }, { immediate: true })
watch(activeTab, (tab) => { watch(activeTab, (tab) => {
@@ -685,19 +1016,293 @@ async function loadOverview() {
} }
function togglePoolSelection(pool: any, checked: unknown) { function togglePoolSelection(pool: any, checked: unknown) {
if (!pool?.can_manage_access) return if (memberSyncBusy.value || (!pool?.can_manage_access && !pool?.can_operate)) return
const id = Number(pool.id) const id = Number(pool.id)
const next = new Set(selectedPoolIds.value) const next = new Set(selectedPoolIds.value)
checked ? next.add(id) : next.delete(id) checked ? next.add(id) : next.delete(id)
selectedPoolIds.value = [...next] selectedPoolIds.value = [...next]
} }
function openAccessDialog(poolIds: number[] = selectedPoolIds.value) { function toggleAllPoolSelection(checked: unknown) {
if (memberSyncBusy.value) return
selectedPoolIds.value = checked ? [...selectablePoolIds.value] : []
}
function openBatchConfigDialog(poolIds: number[] = selectedPoolIds.value) {
if (memberSyncBusy.value) return
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 个分流方案')
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: MemberOperationResult) {
if (!result.success) return `本地保存失败:${result.error || '请检查配置后重试'}`
const prefix = result.sync_only ? '' : '本地已保存,'
if (result.sync_status === 'synced') return `${prefix}企微成员范围已确认同步`
if (result.sync_status === 'blocked' || result.sync_status === 'failed' || result.sync_error) {
return `${prefix}企微同步${result.sync_status === 'blocked' ? '受阻' : '失败'}${result.sync_error || '请重试同步'}`
}
return `${prefix}企微成员范围尚未确认同步,请重试同步`
}
function recordOperationResult(result: MemberOperationResult) {
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 (memberSyncBusy.value || togglingMemberId.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, sync_only: true, ...result })
await loadOverview()
notifySavedResult('成员范围同步结果', result)
} finally {
syncingPoolId.value = 0
}
}
async function syncBatchResults(results: MemberOperationResult[]) {
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 syncSelectedMemberRanges() {
if (memberSyncBusy.value || togglingMemberId.value || deletingPoolId.value
|| savingPool.value || savingMember.value || savingAccess.value) return
const ids = [...new Set(selectedPoolIds.value.map(Number))]
if (!ids.length) return ElMessage.warning('请先勾选需要同步的分流方案')
if (ids.length > 100) return ElMessage.warning('单次最多同步 100 个分流方案,请分批选择')
const results: MemberOperationResult[] = ids.map((id) => {
const pool = overview.pools.find((item: any) => Number(item.id) === id)
const eligible = canSyncPool(id)
return {
id, name: pool?.name || `方案 ${id}`, success: true, sync_only: true,
sync_status: eligible ? 'pending' : 'blocked', sync_queued: eligible,
sync_error: eligible ? '' : !pool?.can_operate ? '没有该方案的操作权限' : '方案尚未生成官方链接,请先保存方案'
}
})
syncingBatchMembers.value = true
try {
await syncBatchResults(results)
selectedPoolIds.value = operationResults.value.filter((item) => item.sync_status !== 'synced').map((item) => item.id)
await loadOverview()
const synced = operationResults.value.filter((item) => item.sync_status === 'synced').length
const unconfirmed = ids.length - synced
ElMessage({
type: unconfirmed ? 'warning' : 'success',
message: `企微已确认同步 ${synced} 个方案${unconfirmed ? `${unconfirmed} 个尚未确认同步,已保留勾选` : ''}。详情见方案列表上方。`,
duration: 8000
})
} finally {
syncingBatchMembers.value = false
Object.assign(batchSyncProgress, { completed: 0, total: 0 })
}
}
async function saveBatchConfig() {
if (memberSyncBusy.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) {
if (memberSyncBusy.value) return
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 个分流方案')
Object.assign(accessForm, { Object.assign(accessForm, {
pool_ids: ids, pool_ids: ids,
operator_admin_ids: [], operator_admin_ids: [],
@@ -753,6 +1358,7 @@ function accessSummary(pool: any) {
} }
function openPoolDialog(pool?: any) { function openPoolDialog(pool?: any) {
if (memberSyncBusy.value) return
const selectableMemberIds = new Set(overview.member_options.map((member) => Number(member.id))) const selectableMemberIds = new Set(overview.member_options.map((member) => Number(member.id)))
poolFormError.value = '' poolFormError.value = ''
automationBusy.value = false automationBusy.value = false
@@ -781,7 +1387,7 @@ function openPoolDialog(pool?: any) {
} }
async function savePool() { async function savePool() {
if (savingPool.value || automationBusy.value) return if (savingPool.value || automationBusy.value || memberSyncBusy.value) return
poolFormError.value = '' poolFormError.value = ''
const automation = cloneAutomationConfig(poolForm.automation_config) const automation = cloneAutomationConfig(poolForm.automation_config)
// Disabled sections do not submit unfinished draft rows or draft attachments. // Disabled sections do not submit unfinished draft rows or draft attachments.
@@ -810,11 +1416,10 @@ async function savePool() {
throw new Error('方案已提交,但服务端未确认标签和欢迎语配置保存成功,请刷新后重试') throw new Error('方案已提交,但服务端未确认标签和欢迎语配置保存成功,请刷新后重试')
} }
poolDialogVisible.value = false poolDialogVisible.value = false
recordSavedResult(Number(result?.id || poolForm.id), poolForm.name, result || {})
await loadOverview() await loadOverview()
if (result?.id) selectedPoolId.value = Number(result.id) if (result?.id) selectedPoolId.value = Number(result.id)
result?.sync_error notifySavedResult(poolForm.id ? '本地方案已保存' : '分流方案已创建', result || {})
? ElMessage.warning('方案已保存,企业微信多人范围将在后台自动重试同步')
: ElMessage.success(poolForm.id ? '分流方案已保存' : '分流方案和官方获客链接已创建')
} catch (error: any) { } catch (error: any) {
poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试') poolFormError.value = error?.message || (typeof error === 'string' ? error : '分流方案保存失败,请检查配置或稍后重试')
poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' }) poolFormScroll.value?.scrollTo({ top: 0, behavior: 'smooth' })
@@ -825,6 +1430,7 @@ async function savePool() {
} }
async function removePool(pool: any) { async function removePool(pool: any) {
if (memberSyncBusy.value) return
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`删除“${pool.name}”将同时永久删除企业微信后台中的官方获客链接,已投放的链接会失效且无法恢复。确认继续?`, `删除“${pool.name}”将同时永久删除企业微信后台中的官方获客链接,已投放的链接会失效且无法恢复。确认继续?`,
@@ -847,6 +1453,7 @@ async function removePool(pool: any) {
} }
function openMemberDialog(row: any) { function openMemberDialog(row: any) {
if (memberSyncBusy.value) return
const range = Number(row.active_start) > 0 && Number(row.active_end) > 0 const range = Number(row.active_start) > 0 && Number(row.active_end) > 0
? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)] ? [formatPickerTime(row.active_start), formatPickerTime(row.active_end)]
: [] : []
@@ -863,7 +1470,10 @@ function openMemberDialog(row: any) {
} }
async function saveMemberRule() { async function saveMemberRule() {
if (savingMember.value || memberSyncBusy.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 +1481,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 +1492,15 @@ async function saveMemberRule() {
} }
async function handleMemberToggle(row: any, value: unknown) { async function handleMemberToggle(row: any, value: unknown) {
if (togglingMemberId.value || memberSyncBusy.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 +1638,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 +1651,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 +1700,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 +1736,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 +1753,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 +1937,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 +1987,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>
+89 -5
View File
@@ -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
@@ -450,12 +451,15 @@ const handleCall = async (row: QueueRow) => {
try { try {
const res = await getCallSignature({ const res = await getCallSignature({
patient_id: sourcePatientId, patient_id: sourcePatientId,
appointment_id: Number(row.id),
diagnosis_id: diagnosisId diagnosis_id: diagnosisId
}) })
chatDialogRef.value?.open({ chatDialogRef.value?.open({
patientId: sourcePatientId, patientId: sourcePatientId,
patientName: row.patient_name, patientName: row.patient_name,
diagnosisId, diagnosisId,
appointmentId: Number(row.id),
appointmentType: row.appointment_type,
signatureData: res signatureData: res
}) })
} catch (error: any) { } catch (error: any) {
@@ -16,7 +16,8 @@
<code>一键打包</code> <code>一键打包</code>
产物一致的安装包并填入打包目录中的 SHA-256Windows 推荐使用 产物一致的安装包并填入打包目录中的 SHA-256Windows 推荐使用
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 地址并开启证书校验 2GBWindows 自动安装程序必须使用 HTTPS 地址并开启证书校验
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
+16 -5
View File
@@ -163,7 +163,9 @@
</el-table-column> </el-table-column>
<!-- <el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip /> --> <el-table-column label="面诊类型" width="110">
<template #default="{ row }">{{ appointmentTypeDescription(row.appointment_type) }}</template>
</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 }">
@@ -220,6 +222,7 @@
<template v-if="row.status !== 3"> <template v-if="row.status !== 3">
<el-button <el-button
v-if="canAppointmentVideoCall(row.appointment_type)"
v-perms="['tcm.diagnosis/videoQr']" v-perms="['tcm.diagnosis/videoQr']"
type="warning" type="warning"
link link
@@ -237,7 +240,7 @@
size="small" size="small"
@click="handleChat(row)" @click="handleChat(row)"
> >
通话 {{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
</el-button> </el-button>
<el-button <el-button
v-perms="['doctor.appointment/complete']" v-perms="['doctor.appointment/complete']"
@@ -340,8 +343,8 @@
<span class="detail-value">{{ detailData.period === 'morning' ? '上午' : '下午' }}</span> <span class="detail-value">{{ detailData.period === 'morning' ? '上午' : '下午' }}</span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">预约类型</span> <span class="detail-label">面诊类型</span>
<span class="detail-value">{{ detailData.appointment_type_desc }}</span> <span class="detail-value">{{ appointmentTypeDescription(detailData.appointment_type) }}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -556,6 +559,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { usePaging } from '@/hooks/usePaging' import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue' import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor' import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
@@ -1012,7 +1016,8 @@ const handleChat = async (row: any) => {
// //
const res = await getCallSignature({ const res = await getCallSignature({
patient_id: sourcePatientId, patient_id: sourcePatientId,
diagnosis_id: diagnosisId diagnosis_id: diagnosisId,
appointment_id: Number(row.id)
}) })
console.log('获取聊天签名成功:', res) console.log('获取聊天签名成功:', res)
@@ -1022,6 +1027,8 @@ const handleChat = async (row: any) => {
patientId: sourcePatientId, patientId: sourcePatientId,
patientName: row.patient_name, patientName: row.patient_name,
diagnosisId, diagnosisId,
appointmentId: Number(row.id),
appointmentType: row.appointment_type,
signatureData: res // signatureData: res //
}) })
} catch (error: any) { } catch (error: any) {
@@ -1032,6 +1039,10 @@ const handleChat = async (row: any) => {
// pages/login/login // pages/login/login
const handleMiniProgramQRCode = async (row: any) => { const handleMiniProgramQRCode = async (row: any) => {
if (!canAppointmentVideoCall(row.appointment_type)) {
feedback.msgWarning('仅视频问诊可生成视频二维码')
return
}
if (!row.patient_id) { if (!row.patient_id) {
feedback.msgWarning('患者信息不完整') feedback.msgWarning('患者信息不完整')
return return
+19 -6
View File
@@ -108,6 +108,10 @@
<span class="apt-h5-row-key">预约</span> <span class="apt-h5-row-key">预约</span>
<span class="apt-h5-row-val">{{ row.appointment_date || '-' }} {{ row.appointment_time || '' }}</span> <span class="apt-h5-row-val">{{ row.appointment_date || '-' }} {{ row.appointment_time || '' }}</span>
</div> </div>
<div class="apt-h5-row">
<span class="apt-h5-row-key">面诊类型</span>
<span class="apt-h5-row-val">{{ appointmentTypeDescription(row.appointment_type) }}</span>
</div>
<div class="apt-h5-row"> <div class="apt-h5-row">
<span class="apt-h5-row-key">医生</span> <span class="apt-h5-row-key">医生</span>
<span class="apt-h5-row-val">{{ row.doctor_name || '-' }}</span> <span class="apt-h5-row-val">{{ row.doctor_name || '-' }}</span>
@@ -147,7 +151,7 @@
:icon="Phone" :icon="Phone"
@click="handleChat(row)" @click="handleChat(row)"
> >
通话 {{ canAppointmentVideoCall(row.appointment_type) ? '通话' : '图文沟通' }}
</el-button> </el-button>
<el-button <el-button
v-if="hasPermission(['tcm.diagnosis/kaifang'])" v-if="hasPermission(['tcm.diagnosis/kaifang'])"
@@ -179,7 +183,7 @@
> >
<div v-if="moreSheetRow" class="apt-h5-actions"> <div v-if="moreSheetRow" class="apt-h5-actions">
<button <button
v-if="moreSheetRow.status !== 3 && hasPermission(['tcm.diagnosis/videoQr'])" v-if="moreSheetRow.status !== 3 && canAppointmentVideoCall(moreSheetRow.appointment_type) && hasPermission(['tcm.diagnosis/videoQr'])"
class="apt-h5-action" class="apt-h5-action"
@click="runAction('videoQr')" @click="runAction('videoQr')"
> >
@@ -236,7 +240,7 @@
<div><span>医生</span><strong>{{ detailData.doctor_name || '-' }}</strong></div> <div><span>医生</span><strong>{{ detailData.doctor_name || '-' }}</strong></div>
<div><span>预约时间</span><strong>{{ detailData.appointment_date || '-' }} {{ detailData.appointment_time || '' }}</strong></div> <div><span>预约时间</span><strong>{{ detailData.appointment_date || '-' }} {{ detailData.appointment_time || '' }}</strong></div>
<div><span>时段</span><strong>{{ detailData.period === 'morning' ? '上午' : detailData.period === 'afternoon' ? '下午' : '-' }}</strong></div> <div><span>时段</span><strong>{{ detailData.period === 'morning' ? '上午' : detailData.period === 'afternoon' ? '下午' : '-' }}</strong></div>
<div><span>预约类型</span><strong>{{ detailData.appointment_type_desc || '-' }}</strong></div> <div><span>面诊类型</span><strong>{{ appointmentTypeDescription(detailData.appointment_type) }}</strong></div>
</div> </div>
</section> </section>
@@ -336,7 +340,7 @@
plain plain
@click="handleChat(detailActionRow)" @click="handleChat(detailActionRow)"
> >
通话 {{ canAppointmentVideoCall(detailActionRow.appointment_type) ? '通话' : '图文沟通' }}
</el-button> </el-button>
<el-button <el-button
v-if="detailActionRow.status !== 3 && hasPermission(['doctor.appointment/complete'])" v-if="detailActionRow.status !== 3 && hasPermission(['doctor.appointment/complete'])"
@@ -408,6 +412,7 @@
</template> </template>
<script setup lang="ts" name="tcmAppointmentH5"> <script setup lang="ts" name="tcmAppointmentH5">
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor' import { appointmentDetail, appointmentLists, cancelAppointment, completeAppointment } from '@/api/doctor'
import { getWeappConfig } from '@/api/channel/weapp' import { getWeappConfig } from '@/api/channel/weapp'
import { import {
@@ -656,12 +661,15 @@ const handleChat = async (row: any) => {
try { try {
const res = await getCallSignature({ const res = await getCallSignature({
patient_id: sourcePatientId, patient_id: sourcePatientId,
diagnosis_id: diagnosisId diagnosis_id: diagnosisId,
appointment_id: Number(row.id)
}) })
chatDialogRef.value?.open({ chatDialogRef.value?.open({
patientId: sourcePatientId, patientId: sourcePatientId,
patientName: row.patient_name, patientName: row.patient_name,
diagnosisId, diagnosisId,
appointmentId: Number(row.id),
appointmentType: row.appointment_type,
signatureData: res signatureData: res
}) })
} catch (e: any) { } catch (e: any) {
@@ -777,6 +785,10 @@ const qrcodeLoading = ref(false)
const qrcodeUrl = ref('') const qrcodeUrl = ref('')
const currentQRCodeRow = ref<any>(null) const currentQRCodeRow = ref<any>(null)
const handleMiniProgramQRCode = async (row: any) => { const handleMiniProgramQRCode = async (row: any) => {
if (!canAppointmentVideoCall(row.appointment_type)) {
feedback.msgWarning('仅视频问诊可生成视频二维码')
return
}
if (!row.patient_id) { if (!row.patient_id) {
feedback.msgWarning('患者信息不完整') feedback.msgWarning('患者信息不完整')
return return
@@ -1184,7 +1196,8 @@ onUnmounted(() => {
} }
.apt-h5-row-key { .apt-h5-row-key {
flex: 0 0 38px; flex: 0 0 60px;
white-space: nowrap;
color: #8a94a6; color: #8a94a6;
} }
@@ -30,10 +30,11 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<!-- 预约类型 --> <!-- 挂号类型 -->
<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>
@@ -2,16 +2,16 @@
<div class="im-chat-record-panel"> <div class="im-chat-record-panel">
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明"> <el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
<p class="panel-tip"> <p class="panel-tip">
展示单聊记录已合并患者 展示患者所有诊单中
<strong>所有医生 / 医助账号</strong>分别产生的会话按时间排序 <strong>所有医生 / 医助账号</strong>分别产生的会话按时间排序
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档点击下方按钮可立即触发后台同步 打开页面后自动加载并同步最新消息页面可见期间每 30 秒继续检查更新
</p> </p>
</el-alert> </el-alert>
<div class="toolbar mb-3"> <div class="toolbar mb-3">
<el-button type="primary" link :loading="syncing" @click="triggerSync"> <el-button type="primary" link :loading="syncing" @click="triggerSync">
<el-icon class="mr-1"><Promotion /></el-icon> <el-icon class="mr-1"><Promotion /></el-icon>
同步最新后台异步 同步最新
</el-button> </el-button>
<el-button type="primary" link :loading="loading" @click="reloadArchived"> <el-button type="primary" link :loading="loading" @click="reloadArchived">
<el-icon class="mr-1"><Refresh /></el-icon> <el-icon class="mr-1"><Refresh /></el-icon>
@@ -19,12 +19,30 @@
</el-button> </el-button>
</div> </div>
<div v-loading="loading" class="chat-wrap"> <p v-if="syncing" class="sync-status" role="status" aria-live="polite">
<template v-if="!loading && rows.length"> <template v-if="history.phase === 'checking_accounts'">
正在核验聊天账号<span v-if="history.candidateAccounts">{{ history.checkedAccounts }} / {{ history.candidateAccounts }}</span>
</template>
<template v-else>正在同步聊天记录<span v-if="history.totalPeers">已处理 {{ history.processedPeers }} / {{ history.totalPeers }} 个会话</span>新增 {{ history.inserted }} </template>
</p>
<p v-else-if="history.lastSyncAt" class="sync-status">上次同步{{ formatTime(history.lastSyncAt) }}</p>
<p v-if="history.skippedAccounts" class="sync-status">已跳过 {{ history.skippedAccounts }} 个未注册或已失效的聊天账号已有归档不受影响</p>
<el-alert v-if="history.readError" type="error" show-icon :closable="false" class="mb-3" :title="`读取聊天记录失败:${history.readError}`" />
<el-alert v-if="history.syncError" type="error" show-icon :closable="false" class="mb-3" :title="`同步失败:${history.syncError}`" />
<el-alert v-if="history.partialErrors.length" type="warning" show-icon :closable="false" class="mb-3" title="聊天记录未完全同步">
<p v-for="error in history.partialErrors.slice(0, 3)" :key="error" class="panel-tip">{{ error }}</p>
<details v-if="history.partialErrors.length > 3">
<summary>查看其余 {{ history.partialErrors.length - 3 }} 项同步问题</summary>
<p v-for="error in history.partialErrors.slice(3)" :key="error" class="panel-tip">{{ error }}</p>
</details>
</el-alert>
<div v-loading="loading && !rows.length" class="chat-wrap">
<template v-if="rows.length">
<div class="chat-list"> <div class="chat-list">
<div <div
v-for="item in rows" v-for="item in rows"
:key="item.raw.msg_id" :key="`${item.raw.from_account || ''}-${item.raw.to_account || ''}-${item.raw.msg_id}`"
class="chat-row" class="chat-row"
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'" :class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
> >
@@ -64,31 +82,40 @@
</div> </div>
</div> </div>
</template> </template>
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" /> <el-empty v-else-if="!loading" :description="emptyDescription" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, shallowRef, watch, computed } from 'vue' import { watch, computed, toRefs, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Refresh, Promotion } from '@element-plus/icons-vue' import { Refresh, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getImChatMessages, triggerImChatSync } from '@/api/tcm' import { getImChatMessages, triggerImChatSync } from '@/api/tcm'
import type { FriendlyParse } from '@/utils/im-business-message-parse' import type { FriendlyParse } from '@/utils/im-business-message-parse'
import { parseImBusinessPayload } from '@/utils/im-business-message-parse' import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
import { createImChatHistory } from '@/utils/im-chat-history'
const props = defineProps<{ const props = defineProps<{
diagnosisId: number diagnosisId: number
}>() }>()
const loading = ref(false) const documentVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden'
const syncing = ref(false) const controller = createImChatHistory({
const rawRows = shallowRef<any[]>([]) load: (diagnosisId) => getImChatMessages({ diagnosis_id: diagnosisId, only_archived: 1 }),
const patientImId = ref('') sync: (diagnosisId, syncToken) => triggerImChatSync({ diagnosis_id: diagnosisId, ...(syncToken ? { sync_token: syncToken } : {}) }),
const patientName = ref('') notify: (kind, message) => ElMessage[kind](message),
visible: documentVisible()
const patientImHint = computed(() => patientImId.value || 'patient_*') })
const history = controller.state
const { loading, syncing, rows: rawRows, patientName } = toRefs(history)
const emptyDescription = computed(() => {
if (history.readError) return '聊天记录读取失败,请重试'
if (history.syncError || history.partialErrors.length) return '暂未读取到聊天记录,请重试同步'
if (syncing.value) return '正在同步聊天记录…'
return history.hasLoaded ? '暂无聊天记录' : '正在加载聊天记录…'
})
interface EnrichedRow { interface EnrichedRow {
raw: any raw: any
@@ -109,15 +136,20 @@ const MSG_TYPE_LABELS: Record<string, string> = {
} }
const rows = computed<EnrichedRow[]>(() => const rows = computed<EnrichedRow[]>(() =>
rawRows.value.map((row) => { rawRows.value.flatMap((row) => {
const fr = parseImFriendly(row) const messages = row.msg_type === 'composite' && Array.isArray(row.parts)
let tag = '' ? row.parts.map((part: any, index: number) => ({ ...row, ...part, msg_id: `${row.msg_id}:${index}` }))
if (fr?.tag) { : [row]
tag = fr.tag return messages.map((row: any) => {
} else { const fr = parseImFriendly(row)
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '') let tag = ''
} if (fr?.tag) {
return { raw: row, friendly: fr, tag } tag = fr.tag
} else {
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
return { raw: row, friendly: fr, tag }
})
}) })
) )
@@ -145,55 +177,38 @@ function senderLabel(row: any) {
return patientName.value ? `患者(${patientName.value}` : '患者' return patientName.value ? `患者(${patientName.value}` : '患者'
} }
async function load() {
if (!props.diagnosisId) return
loading.value = true
try {
const res = (await getImChatMessages({
diagnosis_id: props.diagnosisId,
only_archived: 1
})) as {
lists?: any[]
patient_im_id?: string
patient_name?: string
}
rawRows.value = res?.lists || []
patientImId.value = res?.patient_im_id || ''
patientName.value = res?.patient_name || ''
} catch (e) {
console.error(e)
rawRows.value = []
} finally {
loading.value = false
}
}
function reloadArchived() { function reloadArchived() {
load() return controller.reload()
} }
async function triggerSync() { function triggerSync() {
if (!props.diagnosisId) return return controller.sync()
syncing.value = true
try {
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
} catch (e) {
console.error(e)
ElMessage.error('发起同步失败')
} finally {
syncing.value = false
}
} }
watch( watch(
() => props.diagnosisId, () => props.diagnosisId,
() => { (diagnosisId) => controller.setDiagnosis(diagnosisId),
load()
},
{ immediate: true } { immediate: true }
) )
let activated = true
function updateVisibility() {
controller.setVisible(activated && documentVisible())
}
onMounted(() => document.addEventListener('visibilitychange', updateVisibility))
onActivated(() => {
activated = true
updateVisibility()
})
onDeactivated(() => {
activated = false
updateVisibility()
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', updateVisibility)
controller.dispose()
})
defineExpose({ refresh: reloadArchived }) defineExpose({ refresh: reloadArchived })
</script> </script>
@@ -212,6 +227,12 @@ defineExpose({ refresh: reloadArchived })
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
}
.sync-status {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
} }
.chat-wrap { .chat-wrap {
min-height: 120px; min-height: 120px;
+39 -15
View File
@@ -259,6 +259,19 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="挂号类型" min-width="120">
<template #default="{ row }">
<template v-if="appointmentRows(row).length">
<div v-for="apt in appointmentRows(row)" :key="apt.id || `${apt.doctor_id}-${apt.time_text}`" class="mb-2">
<div>{{ appointmentTypeDescription(apt.appointment_type) }}</div>
<div v-if="appointmentRows(row).length > 1" class="text-xs text-gray-500">
{{ apt.doctor_name || '—' }} · {{ apt.time_text || '—' }}
</div>
</div>
</template>
<span v-else class="apt-none"></span>
</template>
</el-table-column>
<el-table-column label="确认" width="88" align="center"> <el-table-column label="确认" width="88" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="isDiagnosisConfirmed(row)" class="status-confirmed">已确认</span> <span v-if="isDiagnosisConfirmed(row)" class="status-confirmed">已确认</span>
@@ -388,7 +401,7 @@
<el-icon><Remove /></el-icon>取消指派 <el-icon><Remove /></el-icon>取消指派
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item> <el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item> <el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && hasActiveAppointment(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item <el-dropdown-item
v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])" v-if="canCancelAppointmentFromDropdown(row) && hasPermission(['tcm.diagnosis/guahao'])"
command="cancelApt" command="cancelApt"
@@ -732,6 +745,7 @@ import { getDictData } from '@/api/app'
import { getWeappConfig } from '@/api/channel/weapp' import { getWeappConfig } from '@/api/channel/weapp'
import feedback from '@/utils/feedback' import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm' import { hasPermission } from '@/utils/perm'
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue' import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose, Remove } from '@element-plus/icons-vue'
import useUserStore from '@/stores/modules/user' import useUserStore from '@/stores/modules/user'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -1378,9 +1392,10 @@ type VideoCallHint = {
} }
const canWatchCallEntry = (row: any) => const canWatchCallEntry = (row: any) =>
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall']) isAppointmentActiveForVideo(row) && isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none' const watchCallState = (row: any) =>
isAppointmentActiveForVideo(row) ? (row.video_call_hint?.state as string) || 'none' : 'none'
const watchCallLiveTooltip = (row: any) => { const watchCallLiveTooltip = (row: any) => {
const h = row.video_call_hint as VideoCallHint | undefined const h = row.video_call_hint as VideoCallHint | undefined
@@ -1405,6 +1420,10 @@ const watchCallEnterTooltip = (row: any) => {
} }
const onWatchCallEntryClick = (row: any) => { const onWatchCallEntryClick = (row: any) => {
if (!canWatchCallEntry(row)) {
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
return
}
if (watchCallState(row) !== 'live') { if (watchCallState(row) !== 'live') {
feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试') feedback.msgWarning('医生尚未接通或未同步房间号,请稍后再试')
return return
@@ -1716,7 +1735,7 @@ const handleRowAction = (cmd: string, row: any) => {
case 'cancelAssign': handleCancelAssign(row); break case 'cancelAssign': handleCancelAssign(row); break
case 'videoQr': case 'videoQr':
if (!isAppointmentActiveForVideo(row)) { if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅已预约」状态可生成视频二维码') feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return return
} }
handleVideoQRCode(row) handleVideoQRCode(row)
@@ -1753,7 +1772,8 @@ const appointmentRows = (row: any) => {
status: row.appointment_status, status: row.appointment_status,
doctor_id: row.appointment_doctor_id, doctor_id: row.appointment_doctor_id,
doctor_name: row.appointment_doctor_name, doctor_name: row.appointment_doctor_name,
time_text: row.appointment_time_text time_text: row.appointment_time_text,
appointment_type: row.appointment_type
}] }]
} }
@@ -1779,12 +1799,14 @@ const appointmentCellClasses = (row: any) => {
* 找到当前可用的已预约挂号 * 找到当前可用的已预约挂号
* 同一诊单当天可能先完成一条挂号随后又新增一条预约此时行级 appointment_* 仍可能指向旧记录 * 同一诊单当天可能先完成一条挂号随后又新增一条预约此时行级 appointment_* 仍可能指向旧记录
*/ */
const activeAppointment = (row: any) => const activeAppointment = (row: any, videoOnly = false) =>
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null appointmentRows(row).find((apt: any) =>
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
) ?? null
/** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */ /** 二维码必须使用已预约挂号对应的医生和时间,不能继续沿用行级旧挂号字段。 */
const activeAppointmentRow = (row: any) => { const activeAppointmentRow = (row: any, videoOnly = false) => {
const apt = activeAppointment(row) const apt = activeAppointment(row, videoOnly)
if (!apt) return null if (!apt) return null
return { return {
@@ -1794,12 +1816,14 @@ const activeAppointmentRow = (row: any) => {
appointment_status: apt.status, appointment_status: apt.status,
appointment_doctor_id: apt.doctor_id, appointment_doctor_id: apt.doctor_id,
appointment_doctor_name: apt.doctor_name, appointment_doctor_name: apt.doctor_name,
appointment_time_text: apt.time_text appointment_time_text: apt.time_text,
appointment_type: apt.appointment_type
} }
} }
/** 任一挂号记录处于已预约(1)即可进视频/小程序码。 */ /** 诊单二维码适用于所有已预约挂号;视频入口仅适用于视频问诊。 */
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row) const hasActiveAppointment = (row: any) => !!activeAppointment(row)
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row, true)
/** 已预约、已过号可取消(后端同步限制),针对行上主字段 */ /** 已预约、已过号可取消(后端同步限制),针对行上主字段 */
const canCancelAppointmentRow = (row: any) => { const canCancelAppointmentRow = (row: any) => {
@@ -1983,11 +2007,11 @@ const submitFillIdCard = async () => {
} }
} }
// - (1) // -
const handleVideoQRCode = async (row: any) => { const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row) const qrcodeRow = activeAppointmentRow(row, true)
if (!qrcodeRow) { if (!qrcodeRow) {
feedback.msgWarning('仅已预约」状态可生成视频二维码') feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return return
} }
if (!qrcodeRow.patient_id) { if (!qrcodeRow.patient_id) {
+33 -13
View File
@@ -156,12 +156,14 @@
<span class="dh5-apt-status">{{ appointmentStatusLabelByStatus(apt.status) }}</span> <span class="dh5-apt-status">{{ appointmentStatusLabelByStatus(apt.status) }}</span>
<span class="dh5-apt-doctor">{{ apt.doctor_name || '-' }}</span> <span class="dh5-apt-doctor">{{ apt.doctor_name || '-' }}</span>
<span class="dh5-apt-time">{{ apt.time_text || '-' }}</span> <span class="dh5-apt-time">{{ apt.time_text || '-' }}</span>
<span class="dh5-apt-type">挂号类型{{ appointmentTypeDescription(apt.appointment_type) }}</span>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<span class="dh5-apt-status">{{ appointmentStatusLabel(row) }}</span> <span class="dh5-apt-status">{{ appointmentStatusLabel(row) }}</span>
<span class="dh5-apt-doctor">{{ row.appointment_doctor_name || '-' }}</span> <span class="dh5-apt-doctor">{{ row.appointment_doctor_name || '-' }}</span>
<span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span> <span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span>
<span class="dh5-apt-type">挂号类型{{ appointmentTypeDescription(row.appointment_type) }}</span>
</template> </template>
</template> </template>
<span v-else class="dh5-apt-none">未挂号</span> <span v-else class="dh5-apt-none">未挂号</span>
@@ -299,7 +301,7 @@
<span>视频二维码</span> <span>视频二维码</span>
</button> </button>
<button <button
v-if="isAppointmentActiveForVideo(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])" v-if="hasActiveAppointment(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
class="dh5-action" class="dh5-action"
@click="runAction('confirmQr')" @click="runAction('confirmQr')"
> >
@@ -585,6 +587,7 @@
</template> </template>
<script setup lang="ts" name="tcmDiagnosisH5"> <script setup lang="ts" name="tcmDiagnosisH5">
import { appointmentTypeDescription, canAppointmentVideoCall } from '@/utils/appointment-type'
import { import {
tcmDiagnosisLists, tcmDiagnosisLists,
tcmDiagnosisDelete, tcmDiagnosisDelete,
@@ -924,7 +927,8 @@ const appointmentRows = (row: any) => {
status: row.appointment_status, status: row.appointment_status,
doctor_id: row.appointment_doctor_id, doctor_id: row.appointment_doctor_id,
doctor_name: row.appointment_doctor_name, doctor_name: row.appointment_doctor_name,
time_text: row.appointment_time_text time_text: row.appointment_time_text,
appointment_type: row.appointment_type
}] }]
} }
@@ -949,11 +953,13 @@ const appointmentRowClass = (row: any) => {
/** /**
* 同一诊单可能同时有已完成的旧挂号和已预约的新挂号二维码应使用明细里的有效预约 * 同一诊单可能同时有已完成的旧挂号和已预约的新挂号二维码应使用明细里的有效预约
*/ */
const activeAppointment = (row: any) => const activeAppointment = (row: any, videoOnly = false) =>
appointmentRows(row).find((apt: any) => Number(apt?.status) === 1) ?? null appointmentRows(row).find((apt: any) =>
Number(apt?.status) === 1 && (!videoOnly || canAppointmentVideoCall(apt.appointment_type))
) ?? null
const activeAppointmentRow = (row: any) => { const activeAppointmentRow = (row: any, videoOnly = false) => {
const apt = activeAppointment(row) const apt = activeAppointment(row, videoOnly)
if (!apt) return null if (!apt) return null
return { return {
@@ -963,11 +969,13 @@ const activeAppointmentRow = (row: any) => {
appointment_status: apt.status, appointment_status: apt.status,
appointment_doctor_id: apt.doctor_id, appointment_doctor_id: apt.doctor_id,
appointment_doctor_name: apt.doctor_name, appointment_doctor_name: apt.doctor_name,
appointment_time_text: apt.time_text appointment_time_text: apt.time_text,
appointment_type: apt.appointment_type
} }
} }
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row) const hasActiveAppointment = (row: any) => !!activeAppointment(row)
const isAppointmentActiveForVideo = (row: any) => !!activeAppointment(row, true)
const canCancelAppointmentRow = (row: any) => { const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status) const s = Number(row.appointment_status)
@@ -982,8 +990,9 @@ const isAssignedAssistant = (row: any) => {
return Number(aid) === Number(userStore.userInfo?.id) return Number(aid) === Number(userStore.userInfo?.id)
} }
const canWatchCallEntry = (row: any) => const canWatchCallEntry = (row: any) =>
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall']) isAppointmentActiveForVideo(row) && isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none' const watchCallState = (row: any) =>
isAppointmentActiveForVideo(row) ? (row.video_call_hint?.state as string) || 'none' : 'none'
const watchCallShowEnterButton = (row: any) => { const watchCallShowEnterButton = (row: any) => {
const st = watchCallState(row) const st = watchCallState(row)
return st === 'live' || st === 'pending_room' return st === 'live' || st === 'pending_room'
@@ -1016,6 +1025,10 @@ const watchCallVisibleForRow = (row: any) => {
const watchCallDialogVisible = ref(false) const watchCallDialogVisible = ref(false)
const watchCallDiagnosisId = ref(0) const watchCallDiagnosisId = ref(0)
const onWatchCallEntryClick = (row: any) => { const onWatchCallEntryClick = (row: any) => {
if (!canWatchCallEntry(row)) {
feedback.msgWarning('仅已预约的视频问诊可进入视频旁观')
return
}
if (watchCallState(row) !== 'live') { if (watchCallState(row) !== 'live') {
feedback.msgWarning('医生未接通或未同步房间号,请稍后再试') feedback.msgWarning('医生未接通或未同步房间号,请稍后再试')
return return
@@ -1087,7 +1100,7 @@ const runAction = (cmd: string) => {
case 'assign': handleSingleAssign(row); break case 'assign': handleSingleAssign(row); break
case 'videoQr': case 'videoQr':
if (!isAppointmentActiveForVideo(row)) { if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅已预约」状态可生成视频二维码') feedback.msgWarning('仅已预约的视频问诊可生成视频二维码')
return return
} }
handleVideoQRCode(row); break handleVideoQRCode(row); break
@@ -1262,9 +1275,9 @@ const qrcodeAppointmentTimeText = computed(() => {
}) })
const handleVideoQRCode = async (row: any) => { const handleVideoQRCode = async (row: any) => {
const qrcodeRow = activeAppointmentRow(row) const qrcodeRow = activeAppointmentRow(row, true)
if (!qrcodeRow) { if (!qrcodeRow) {
feedback.msgWarning('仅已预约」状态可生成视频二维码'); return feedback.msgWarning('仅已预约的视频问诊可生成视频二维码'); return
} }
if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return } if (!qrcodeRow.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video' lastQRCodeType.value = 'video'
@@ -1807,6 +1820,7 @@ $dh5-card-bg: #ffffff;
.dh5-apt-item { .dh5-apt-item {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px 8px; padding: 4px 8px;
@@ -1836,6 +1850,12 @@ $dh5-card-bg: #ffffff;
margin-left: auto; margin-left: auto;
} }
.dh5-apt-type {
flex-basis: 100%;
color: $dh5-text-mute;
font-size: 12px;
}
.dh5-apt-none { .dh5-apt-none {
color: $dh5-warn; color: $dh5-warn;
font-weight: 600; font-weight: 600;
+146
View File
@@ -0,0 +1,146 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const vue = require('vue')
const { renderToString } = require('@vue/server-renderer')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const src = path.join(__dirname, '../src')
function moduleFrom(source, mockRequire = require, browserWindow = {}) {
const code = ts.transpileModule(source.replaceAll('import.meta.env.DEV', 'false'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', 'window', code)(mockRequire, module, module.exports, browserWindow)
return module.exports
}
const read = (file) => fs.readFileSync(path.join(src, file), 'utf8')
const types = moduleFrom(read('utils/appointment-type.ts'))
const { descriptor } = parse(read('components/chat-dialog/index.vue'))
const script = compileScript(descriptor, { id: 'appointment-call-mode' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'chat-dialog.vue', id: 'appointment-call-mode', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const render = moduleFrom(template.code).render
const passthrough = { setup: (_, { slots }) => () => vue.h('div', [slots.default?.(), slots.headerToolbar?.()]) }
const picker = text => ({ setup: () => () => vue.h('button', text) })
function chat(policy = {}) {
const requests = [], warnings = [], sdkCalls = []
const guard = moduleFrom(read('utils/appointment-call-guard.ts'))
const server = Object.fromEntries(['calls', 'call', 'groupCall'].map(name => [name, async params => { sdkCalls.push({ name, params }) }]))
const api = { getCallSignature: async params => { requests.push(params); return policy } }
const result = moduleFrom(script.content, name => {
if (name === 'vue') return { ...vue, onMounted() {}, onUnmounted() {} }
if (name === '@/utils/appointment-type') return types
if (name === '@/utils/appointment-call-guard') return guard
if (name === '@/utils/im-chat-archive-trigger') return moduleFrom(read('utils/im-chat-archive-trigger.ts'))
if (name === '@/api/tcm') return api
if (name === '@/utils/feedback') return { default: { msgWarning: text => warnings.push(text) } }
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: {} }) }
if (name === '@/utils/call-local-recorder') return { CallLocalRecorder: class {} }
if (name === '@tencentcloud/chat-uikit-vue3') return {
useLoginState: () => ({}), useConversationListState: () => ({ activeConversation: vue.ref(null) }),
Chat: passthrough, UIKitProvider: passthrough, MessageInput: passthrough, MessageList: passthrough,
EmojiPicker: picker('表情'), ImagePicker: picker('图片'), FilePicker: picker('文件'),
AudioCallPicker: picker('语音拨打'), VideoCallPicker: picker('视频拨打')
}
if (name === '@tencentcloud/call-uikit-vue') return { TUICallKitServer: server, TUICallType: { VIDEO_CALL: 2 }, TUIStore: {}, StoreName: {}, NAME: {} }
return {}
}).default.setup({}, { expose() {} })
result.visible.value = true
result.isReady.value = true
result.isCallReady.value = true
result.patientId.value = 50
result.diagnosisId.value = 70
result.appointmentId.value = 90
result.syncAppointmentCallPolicy(policy)
guard.registerAppointmentCallGuard(result.prepareAppointmentCall)
return { state: result, guard, server, requests, warnings, sdkCalls, api }
}
const policy = type => ({ appointment_id: 90, appointment_type: type, appointment_type_desc: types.appointmentTypeDescription(type), can_video_call: type === 'video', can_audio_call: type === 'video' || type === 'phone', call_disabled_reason: type === 'text' ? '图文问诊不支持音视频通话' : '当前挂号不支持该通话方式' })
test('type labels and video capability preserve legacy video while rejecting text and unknown', () => {
for (const value of ['text', 'phone', 'unknown']) assert.equal(types.canAppointmentVideoCall(value), false)
for (const value of ['video', '', ' ', null, undefined]) assert.equal(types.canAppointmentVideoCall(value), true)
assert.equal(types.appointmentTypeDescription('text'), '图文问诊')
})
test('rendered real chat template keeps text tools while excluding call controls in text mode', async () => {
async function markup(type) {
const { state } = chat(policy(type))
const app = vue.createSSRApp({ render: () => render({}, [], {}, vue.proxyRefs(state)) })
app.config.warnHandler = () => {}
for (const name of ['el-tag', 'el-button', 'el-tooltip', 'el-icon', 'el-alert']) app.component(name, passthrough)
const context = {}
await renderToString(app, context)
return context.teleports.body
}
const text = await markup('text')
assert.match(text, /图文问诊/)
for (const tool of ['表情', '图片', '文件']) assert.match(text, new RegExp(tool))
assert.doesNotMatch(text, /视频拨打|语音拨打|群视频/)
const video = await markup('video')
assert.match(video, /视频问诊/)
assert.match(video, /视频拨打/)
assert.match(video, /群视频/)
})
test('text mode hides all live call entries and refuses direct group invocation', async () => {
const { state, requests, sdkCalls } = chat(policy('text'))
assert.equal(state.appointmentTypeLabel.value, '图文问诊')
assert.equal(state.canVideoCall.value, false)
assert.equal(state.canAudioCall.value, false)
await state.startGroupVideoCall()
await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/)
assert.deepEqual(requests, [])
assert.deepEqual(sdkCalls, [])
assert.match(descriptor.template.content, /<VideoCallPicker v-if="isCallReady && canVideoCall"/)
assert.match(descriptor.template.content, /<AudioCallPicker v-if="isCallReady && canAudioCall"/)
})
test('all SDK outgoing paths stop before touching SDK when current appointment is text', async () => {
const { state, server, sdkCalls } = chat(policy('text'))
state.installTUICallKitRoomHooks()
for (const method of ['call', 'calls', 'groupCall']) await assert.rejects(server[method]({ type: 2 }), /图文问诊/)
assert.deepEqual(sdkCalls, [])
})
test('video preflight uses actual appointment and prevents a server-side change to text', async () => {
const { state, requests, api } = chat(policy('video'))
assert.equal(state.canVideoCall.value, true)
await state.prepareAppointmentCall(2)
assert.deepEqual(requests, [{ patient_id: 50, diagnosis_id: 70, appointment_id: 90 }])
api.getCallSignature = async () => policy('text')
await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/)
assert.equal(state.canVideoCall.value, false)
assert.equal(state.appointmentTypeLabel.value, '图文问诊')
})
test('historical phone is audio-only; missing server confirmation never enables video', async () => {
const { state } = chat(policy('phone'))
assert.equal(state.canAudioCall.value, true)
assert.equal(state.canVideoCall.value, false)
await state.prepareAppointmentCall(1)
await assert.rejects(state.prepareAppointmentCall(2))
state.syncAppointmentCallPolicy({ appointment_type: 'video' })
assert.equal(state.canVideoCall.value, false)
state.syncAppointmentCallPolicy({ appointment_type: null, appointment_type_desc: '未挂号', can_video_call: false, can_audio_call: false })
assert.equal(state.appointmentTypeLabel.value, '未挂号')
assert.equal(state.canVideoCall.value, false)
})
test('singleton call guard switches to current chat and cancels a pending old context', async () => {
const guard = moduleFrom(read('utils/appointment-call-guard.ts'))
let resolveOld
const releaseOld = guard.registerAppointmentCallGuard(() => new Promise(resolve => { resolveOld = resolve }))
const pending = guard.checkAppointmentOutgoingCall({ type: 2 })
const releaseNew = guard.registerAppointmentCallGuard(async () => { throw new Error('图文问诊') })
releaseOld()
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /图文问诊/)
resolveOld()
await assert.rejects(pending, /问诊已切换/)
releaseNew()
await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /先打开/)
})
@@ -0,0 +1,125 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const { execFileSync } = require('node:child_process')
const { DatabaseSync } = require('node:sqlite')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const root = path.resolve(__dirname, '../..')
const sql = JSON.parse(execFileSync(process.env.PHP_BINARY || 'php', [path.join(root, 'server/tests/AppointmentTypeFilterSqlFixture.php')], { encoding: 'utf8' }))
const db = new DatabaseSync(':memory:')
db.exec(`
CREATE TABLE doctor_appointment (id INTEGER, patient_id INTEGER, doctor_id INTEGER, assistant_id INTEGER, appointment_type TEXT, status INTEGER, appointment_date TEXT, appointment_time TEXT);
CREATE TABLE tcm_diagnosis (id INTEGER, patient_id INTEGER, patient_name TEXT, phone TEXT, gender INTEGER, age INTEGER, weight INTEGER, height INTEGER, assistant_id INTEGER, delete_time INTEGER);
CREATE TABLE admin (id INTEGER, name TEXT);
INSERT INTO tcm_diagnosis VALUES (100, 1000, '患者甲', '', 1, 30, 60, 170, 301, NULL), (101, 1001, '患者乙', '', 1, 31, 61, 171, 302, NULL), (102, 1002, '已删除', '', 1, 30, 60, 170, 301, 1);
INSERT INTO admin VALUES (201, '医生甲'), (202, '医生乙'), (301, '医助甲'), (302, '医助乙');
`)
const add = db.prepare('INSERT INTO doctor_appointment VALUES (?, ?, ?, 301, ?, ?, ?, ?)')
const fixtures = [
[1, 100, 201, 'video', 1],
[2, 100, 201, null, 1],
[3, 100, 201, '', 2],
[4, 101, 202, ' ', 3],
[5, 101, 202, '\t\r\n\v ', 4],
[6, 100, 201, 'text', 1],
[7, 101, 202, 'text', 3],
[8, 100, 201, 'phone', 1],
[9, 100, 201, 'unknown', 1],
[10, 100, 201, 'Video', 1],
[11, 100, 201, 'video ', 1],
[12, 102, 201, null, 1],
[13, 102, 201, 'text', 1]
]
for (const [id, patient, doctor, type, status] of fixtures) add.run(id, patient, doctor, type, status, '2026-09-09', `${String(id).padStart(2, '0')}:00:00`)
// SQLite's explicit BINARY collation has the same exact text comparison semantics used here.
// The remaining statements are the SQL emitted by the real ThinkPHP list/count/tab paths.
const execute = statement => db.prepare(statement.replace(/BINARY a\.appointment_type/g, 'a.appointment_type COLLATE BINARY')).all()
const result = name => ({
ids: execute(sql[name].lists).map(row => row.id),
count: execute(sql[name].count)[0].think_count,
tabs: Object.fromEntries(execute(sql[name].tabs).map(row => [row.status, row.cnt]))
})
test('video query includes legacy blanks and keeps list, total and status counts aligned', () => {
assert.deepEqual(result('video'), { ids: [1, 2, 3, 4, 5], count: 5, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
})
test('text query excludes video, unknown and historical phone records', () => {
assert.deepEqual(result('text'), { ids: [6, 7], count: 2, tabs: { 1: 1, 3: 1 } })
})
test('all and omitted filters retain historical types without admitting deleted diagnoses', () => {
for (const name of ['omitted', 'all', 'null']) {
const actual = result(name)
assert.equal(actual.count, 11)
assert.deepEqual(actual.ids.toSorted((a, b) => a - b), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
assert.equal(Object.values(actual.tabs).reduce((sum, count) => sum + count, 0), 11)
}
})
test('type filter composes with status, doctor and patient filters without OR leakage', () => {
assert.deepEqual(result('video_status_1'), { ids: [1, 2], count: 2, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
assert.deepEqual(result('text_doctor'), { ids: [6], count: 1, tabs: { 1: 1 } })
assert.deepEqual(result('video_patient'), { ids: [4, 5], count: 2, tabs: { 3: 1, 4: 1 } })
})
test('pagination limits only visible rows while count and tabs retain the full filtered set', () => {
assert.deepEqual(result('video_page'), { ids: [2, 3], count: 5, tabs: { 1: 2, 2: 1, 3: 1, 4: 1 } })
})
test('unsupported filter values return no rows or counts instead of being converted to video', () => {
for (const name of Object.keys(sql).filter(name => name.startsWith('invalid_'))) {
assert.deepEqual(result(name), { ids: [], count: 0, tabs: {} }, name)
}
})
function moduleFrom(source, dependencies = require, globals = {}) {
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), compiled)(dependencies, module, module.exports, ...Object.values(globals))
return module.exports
}
const pageFile = path.join(root, 'admin/src/views/consumer/prescription/guahao.vue')
const { descriptor, errors } = parse(fs.readFileSync(pageFile, 'utf8'), { filename: pageFile })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'appointment-type-filter' })
test('actual page and paging hook send type on query and clear it together with all fields on reset', async () => {
const calls = []
const paging = moduleFrom(fs.readFileSync(path.join(root, 'admin/src/hooks/usePaging.ts'), 'utf8'))
const component = moduleFrom(script.content, name => {
if (name === 'vue') return vue
if (name === '@/hooks/usePaging') return paging
if (name === '@/api/doctor') return { appointmentLists: async params => { calls.push({ ...params }); return { lists: [], count: 0 } } }
if (name === '@/utils/appointment-type') return { appointmentTypeDescription: value => value }
return {}
}, { reactive: vue.reactive, ref: vue.ref, computed: vue.computed, onMounted() {}, onActivated() {} })
const page = component.default.setup({}, { expose() {} })
assert.equal(calls[0].appointment_type, '')
page.pager.page = 4
page.queryParams.appointment_type = 'text'
page.queryParams.patient_name = '张'
page.resetPage()
assert.equal(calls.at(-1).page_no, 1)
assert.equal(calls.at(-1).appointment_type, 'text')
assert.equal(calls.at(-1).patient_name, '张')
page.pager.page = 3
page.queryParams.appointment_type = 'video'
page.resetPage()
assert.equal(calls.at(-1).appointment_type, 'video')
page.resetFilter()
assert.equal(calls.at(-1).appointment_type, '')
assert.equal(calls.at(-1).patient_name, '')
assert.equal(calls.at(-1).page_no, 1)
await Promise.resolve()
})
test('updated filter template compiles with existing page bindings', () => {
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: pageFile, id: 'appointment-type-filter', compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
})
@@ -0,0 +1,89 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../src/utils/im-chat-archive-trigger.ts'), 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
const mod = { exports: {} }
new Function('module', 'exports', compiled)(mod, mod.exports)
const { createImChatArchiveTrigger } = mod.exports
const deferred = () => {
let resolve, reject
const promise = new Promise((a, b) => { resolve = a; reject = b })
return { promise, resolve, reject }
}
test('all pages use server-owned current-doctor scope and carry the continuation token', async () => {
const calls = []
const sync = createImChatArchiveTrigger(async params => {
calls.push(params)
return { completed: calls.length === 3, sync_token: 'next-page' }
})
await sync(12116)
assert.deepEqual(calls, [
{ diagnosis_id: 12116, scope: 'current', sync_token: undefined },
{ diagnosis_id: 12116, scope: 'current', sync_token: 'next-page' },
{ diagnosis_id: 12116, scope: 'current', sync_token: 'next-page' }
])
})
test('many events while syncing coalesce, then perform one catch-up scan for new messages', async () => {
const page = deferred()
let calls = 0
const sync = createImChatArchiveTrigger(() => {
calls++
return calls === 1 ? page.promise : Promise.resolve({ completed: true })
})
const first = sync(10)
assert.equal(sync(10), first)
assert.equal(sync(10), first)
assert.equal(calls, 1)
page.resolve({ completed: true })
await first
assert.equal(calls, 2)
await sync(10)
assert.equal(calls, 3)
})
test('switching patients keeps in-flight requests bound to their original diagnosis', async () => {
const old = deferred(), calls = []
const sync = createImChatArchiveTrigger(params => {
calls.push(params)
if (params.diagnosis_id === 10 && !params.sync_token) return old.promise
return Promise.resolve({ completed: true })
})
const first = sync(10)
await sync(20)
old.resolve({ completed: false, sync_token: 'old-patient' })
await first
assert.deepEqual(calls.map(p => [p.diagnosis_id, p.sync_token]), [[10, undefined], [20, undefined], [10, 'old-patient']])
})
test('partial failure and network failure are reported and do not poison later retries', async () => {
const warnings = []
let count = 0
const sync = createImChatArchiveTrigger(async () => {
count++
if (count === 1) return { completed: true, errors: ['cloud unavailable'] }
if (count === 2) throw new Error('request timeout')
return { completed: true }
}, error => warnings.push(error.message))
await sync(10)
await sync(10)
await sync(10)
assert.deepEqual(warnings, ['cloud unavailable', 'request timeout'])
assert.equal(count, 3)
})
test('invalid IDs never request and missing progress stops a malformed response loop', async () => {
const warnings = []
let count = 0
const sync = createImChatArchiveTrigger(async () => { count++; return { completed: false } }, error => warnings.push(error.message))
for (const id of [0, -1, NaN, 1.5]) await sync(id)
assert.equal(count, 0)
await sync(10)
assert.equal(count, 1)
assert.match(warnings[0], /未返回进度/)
})
+392
View File
@@ -0,0 +1,392 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
function loadTs(filename, dependencies = require) {
const source = fs.readFileSync(filename, 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports)
return module.exports
}
const { createImChatHistory } = loadTs(path.join(__dirname, '../src/utils/im-chat-history.ts'))
const archive = (id, text = '已归档消息') => ({ lists: [{ msg_id: id, text }], patient_im_id: `patient_${id}`, patient_name: `患者${id}` })
const progress = (completed, extra = {}) => ({ completed, sync_token: 'session-token', inserted: 2, processed_peers: 1, total_peers: 2, ...extra })
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const settle = async () => { for (let i = 0; i < 50; i++) await Promise.resolve() }
function fixture(overrides = {}) {
let time = 100000, nextTimer = 0
const timers = new Map(), calls = [], notices = []
const controller = createImChatHistory({
load: async (id) => { calls.push(['load', id]); return overrides.load ? overrides.load(id) : archive(id) },
sync: async (id, token) => { calls.push(['sync', id, token]); return overrides.sync ? overrides.sync(id, token) : progress(true) },
notify: (kind, message) => notices.push({ kind, message }),
now: () => time,
setTimer: (callback, delay) => { const id = ++nextTimer; timers.set(id, { callback, at: time + delay }); return id },
clearTimer: (id) => timers.delete(id),
visible: overrides.visible
})
return {
controller, state: controller.state, calls, notices, timers,
setTime(value) { time = value },
advance(ms) {
time += ms
for (const [id, timer] of [...timers]) {
if (timer.at <= time) { timers.delete(id); timer.callback() }
}
}
}
}
test('archive appears immediately while automatic cloud sync is pending, then refreshes on completion', async () => {
const pending = deferred()
let reads = 0
const f = fixture({ load: async () => archive(++reads), sync: () => pending.promise })
f.controller.setDiagnosis(10)
await settle()
assert.equal(f.state.rows[0].msg_id, 1)
assert.equal(f.state.syncing, true)
assert.equal(f.state.loading, false)
assert.equal(f.timers.size, 0)
pending.resolve(progress(true))
await settle()
assert.equal(f.state.rows[0].msg_id, 2)
assert.equal(f.state.syncing, false)
assert.equal(f.timers.size, 1)
assert.deepEqual(f.notices, [])
f.controller.dispose()
})
test('account verification progress and skipped staff are displayed separately from sync failures', async () => {
const checked = deferred(), finished = deferred()
let step = 0
const f = fixture({ sync: async () => {
step++
if (step === 1) return progress(false, { phase: 'checking_accounts', checked_accounts: 100, candidate_accounts: 185, skipped_accounts: 98 })
if (step === 2) return checked.promise
return finished.promise
} })
f.controller.setDiagnosis(1)
await settle()
assert.equal(f.state.phase, 'checking_accounts')
assert.equal(f.state.checkedAccounts, 100)
assert.equal(f.state.candidateAccounts, 185)
assert.deepEqual(f.state.partialErrors, [])
checked.resolve(progress(false, { phase: 'syncing', checked_accounts: 185, candidate_accounts: 185, skipped_accounts: 183, total_peers: 1 }))
await settle()
assert.equal(f.state.totalPeers, 1)
assert.equal(f.state.skippedAccounts, 183)
finished.resolve(progress(true, { phase: 'completed', skipped_accounts: 183, total_peers: 1 }))
await settle()
assert.equal(f.state.syncError, '')
assert.deepEqual(f.state.partialErrors, [])
f.controller.setDiagnosis(2)
assert.equal(f.state.skippedAccounts, 0)
f.controller.dispose()
})
test('completion waits for initial archive read and starts a fresh read after it', async () => {
const initial = deferred()
let reads = 0
const f = fixture({ load: () => ++reads === 1 ? initial.promise : Promise.resolve(archive(2)) })
f.controller.setDiagnosis(10)
await settle()
assert.equal(reads, 1)
initial.resolve(archive(1))
await settle()
assert.equal(reads, 2)
assert.equal(f.state.rows[0].msg_id, 2)
assert.equal(f.timers.size, 1)
f.controller.dispose()
})
test('sync tokens continue across pages and archive refreshes every three pages and on completion', async () => {
const lastPage = deferred()
let pages = 0, reads = 0
const f = fixture({
load: async () => archive(++reads),
sync: async () => ++pages < 4 ? progress(false, { sync_token: `token-${pages}`, inserted: pages }) : lastPage.promise
})
f.controller.setDiagnosis(10)
await settle()
assert.equal(pages, 4)
assert.equal(reads, 2)
assert.deepEqual(f.calls.filter(call => call[0] === 'sync').map(call => call[2]), [undefined, 'token-1', 'token-2', 'token-3'])
assert.equal(f.state.inserted, 3)
lastPage.resolve(progress(true, { inserted: 4, processed_peers: 2 }))
await settle()
assert.equal(reads, 3)
assert.equal(f.state.inserted, 4)
f.controller.dispose()
})
test('a slow page refreshes archive after two seconds even before three pages', async () => {
const lastPage = deferred()
let pages = 0, reads = 0
const f = fixture({
load: async () => archive(++reads),
sync: async () => {
if (++pages === 1) { f.setTime(102100); return progress(false) }
return lastPage.promise
}
})
f.controller.setDiagnosis(10)
await settle()
assert.equal(reads, 2)
assert.equal(f.state.syncing, true)
f.controller.dispose()
lastPage.resolve(progress(true))
await settle()
})
test('switching diagnosis ignores old archive, old sync errors, and old completion timers', async () => {
const oldRead = deferred(), oldSync = deferred()
const f = fixture({ load: (id) => id === 1 ? oldRead.promise : Promise.resolve(archive(id)), sync: (id) => id === 1 ? oldSync.promise : Promise.resolve(progress(true)) })
f.controller.setDiagnosis(1)
await settle()
f.controller.setDiagnosis(2)
await settle()
oldRead.resolve(archive(1))
oldSync.reject(new Error('旧患者同步失败'))
await settle()
assert.equal(f.state.patientName, '患者2')
assert.equal(f.state.rows[0].msg_id, 2)
assert.equal(f.state.syncError, '')
assert.equal(f.timers.size, 1)
assert.equal(f.calls.filter(call => call[0] === 'load' && call[1] === 1).length, 1)
f.controller.dispose()
})
test('dispose invalidates in-flight requests and never refreshes or schedules after completion', async () => {
const pending = deferred()
const f = fixture({ sync: () => pending.promise })
f.controller.setDiagnosis(1)
await settle()
const before = f.calls.length
f.controller.dispose()
pending.resolve(progress(true))
await settle()
assert.equal(f.calls.length, before)
assert.equal(f.timers.size, 0)
assert.equal(f.state.syncing, false)
f.controller.setVisible(true)
f.controller.setDiagnosis(2)
assert.equal(f.calls.length, before)
})
test('read and sync failures preserve existing rows and expose the original errors', async () => {
let readFails = false, syncFails = false
const f = fixture({
load: async () => { if (readFails) throw { response: { data: { msg: '归档服务读取失败' } } }; return archive(1) },
sync: async () => { if (syncFails) throw new Error('腾讯云凭证无效'); return progress(true) }
})
f.controller.setDiagnosis(1)
await settle()
readFails = true
await f.controller.reload()
assert.equal(f.state.rows[0].msg_id, 1)
assert.equal(f.state.readError, '归档服务读取失败')
syncFails = true
await f.controller.sync()
assert.equal(f.state.rows[0].msg_id, 1)
assert.equal(f.state.syncError, '腾讯云凭证无效')
assert.deepEqual(f.notices, [{ kind: 'error', message: '腾讯云凭证无效' }])
assert.equal(f.timers.size, 1)
f.controller.dispose()
})
test('only a manual completed sync emits success; concurrent clicks share the in-flight run', async () => {
let pending
const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) })
f.controller.setDiagnosis(1)
await settle()
assert.deepEqual(f.notices, [])
pending = deferred()
const manual = f.controller.sync()
const duplicate = f.controller.sync()
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
assert.deepEqual(f.notices, [])
pending.resolve(progress(true, { inserted: 7 }))
await Promise.all([manual, duplicate])
assert.deepEqual(f.notices, [{ kind: 'success', message: '聊天记录已更新,本次新增 7 条' }])
assert.equal(f.timers.size, 1)
f.controller.dispose()
})
test('completed partial failure refreshes rows, ends the loop, and never reports full success', async () => {
let partial = false, reads = 0
const f = fixture({ load: async () => archive(++reads), sync: async () => progress(true, partial ? { error: '医生账号失败', errors: ['医生账号失败', '医助账号失败'] } : {}) })
f.controller.setDiagnosis(1)
await settle()
partial = true
await f.controller.sync()
assert.deepEqual([...f.state.partialErrors], ['医生账号失败', '医助账号失败'])
assert.equal(f.state.rows[0].msg_id, 3)
assert.equal(f.state.syncing, false)
assert.equal(f.notices.length, 1)
assert.equal(f.notices[0].kind, 'warning')
assert.match(f.notices[0].message, /医生账号失败;医助账号失败/)
f.controller.dispose()
})
test('missing token and missing completed state stop safely without claiming success', async () => {
for (const response of [{ completed: false }, { inserted: 0 }]) {
const f = fixture({ sync: async () => response })
f.controller.setDiagnosis(1)
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1)
assert.match(f.state.syncError, /同步接口未返回/)
assert.equal(f.state.rows[0].msg_id, 1)
assert.equal(f.notices.length, 0)
f.controller.dispose()
}
})
test('one 30-second timer starts only after completion and never overlaps a pending run', async () => {
let pending
const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) })
f.controller.setDiagnosis(1)
await settle()
f.advance(29999)
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1)
pending = deferred()
f.advance(1)
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
assert.equal(f.timers.size, 0)
f.advance(90000)
await settle()
assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2)
pending.resolve(progress(true))
await settle()
assert.equal(f.timers.size, 1)
f.controller.dispose()
assert.equal(f.timers.size, 0)
})
test('hidden or deactivated panels stop scheduling and resume with existing rows retained', async () => {
const f = fixture({ visible: false })
f.controller.setDiagnosis(1)
await settle()
assert.deepEqual(f.calls, [])
f.controller.setVisible(true)
await settle()
assert.equal(f.state.rows[0].msg_id, 1)
f.controller.setVisible(false)
assert.equal(f.timers.size, 0)
const before = f.calls.length
f.advance(60000)
await settle()
assert.equal(f.calls.length, before)
f.controller.setVisible(true)
assert.equal(f.state.rows[0].msg_id, 1)
await settle()
assert.equal(f.timers.size, 1)
f.controller.dispose()
})
test('IM API functions retain raw backend reasons, send progress tokens, and disable retries', async () => {
const calls = []
let reply = { code: 1, data: progress(true), msg: '不应自动显示的成功提示', show: 1 }
const request = {}
for (const method of ['get', 'post']) request[method] = async (config, options) => { calls.push({ method, config, options }); return reply }
const api = loadTs(path.join(__dirname, '../src/api/tcm.ts'), (name) => {
if (name === '@/utils/request') return { default: request }
throw new Error(`Unexpected import: ${name}`)
})
assert.equal((await api.triggerImChatSync({ diagnosis_id: 2, sync_token: 'resume' })).completed, true)
reply = { code: 1, data: archive(2) }
assert.equal((await api.getImChatMessages({ diagnosis_id: 2, only_archived: 1 })).lists[0].msg_id, 2)
assert.deepEqual(calls[0].config.data, { diagnosis_id: 2, sync_token: 'resume' })
for (const call of calls) {
assert.equal(call.config.timeout, 30000)
assert.deepEqual(call.options, { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 })
}
reply = { code: 0, data: [], msg: '腾讯云错误:签名校验失败' }
await assert.rejects(api.triggerImChatSync({ diagnosis_id: 2 }), /腾讯云错误:签名校验失败/)
})
test('chat panel script, template, and scoped styles compile', () => {
const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue')
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'im-chat-history' })
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'im-chat-history', compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'im-chat-history', scoped: true, preprocessLang: 'scss' }).errors, [])
})
test('actual panel setup wires archived reads, continued sync, patient switches, and lifecycle cleanup', async () => {
const vue = require('vue')
const hooks = {}, listeners = new Map(), timers = new Map(), apiCalls = []
const pending = deferred()
let timerId = 0
const originalDocument = global.document
global.document = {
visibilityState: 'visible',
addEventListener: (name, callback) => listeners.set(name, callback),
removeEventListener: (name) => listeners.delete(name)
}
const scope = vue.effectScope()
try {
const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue')
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'), { filename })
const script = compileScript(descriptor, { id: 'im-chat-panel-setup' })
const compiled = ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', compiled)((name) => {
if (name === 'vue') return { ...vue, ...Object.fromEntries(['onMounted', 'onBeforeUnmount', 'onActivated', 'onDeactivated'].map(hook => [hook, callback => { hooks[hook] = callback }])) }
if (name === 'dayjs') return { default: require('dayjs') }
if (name === '@element-plus/icons-vue') return {}
if (name === 'element-plus') return { ElMessage: { success() {}, warning() {}, error() {} } }
if (name === '@/utils/im-business-message-parse') return { parseImBusinessPayload: () => null }
if (name === '@/utils/im-chat-history') return { createImChatHistory: (deps) => createImChatHistory({ ...deps, setTimer: callback => { const id = ++timerId; timers.set(id, callback); return id }, clearTimer: id => timers.delete(id) }) }
if (name === '@/api/tcm') return {
getImChatMessages: async params => { apiCalls.push(['read', params]); return archive(params.diagnosis_id) },
triggerImChatSync: async params => { apiCalls.push(['sync', params]); return params.diagnosis_id === 1 ? pending.promise : progress(true) }
}
throw new Error(`Unexpected panel import: ${name}`)
}, module, module.exports)
const props = vue.reactive({ diagnosisId: 1 })
const panel = scope.run(() => module.exports.default.setup(props, { expose() {} }))
hooks.onMounted()
await settle()
assert.equal(panel.rows.value[0].raw.msg_id, 1)
assert.deepEqual(apiCalls[0], ['read', { diagnosis_id: 1, only_archived: 1 }])
assert.deepEqual(apiCalls[1], ['sync', { diagnosis_id: 1 }])
props.diagnosisId = 2
await settle()
assert.equal(panel.rows.value[0].raw.msg_id, 2)
pending.resolve(progress(true))
await settle()
assert.equal(panel.rows.value[0].raw.msg_id, 2)
assert.equal(timers.size, 1)
panel.history.rows = [{
msg_id: 'multi-part', msg_type: 'composite', from_account: 'doctor_20', to_account: 'patient_2', time: 1700000000,
parts: [{ msg_type: 'text', text: '第一段' }, { msg_type: 'image', image_url: 'https://example.invalid/chat.png' }, { msg_type: 'text', text: '第三段' }]
}]
assert.deepEqual(panel.rows.value.map(item => [item.raw.msg_id, item.raw.msg_type]), [
['multi-part:0', 'text'], ['multi-part:1', 'image'], ['multi-part:2', 'text']
])
assert.equal(panel.rows.value[2].raw.text, '第三段')
assert.ok(panel.rows.value.every(item => item.raw.to_account === 'patient_2'))
global.document.visibilityState = 'hidden'
listeners.get('visibilitychange')()
assert.equal(timers.size, 0)
hooks.onBeforeUnmount()
assert.equal(listeners.size, 0)
} finally {
scope.stop()
global.document = originalDocument
}
})
@@ -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,362 @@
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, /企微请求超时/)
})
test('direct batch sync snapshots and deduplicates selection, limits concurrency, and waits for refresh', async () => {
const pools = [pool(1), pool(2), pool(3), pool(4)]
const pending = []
const unrelatedCalls = []
let active = 0
let maximum = 0
let refreshCount = 0
let finishRefresh
const api = Object.fromEntries([
'wecomPromotionBatchUpdatePools', 'wecomPromotionSavePool', 'wecomPromotionSaveMember',
'wecomPromotionBatchSetOperators', 'wecomPromotionSyncCustomers', 'wecomPromotionToggleMember'
].map((name) => [name, async (payload) => { unrelatedCalls.push({ name, payload }); return {} }]))
const { state, messages } = page({
...api,
wecomPromotionOverview: () => {
refreshCount++
return new Promise((resolve) => { finishRefresh = () => resolve({ pools }) })
},
wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
active++
maximum = Math.max(maximum, active)
pending.push({ id: pool_id, finish: () => {
active--
resolve({ pool_id, sync_status: 'synced', sync_error: '', sync_queued: false })
} })
})
}, pools)
state.selectedPoolIds.value = [1, 1, 2, 3]
let completed = false
const syncing = state.syncSelectedMemberRanges().then(() => { completed = true })
assert.equal(state.syncingBatchMembers.value, true)
assert.deepEqual(pending.map((item) => item.id), [1, 2])
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 3 })
// A changed ref must not alter the already-dispatched batch's work list.
state.selectedPoolIds.value = [4]
pending[0].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
assert.equal(state.batchSyncProgress.completed, 1)
assert.equal(refreshCount, 0)
pending[1].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(state.batchSyncProgress.completed, 2)
assert.equal(completed, false)
assert.equal(refreshCount, 0)
pending[2].finish()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(maximum, 2)
assert.equal(refreshCount, 1)
assert.equal(state.syncingBatchMembers.value, true)
assert.equal(state.batchSyncProgress.completed, 3)
assert.equal(completed, false)
finishRefresh()
await syncing
assert.equal(state.syncingBatchMembers.value, false)
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 0 })
assert.deepEqual(state.selectedPoolIds.value, [])
assert.deepEqual(state.operationResults.value.map((item) => item.id), [1, 2, 3])
assert.ok(state.operationResults.value.every((item) => item.sync_only && item.sync_status === 'synced'))
assert.deepEqual(unrelatedCalls, [])
assert.equal(messages.at(-1).type, 'success')
assert.match(messages.at(-1).message, /已确认同步 3 个/)
assert.doesNotMatch(messages.at(-1).message, /本地.*保存/)
for (const result of state.operationResults.value) {
assert.match(state.operationResultText(result), /已确认同步/)
assert.doesNotMatch(state.operationResultText(result), /本地.*保存/)
}
})
test('direct batch sync keeps failed, pending and blocked pools selected with precise results', async () => {
const pools = [pool(1), pool(2), pool(3), { ...pool(4), official_link: null }, { ...pool(5), can_operate: false }]
const calls = []
let refreshCount = 0
const { state, messages } = page({
wecomPromotionOverview: async () => { refreshCount++; return { pools } },
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
if (pool_id === 2) throw new Error('企微请求超时,请检查网络')
return { pool_id, sync_status: pool_id === 1 ? 'synced' : 'pending', sync_queued: pool_id === 3, sync_error: '' }
}
}, pools)
state.selectedPoolIds.value = [1, 2, 3, 4, 5]
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [1, 2, 3])
assert.equal(refreshCount, 1)
assert.deepEqual(state.selectedPoolIds.value, [2, 3, 4, 5])
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', 'blocked', 'blocked'])
assert.ok(state.operationResults.value.every((item) => item.sync_only))
assert.match(state.operationResults.value[1].sync_error, /企微请求超时,请检查网络/)
assert.equal(state.operationResults.value[2].sync_queued, true)
assert.match(state.operationResultText(state.operationResults.value[3]), /链接/)
assert.match(state.operationResultText(state.operationResults.value[4]), /权限|无权/)
for (const result of state.operationResults.value) assert.doesNotMatch(state.operationResultText(result), /本地.*保存/)
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /已确认同步 1 个/)
assert.match(messages.at(-1).message, /4 个尚未确认同步/)
assert.doesNotMatch(messages.at(-1).message, /本地.*保存/)
assert.equal(state.syncingBatchMembers.value, false)
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 0 })
})
test('direct batch sync prevents duplicate requests and freezes member and selection changes while busy', async () => {
const pools = [pool(1), pool(2)]
const calls = []
const toggles = []
let finish
const { state } = page({
wecomPromotionSyncMemberRange: ({ pool_id }) => {
calls.push(pool_id)
return new Promise((resolve) => { finish = () => resolve({ pool_id, sync_status: 'synced' }) })
},
wecomPromotionToggleMember: async (payload) => { toggles.push(payload); return { sync_status: 'synced' } }
}, pools)
state.selectedPoolIds.value = [1]
const syncing = state.syncSelectedMemberRanges()
await state.syncSelectedMemberRanges()
await state.syncMemberRange(2)
await state.handleMemberToggle(member(2), false)
state.togglePoolSelection(pools[0], false)
state.togglePoolSelection(pools[1], true)
assert.deepEqual(state.selectedPoolIds.value, [1])
state.toggleAllPoolSelection(false)
assert.deepEqual(state.selectedPoolIds.value, [1])
state.toggleAllPoolSelection(true)
assert.deepEqual(state.selectedPoolIds.value, [1])
assert.deepEqual(calls, [1])
assert.deepEqual(toggles, [])
finish()
await syncing
assert.equal(state.syncingBatchMembers.value, false)
})
test('direct batch sync cannot begin while a single sync, member toggle or batch save is active', async () => {
const calls = []
const { state } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { sync_status: 'synced' } } })
state.selectedPoolIds.value = [1]
for (const [busy, value] of [[state.syncingPoolId, 1], [state.togglingMemberId, 2], [state.savingBatchConfig, true]]) {
busy.value = value
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [])
assert.equal(state.syncingBatchMembers.value, false)
busy.value = typeof value === 'boolean' ? false : 0
}
})
test('direct batch sync rejects empty and oversized selections but accepts 100 unique pools', async () => {
const pools = Array.from({ length: 101 }, (_, index) => pool(index + 1))
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
return { pool_id, sync_status: 'synced' }
} }, pools)
await state.syncSelectedMemberRanges()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /选择|勾选/)
assert.deepEqual(calls, [])
state.selectedPoolIds.value = pools.map((item) => item.id)
await state.syncSelectedMemberRanges()
assert.equal(messages.at(-1).type, 'warning')
assert.match(messages.at(-1).message, /100/)
assert.deepEqual(calls, [])
assert.equal(state.syncingBatchMembers.value, false)
state.selectedPoolIds.value = [...pools.slice(0, 100).map((item) => item.id), 1]
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, pools.slice(0, 100).map((item) => item.id))
assert.equal(messages.at(-1).type, 'success')
})
test('shared operators can select and sync pools while access and configuration stay manager-only', async () => {
const pools = [
{ ...pool(1), can_manage_access: false },
{ ...pool(2), can_operate: false },
{ ...pool(3), can_operate: false, can_manage_access: false },
pool(4)
]
const calls = []
const { state, messages } = page({ wecomPromotionSyncMemberRange: async ({ pool_id }) => {
calls.push(pool_id)
return { pool_id, sync_status: 'synced' }
} }, pools)
assert.deepEqual(state.selectablePoolIds.value, [1, 2, 4])
state.togglePoolSelection(pools[0], true)
state.togglePoolSelection(pools[2], true)
await vue.nextTick()
assert.deepEqual(state.selectedPoolIds.value, [1])
state.openAccessDialog()
assert.equal(state.accessDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
state.openBatchConfigDialog()
assert.equal(state.batchConfigDialogVisible.value, false)
assert.equal(messages.at(-1).type, 'warning')
await state.syncSelectedMemberRanges()
assert.deepEqual(calls, [1])
state.toggleAllPoolSelection(true)
assert.deepEqual(state.selectedPoolIds.value, [1, 2, 4])
state.openAccessDialog()
assert.deepEqual(state.accessForm.pool_ids, [2, 4])
state.openBatchConfigDialog()
assert.deepEqual(state.batchConfigForm.pool_ids, [2, 4])
state.overview.pools[0].can_operate = false
await vue.nextTick()
assert.deepEqual(state.selectedPoolIds.value, [2, 4])
})
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.
+93
View File
@@ -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.
+29
View File
@@ -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`, 100900; 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.
+24 -2
View File
@@ -169,8 +169,30 @@ try {
& $Npm run build --prefix $CompanionRoot & $Npm run build --prefix $CompanionRoot
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" } if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
& $Python -m PyInstaller --noconfirm --clean $Spec $BuildPythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" } if ($LASTEXITCODE -ne 0 -or -not $BuildPythonBase) {
throw "Unable to resolve the build Python runtime directory"
}
# Dependency scanning must not collect unrelated ICU/OpenSSL libraries from
# an editor's helper tools (for example Poppler) ahead of Windows libraries.
$PreviousBuildPath = $env:PATH
$BuildRuntimePaths = @(
(Split-Path -Parent $Python),
$BuildPythonBase,
(Join-Path $BuildPythonBase "DLLs"),
(Join-Path $env:SystemRoot "System32"),
$env:SystemRoot,
(Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0")
)
try {
$env:PATH = ($BuildRuntimePaths | Select-Object -Unique) -join [System.IO.Path]::PathSeparator
& $Python -m PyInstaller --noconfirm --clean $Spec
$PyInstallerExitCode = $LASTEXITCODE
}
finally {
$env:PATH = $PreviousBuildPath
}
if ($PyInstallerExitCode -ne 0) { throw "PyInstaller build failed" }
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation" $Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1 $Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
@@ -8,10 +8,10 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet from doctor_workstation.core import PermissionSet
from doctor_workstation.ui import apply_theme
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog
from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
@@ -480,12 +480,7 @@ def _run_immediately(
def render() -> list[Path]: def render() -> list[Path]:
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
font_path = Path("C:/Windows/Fonts/msyh.ttc") apply_theme(app)
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
diagnosis_module.run_async = _run_immediately diagnosis_module.run_async = _run_immediately
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual" output = root / "artifacts" / "diagnosis_visual"
@@ -8,7 +8,6 @@ from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository from doctor_workstation.services import DemoDoctorRepository
@@ -19,13 +18,6 @@ def render() -> list[Path]:
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
apply_theme(app) apply_theme(app)
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual" output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True) output.mkdir(parents=True, exist_ok=True)
+3 -9
View File
@@ -9,10 +9,11 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import Qt, QThreadPool, Signal from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
from PySide6.QtWidgets import QApplication, QToolButton, QWidget from PySide6.QtWidgets import QApplication, QToolButton, QWidget
from doctor_workstation.core import PermissionSet from doctor_workstation.core import PermissionSet
from doctor_workstation.ui import apply_theme
from doctor_workstation.ui.pages import consultations as consultations_module from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import ConsultationsPage from doctor_workstation.ui.pages.consultations import ConsultationsPage
@@ -339,14 +340,7 @@ def _save_with_payment_qr(
def _application() -> QApplication: def _application() -> QApplication:
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
# The offscreen Windows plugin does not enumerate system fonts. Register apply_theme(app)
# the same CJK face used by the production QSS when it is available.
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
return app return app
@@ -9,7 +9,6 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication, QWidget from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.core.permissions import PermissionSet from doctor_workstation.core.permissions import PermissionSet
@@ -136,12 +135,6 @@ def _settle(app: QApplication, rounds: int = 8) -> None:
def render() -> list[Path]: def render() -> list[Path]:
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
apply_theme(app) apply_theme(app)
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact" output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
output.mkdir(parents=True, exist_ok=True) output.mkdir(parents=True, exist_ok=True)
@@ -8,7 +8,6 @@ from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from doctor_workstation.services.mock_repository import DemoDoctorRepository from doctor_workstation.services.mock_repository import DemoDoctorRepository
@@ -19,9 +18,6 @@ from doctor_workstation.ui.theme import apply_theme
def main() -> int: def main() -> int:
application = QApplication.instance() or QApplication([]) application = QApplication.instance() or QApplication([])
apply_theme(application) apply_theme(application)
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
if font_path.is_file():
QFontDatabase.addApplicationFont(str(font_path))
repository = DemoDoctorRepository() repository = DemoDoctorRepository()
session = repository.login("doctor", "doctor123") session = repository.login("doctor", "doctor123")
+93
View File
@@ -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()
+1 -1
View File
@@ -3,7 +3,7 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"] __all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions. # Single source of truth for runtime, package, installer, and executable versions.
__version__ = "1.2.0" __version__ = "1.4.2"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。 # 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。 # 正式发布请保持 False;此时程序只使用下面配置的线上域名。
+27 -7
View File
@@ -313,7 +313,7 @@ class DemoVideoDialog(QDialog):
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}" "background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}" "QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}" "QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}" "QPushButton#Hangup{color:#FFFFFF;background:#C23D4E;border-color:#C23D4E;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}" "QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
) )
@@ -326,7 +326,7 @@ class DemoVideoDialog(QDialog):
header.addWidget(title) header.addWidget(title)
header.addStretch(1) header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云") demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#7886AA;font-size:12px;") demo.setStyleSheet("color:#707584;font-size:12px;")
header.addWidget(demo) header.addWidget(demo)
self.duration_label = QLabel("00:00") self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;") self.duration_label.setStyleSheet("font-weight:700;")
@@ -413,6 +413,7 @@ class ApplicationController(QObject):
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._pending_im_request: tuple[str, object] | None = None
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
@@ -726,6 +727,7 @@ class ApplicationController(QObject):
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()
self._pending_im_request = None
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()
@@ -747,6 +749,7 @@ class ApplicationController(QObject):
return return
patient_id = payload.get("patient_id") patient_id = payload.get("patient_id")
diagnosis_id = payload.get("diagnosis_id") diagnosis_id = payload.get("diagnosis_id")
appointment_id = int(payload.get("appointment_id") or 0)
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")
@@ -754,7 +757,15 @@ class ApplicationController(QObject):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200) show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return return
call_key = str(diagnosis_id) call_key = f"{diagnosis_id}:{appointment_id}"
if open_im and self._pending_im_request is not None:
pending_key, pending_marker = self._pending_im_request
if pending_key != call_key:
# Retire a previous selection before any early return, including
# when the selected IM window is already open.
if self.video_pending.get(pending_key) is pending_marker:
self.video_pending.pop(pending_key, None)
self._pending_im_request = None
existing_call = self.video_calls.get(call_key) existing_call = self.video_calls.get(call_key)
if open_im and existing_call is not None and getattr(existing_call, "open_im", False): if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
qt_window = getattr(existing_call, "qt_window", None) qt_window = getattr(existing_call, "qt_window", None)
@@ -802,12 +813,17 @@ class ApplicationController(QObject):
repository = self.current_repository repository = self.current_repository
marker = object() marker = object()
self.video_pending[call_key] = marker self.video_pending[call_key] = marker
if open_im:
self._pending_im_request = (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),
appointment_id=appointment_id,
) )
if appointment_id and int(ticket.raw.get("appointment_id") or 0) != appointment_id:
raise ValueError("通话凭证与本次挂号不匹配,请重新打开聊天窗口。")
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):
@@ -871,6 +887,8 @@ class ApplicationController(QObject):
if self.video_pending.get(call_key) is not marker: if self.video_pending.get(call_key) is not marker:
return return
self.video_pending.pop(call_key, None) self.video_pending.pop(call_key, None)
if self._pending_im_request == (call_key, marker):
self._pending_im_request = None
if self.shell_window is parent: if self.shell_window is parent:
show_toast( show_toast(
parent, parent,
@@ -895,6 +913,8 @@ class ApplicationController(QObject):
if self.video_pending.get(call_key) is not marker: if self.video_pending.get(call_key) is not marker:
return return
self.video_pending.pop(call_key, None) self.video_pending.pop(call_key, None)
if self._pending_im_request == (call_key, marker):
self._pending_im_request = None
if ( if (
self.shell_window is None self.shell_window is None
or self.current_repository is not repository or self.current_repository is not repository
@@ -919,8 +939,8 @@ class ApplicationController(QObject):
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, current_key=call_key: (
self._open_video_diagnosis(current_id) self._open_video_diagnosis(current_id, call_key=current_key)
), ),
) )
except Exception as error: except Exception as error:
@@ -942,7 +962,7 @@ class ApplicationController(QObject):
) )
) )
def _open_video_diagnosis(self, diagnosis_id: Any) -> None: def _open_video_diagnosis(self, diagnosis_id: Any, *, call_key: str) -> 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
@@ -951,7 +971,7 @@ class ApplicationController(QObject):
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(call_key)
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)
@@ -0,0 +1,48 @@
"""Appointment medium labels, separate from first-visit/follow-up diagnosis type."""
from collections.abc import Mapping
from typing import Any
APPOINTMENT_MODES = (("视频问诊", "video"), ("图文问诊", "text"))
def appointment_type_description(value: Any) -> str:
if value is None or isinstance(value, str) and not value.strip():
return "视频问诊"
return {"video": "视频问诊", "text": "图文问诊", "phone": "电话问诊"}.get(str(value), "未知")
def can_appointment_video(value: Any) -> bool:
"""UI hint only; actual call permission must come from the server ticket."""
return value is None or isinstance(value, str) and (not value.strip() or value == "video")
def appointment_type_value(record: Any) -> Any:
"""Read appointment rows and diagnosis-list projections without visit-type aliases."""
def read(value: Any, key: str) -> Any:
if isinstance(value, Mapping):
return value.get(key)
result = getattr(value, key, None)
raw = getattr(value, "raw", None)
return raw.get(key) if result is None and isinstance(raw, Mapping) else result
appointment_id = read(record, "appointment_id") or read(record, "latest_appointment_id")
rows = read(record, "appointments")
if isinstance(rows, (list, tuple)) and rows:
if appointment_id:
for row in rows:
if str(read(row, "id")) == str(appointment_id):
return read(row, "appointment_type")
elif len(rows) == 1:
return read(rows[0], "appointment_type")
else:
return "unknown"
for key in ("appointment_type", "latest_appointment_type"):
value = read(record, key)
if value is not None and value != "":
return value
for key in ("appointment", "latest_appointment"):
nested = read(record, key)
if nested is not None:
return read(nested, "appointment_type")
return None
@@ -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 [
@@ -3022,7 +3022,7 @@ class DemoDoctorRepository:
raise ValueError("order_no is required") raise ValueError("order_no is required")
return {"qrcode_url": f"https://demo.invalid/qrcode/order/{clean}.png"} return {"qrcode_url": f"https://demo.invalid/qrcode/order/{clean}.png"}
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket: def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
"""Return non-production placeholder credentials for UI demonstration.""" """Return non-production placeholder credentials for UI demonstration."""
with self._lock: with self._lock:
@@ -3036,21 +3036,22 @@ class DemoDoctorRepository:
assistant_id="assistant_2001", assistant_id="assistant_2001",
diagnosis_id=diagnosis_id, diagnosis_id=diagnosis_id,
is_lochost_vod=False, is_lochost_vod=False,
raw={"demo": True}, raw={"demo": True, "appointment_id": appointment_id},
) )
def start_call( def start_call(
self, diagnosis_id: int, patient_id: int, *, call_type: int = 2 self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Create a mutable demo call record.""" """Create a mutable demo call record."""
with self._lock: with self._lock:
self.get_call_ticket(patient_id, diagnosis_id) self.get_call_ticket(patient_id, diagnosis_id, appointment_id=appointment_id)
record = { record = {
"id": self._next_call_id, "id": self._next_call_id,
"diagnosis_id": diagnosis_id, "diagnosis_id": diagnosis_id,
"patient_id": patient_id, "patient_id": patient_id,
"call_type": call_type, "call_type": call_type,
"appointment_id": appointment_id,
"status": "ringing", "status": "ringing",
"room_id": "", "room_id": "",
} }
@@ -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."""
@@ -712,10 +712,10 @@ class DoctorRepository(Protocol):
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]: def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
"""Generate the payment mini-program QR code for a generic order.""" """Generate the payment mini-program QR code for a generic order."""
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket: def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
"""Return short-lived call credentials.""" """Return short-lived call credentials."""
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any: def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0) -> Any:
"""Create a call record.""" """Create a call record."""
def end_call( def end_call(
@@ -976,17 +976,20 @@ class RemoteDoctorRepository:
payload = self.client.get("doctor.appointment/detail", {"id": appointment_id}) payload = self.client.get("doctor.appointment/detail", {"id": appointment_id})
return dict(_require_mapping(payload, "doctor.appointment/detail")) return dict(_require_mapping(payload, "doctor.appointment/detail"))
def list_departments(self) -> list[dict[str, Any]]: def list_departments(self, *, apply_data_scope: bool = False) -> list[dict[str, Any]]:
"""Load the department tree through ``dept.dept/all``.""" """Load ``dept.dept/all`` with optional server-enforced role data scope."""
payload = self.client.get("dept.dept/all") payload = self.client.get(
if isinstance(payload, list): "dept.dept/all", {"apply_data_scope": 1} if apply_data_scope else None
return [dict(row) for row in payload if isinstance(row, Mapping)] )
if isinstance(payload, Mapping): for _depth in range(5):
rows = payload.get("lists", payload.get("data", payload.get("tree"))) if isinstance(payload, list):
if isinstance(rows, list): return [dict(row) for row in payload if isinstance(row, Mapping)]
return [dict(row) for row in rows if isinstance(row, Mapping)] if isinstance(payload, Mapping):
return [] payload = payload.get("lists", payload.get("data", payload.get("tree")))
else:
break
raise ApiProtocolError("部门数据格式异常,请重试")
def list_reception_queue( def list_reception_queue(
self, self,
@@ -2057,6 +2060,9 @@ class RemoteDoctorRepository:
"""List diagnosis records using ``tcm.diagnosis/lists``.""" """List diagnosis records using ``tcm.diagnosis/lists``."""
request_filters = dict(filters) request_filters = dict(filters)
department_id = request_filters.pop("department_id", None)
if department_id not in (None, ""):
request_filters.setdefault("assistant_dept_id", department_id)
start_date = str(request_filters.pop("start_date", "") or "").strip() start_date = str(request_filters.pop("start_date", "") or "").strip()
end_date = str(request_filters.pop("end_date", "") or "").strip() end_date = str(request_filters.pop("end_date", "") or "").strip()
if start_date and start_date == end_date: if start_date and start_date == end_date:
@@ -2623,22 +2629,51 @@ class RemoteDoctorRepository:
) )
return result return result
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> CallTicket: def get_call_ticket(self, patient_id: int, diagnosis_id: int, *, appointment_id: int = 0) -> CallTicket:
"""Obtain short-lived Tencent credentials for a consultation call.""" """Obtain short-lived Tencent credentials for a consultation call."""
result = _require_mapping( result = _require_mapping(
self.client.post( self.client.post(
"tcm.diagnosis/getCallSignature", "tcm.diagnosis/getCallSignature",
{"patient_id": patient_id, "diagnosis_id": diagnosis_id}, {"patient_id": patient_id, "diagnosis_id": diagnosis_id, "appointment_id": appointment_id},
), ),
"tcm.diagnosis/getCallSignature", "tcm.diagnosis/getCallSignature",
) )
policy_fields = {
"appointment_id", "appointmentId", "appointment_type", "appointment_type_desc",
"can_video_call", "can_audio_call", "call_disabled_reason",
}
if appointment_id > 0 and not policy_fields.intersection(result):
# Older signature endpoints authenticate the patient/diagnosis but
# do not echo the appointment. Keep the requested chat context;
# never treat it as server authorization for an audio/video call.
if (
str(result.get("diagnosis_id", "")).strip() != str(diagnosis_id)
or str(result.get("patient_id", "")).strip() != str(patient_id)
or result.get("patientUserId") != f"patient_{patient_id}"
):
raise ValueError("聊天凭证与当前患者或诊单不匹配,请重新打开聊天窗口。")
result = dict(result)
result.update(
appointment_id=appointment_id,
can_video_call=False,
can_audio_call=False,
call_disabled_reason="暂未获取到本次挂号的通话权限,仍可图文聊天",
)
if appointment_id > 0:
returned_id = result.get("appointment_id")
if (
isinstance(returned_id, bool)
or not isinstance(returned_id, (int, str))
or str(returned_id).strip() != str(appointment_id)
):
raise ValueError("通话凭证与本次挂号不匹配,请重新打开聊天窗口。")
ticket = CallTicket.from_dict(result) ticket = CallTicket.from_dict(result)
if ticket.diagnosis_id is None: if ticket.diagnosis_id is None:
ticket.diagnosis_id = diagnosis_id ticket.diagnosis_id = diagnosis_id
return ticket return ticket
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any: def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2, appointment_id: int = 0) -> Any:
"""Create the server-side call record before ringing participants.""" """Create the server-side call record before ringing participants."""
payload = self.client.post( payload = self.client.post(
@@ -2647,6 +2682,7 @@ class RemoteDoctorRepository:
"diagnosis_id": diagnosis_id, "diagnosis_id": diagnosis_id,
"patient_id": patient_id, "patient_id": patient_id,
"call_type": call_type, "call_type": call_type,
"appointment_id": appointment_id,
}, },
) )
if not isinstance(payload, Mapping): if not isinstance(payload, Mapping):
@@ -42,6 +42,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ..core.appointment_modes import APPOINTMENT_MODES
from .widgets import ( from .widgets import (
display_text, display_text,
first_value, first_value,
@@ -55,26 +56,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 +86,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 +109,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 +123,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 +154,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 +238,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 +322,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 +351,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 +418,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 +432,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 +718,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 +747,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 +780,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):
@@ -971,10 +976,21 @@ class AppointmentDrawer(QDialog):
self.appointment_type_radio = QRadioButton("视频问诊") self.appointment_type_radio = QRadioButton("视频问诊")
self.appointment_type_radio.setChecked(True) self.appointment_type_radio.setChecked(True)
type_layout.addWidget(self.appointment_type_radio) type_layout.addWidget(self.appointment_type_radio)
self.text_appointment_type_radio = QRadioButton("图文问诊")
type_layout.addWidget(self.text_appointment_type_radio)
self.appointment_type_group = QButtonGroup(self)
self.appointment_type_group.addButton(self.appointment_type_radio, 0)
self.appointment_type_group.addButton(self.text_appointment_type_radio, 1)
type_layout.addStretch(1) type_layout.addStretch(1)
self._add_form_row("预约类型:", type_container) self._add_form_row("预约类型:", type_container)
self.appointment_type = QComboBox(self) self.appointment_type = QComboBox(self)
self.appointment_type.addItem("视频问诊", "video") for label, value in APPOINTMENT_MODES:
self.appointment_type.addItem(label, value)
self.appointment_type_group.idClicked.connect(self.appointment_type.setCurrentIndex)
self.appointment_type.currentIndexChanged.connect(
lambda index: self.appointment_type_group.button(index).setChecked(True)
if self.appointment_type_group.button(index) is not None else None
)
self.appointment_type.hide() self.appointment_type.hide()
patient_container = QWidget() patient_container = QWidget()
@@ -1705,7 +1721,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()
+282 -135
View File
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ...core.appointment_modes import appointment_type_description, appointment_type_value
from ..diagnosis_drawer import ( from ..diagnosis_drawer import (
DIAGNOSIS_QSS, DIAGNOSIS_QSS,
CaseGrid, CaseGrid,
@@ -59,7 +60,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 +197,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 +765,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 +838,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 +854,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 +894,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 +929,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 +974,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 +1009,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 +1037,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 +1447,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 +1544,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 +1709,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 +1755,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 +1766,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 +1824,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 +1859,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 +1868,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 +1894,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 +1981,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 +2014,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 +2032,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 +2071,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 +2144,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 +2174,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 +2230,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 +2262,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 +2281,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 +2370,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 +2696,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 +2711,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 +2807,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 +3493,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 +3543,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:
@@ -3438,7 +3590,7 @@ class DiagnosisDialog(QDialog):
first_value(row, "assistant_name"), first_value(row, "assistant_name"),
first_value(row, "appointment_date"), first_value(row, "appointment_date"),
first_value(row, "appointment_time", "period"), first_value(row, "appointment_time", "period"),
first_value(row, "appointment_type_text", "appointment_type"), appointment_type_description(appointment_type_value(row)),
" / ".join( " / ".join(
part part
for part in ( for part in (
@@ -4009,6 +4161,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 +4835,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))
+794
View File
@@ -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"]
+76 -95
View File
@@ -21,6 +21,7 @@ from PySide6.QtGui import (
QResizeEvent, QResizeEvent,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QCheckBox, QCheckBox,
QFrame, QFrame,
QGraphicsDropShadowEffect, QGraphicsDropShadowEffect,
@@ -43,7 +44,7 @@ from PySide6.QtWidgets import (
from doctor_workstation import __version__ from doctor_workstation import __version__
from doctor_workstation.resources import app_icon_path, brand_lockup_path from doctor_workstation.resources import app_icon_path, brand_lockup_path
from .theme import crisp_pixmap from . import icons
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
@@ -71,7 +72,7 @@ class _VisibleCheckBox(QCheckBox):
) )
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#FFFFFF" if self.isEnabled() else "#98A2B3") color = QColor("#FFFFFF" if self.isEnabled() else "#8E8F90")
painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.drawLine( painter.drawLine(
QPoint(indicator.left() + 4, indicator.center().y()), QPoint(indicator.left() + 4, indicator.center().y()),
@@ -90,7 +91,7 @@ class _AccountLineEdit(QLineEdit):
super().paintEvent(event) super().paintEvent(event)
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(_round_pen("#8292B6", 1.5)) painter.setPen(_round_pen("#6A6B6D", 1.5))
painter.setBrush(Qt.BrushStyle.NoBrush) painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(QRectF(24, 14.5, 8, 8)) painter.drawEllipse(QRectF(24, 14.5, 8, 8))
painter.drawRoundedRect(QRectF(19, 27, 18, 9), 4.5, 4.5) painter.drawRoundedRect(QRectF(19, 27, 18, 9), 4.5, 4.5)
@@ -104,7 +105,7 @@ class _DemoCheckBox(_VisibleCheckBox):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
center = QPointF(self.width() - 9, self.height() / 2) center = QPointF(self.width() - 9, self.height() / 2)
painter.setPen(_round_pen("#92A0BF", 1.4)) painter.setPen(_round_pen("#8E8F90", 1.4))
painter.setBrush(Qt.BrushStyle.NoBrush) painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(center, 7, 7) painter.drawEllipse(center, 7, 7)
painter.drawLine(center + QPointF(0, -1), center + QPointF(0, 4)) painter.drawLine(center + QPointF(0, -1), center + QPointF(0, 4))
@@ -112,10 +113,9 @@ class _DemoCheckBox(_VisibleCheckBox):
def _font(pixel_size: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont: def _font(pixel_size: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
font = QFont("Microsoft YaHei UI") font = QFont(QApplication.font())
font.setPixelSize(pixel_size) font.setPixelSize(pixel_size)
font.setWeight(weight) font.setWeight(weight)
font.setHintingPreference(QFont.HintingPreference.PreferFullHinting)
return font return font
@@ -291,10 +291,10 @@ class _BrandPanel(QWidget):
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight()) background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
background.setColorAt(0.0, QColor("#FFFFFF")) background.setColorAt(0.0, QColor("#FFFFFF"))
background.setColorAt(0.7, QColor("#FEFEFF")) background.setColorAt(0.7, QColor("#FFFFFF"))
background.setColorAt(1.0, QColor("#F9FBFF")) background.setColorAt(1.0, QColor("#F7F7F7"))
painter.setBrush(background) painter.setBrush(background)
painter.setPen(QPen(QColor("#E4E9F4"), 1)) painter.setPen(QPen(QColor("#EDEDEE"), 1))
painter.drawRoundedRect(bounds, 24, 24) painter.drawRoundedRect(bounds, 24, 24)
width, height = float(self.width()), float(self.height()) width, height = float(self.width()), float(self.height())
@@ -315,15 +315,15 @@ class _BrandPanel(QWidget):
tag_rect = QRectF(left, 238, 119, 40) tag_rect = QRectF(left, 238, 119, 40)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#F0F2FF")) painter.setBrush(QColor("#EEF1FA"))
painter.drawRoundedRect(tag_rect, 11, 11) painter.drawRoundedRect(tag_rect, 11, 11)
painter.setFont(_font(17, QFont.Weight.DemiBold)) painter.setFont(_font(17, QFont.Weight.Medium))
painter.setPen(QColor("#5265F6")) painter.setPen(QColor("#4F63D9"))
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站") painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
copy_left = left + 4 copy_left = left + 4
painter.setFont(_font(51, QFont.Weight.Bold)) painter.setFont(_font(48, QFont.Weight.Medium))
painter.setPen(QColor("#14224A")) painter.setPen(QColor("#1A1C1F"))
painter.drawText(QPointF(copy_left, 354), "把诊间工作,") painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
painter.drawText(QPointF(copy_left, 424), "留在一个") painter.drawText(QPointF(copy_left, 424), "留在一个")
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个") prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
@@ -333,8 +333,8 @@ class _BrandPanel(QWidget):
copy_left + prefix_width + 264, copy_left + prefix_width + 264,
0, 0,
) )
highlight.setColorAt(0.0, QColor("#4258EC")) highlight.setColorAt(0.0, QColor("#4F63D9"))
highlight.setColorAt(1.0, QColor("#6975FF")) highlight.setColorAt(1.0, QColor("#4F63D9"))
painter.setPen(QPen(QBrush(highlight), 1)) painter.setPen(QPen(QBrush(highlight), 1))
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里") painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
suffix_x = ( suffix_x = (
@@ -342,19 +342,19 @@ class _BrandPanel(QWidget):
+ prefix_width + prefix_width
+ painter.fontMetrics().horizontalAdvance("安静的界面里") + painter.fontMetrics().horizontalAdvance("安静的界面里")
) )
painter.setPen(QColor("#14224A")) painter.setPen(QColor("#1A1C1F"))
painter.drawText(QPointF(suffix_x, 424), "") painter.drawText(QPointF(suffix_x, 424), "")
body_left = left + 6 body_left = left + 6
painter.setFont(_font(20)) painter.setFont(_font(20))
painter.setPen(QColor("#7181A7")) painter.setPen(QColor("#606163"))
painter.drawText( painter.drawText(
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现," QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
) )
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。") painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
painter.setFont(_font(16)) painter.setFont(_font(16))
painter.setPen(QColor("#7484A9")) painter.setPen(QColor("#6A6B6D"))
painter.drawText( painter.drawText(
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用" QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
) )
@@ -366,7 +366,7 @@ class _RevealButton(QToolButton):
del event del event
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#8796B8" if self.isEnabled() else "#B8C0D1") color = QColor("#6A6B6D" if self.isEnabled() else "#BDBDBE")
painter.setPen(_round_pen(color, 2)) painter.setPen(_round_pen(color, 2))
painter.setBrush(Qt.BrushStyle.NoBrush) painter.setBrush(Qt.BrushStyle.NoBrush)
eye = QPainterPath(QPointF(7, self.height() / 2)) eye = QPainterPath(QPointF(7, self.height() / 2))
@@ -392,11 +392,11 @@ class _ServerButton(QPushButton):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75) rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
painter.setBrush(QColor("#F8FAFF") if self.underMouse() else QColor("#FFFFFF")) painter.setBrush(QColor("#FFFFFF") if self.underMouse() else QColor("#FFFFFF"))
painter.setPen(QPen(QColor("#D8DFEE"), 1.5)) painter.setPen(QPen(QColor("#E4E4E5"), 1.5))
painter.drawRoundedRect(rect, 12, 12) painter.drawRoundedRect(rect, 12, 12)
color = QColor("#17264B" if self.isEnabled() else "#A2ABC0") color = QColor("#1A1C1F" if self.isEnabled() else "#8E8F90")
painter.setPen(_round_pen("#7C8DB2", 1.8)) painter.setPen(_round_pen("#6A6B6D", 1.8))
center = QPointF(29, self.height() / 2) center = QPointF(29, self.height() / 2)
painter.drawEllipse(center, 8, 8) painter.drawEllipse(center, 8, 8)
painter.drawEllipse(center, 2.8, 2.8) painter.drawEllipse(center, 2.8, 2.8)
@@ -420,7 +420,7 @@ class _ServerButton(QPushButton):
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
"服务器设置", "服务器设置",
) )
painter.setPen(_round_pen("#94A1BC", 2)) painter.setPen(_round_pen("#8E8F90", 2))
x, y = self.width() - 28, self.height() / 2 x, y = self.width() - 28, self.height() / 2
if self.isChecked(): if self.isChecked():
painter.drawLine(QPointF(x - 5, y + 3), QPointF(x, y - 3)) painter.drawLine(QPointF(x - 5, y + 3), QPointF(x, y - 3))
@@ -482,12 +482,8 @@ class LoginWindow(QMainWindow):
canvas.setStyleSheet( canvas.setStyleSheet(
""" """
QWidget#LoginCanvas { QWidget#LoginCanvas {
color: #17264B; color: #1A1C1F;
background: qlineargradient( background-color: #F4F6FA;
x1:0, y1:0, x2:1, y2:1,
stop:0 #F8FAFF, stop:0.58 #FBFCFF, stop:1 #F1F5FF
);
font-family: "Microsoft YaHei UI";
font-size: 16px; font-size: 16px;
} }
QWidget#LoginBrandPanel { QWidget#LoginBrandPanel {
@@ -496,28 +492,28 @@ class LoginWindow(QMainWindow):
} }
QFrame#LoginCard { QFrame#LoginCard {
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #E1E6F0; border: 1px solid #EDEDEE;
border-radius: 20px; border-radius: 20px;
} }
QFrame#LoginCard QFrame#SubtleCard { QFrame#LoginCard QFrame#SubtleCard {
background-color: #F8FAFF; background-color: #FFFFFF;
border: 1px solid #DCE2EF; border: 1px solid #E4E4E5;
border-radius: 12px; border-radius: 12px;
} }
QFrame#LoginCard QLabel { color: #17264B; background: transparent; } QFrame#LoginCard QLabel { color: #1A1C1F; background: transparent; }
QFrame#LoginCard QLabel[role="muted"] { color: #7382A5; } QFrame#LoginCard QLabel[role="muted"] { color: #606163; }
QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; } QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; }
QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; } QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; }
QFrame#LoginCard QLineEdit, QFrame#LoginCard QLineEdit,
QFrame#LoginCard QSpinBox { QFrame#LoginCard QSpinBox {
color: #17264B; color: #1A1C1F;
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #D6DEED; border: 1px solid #E4E4E5;
border-radius: 12px; border-radius: 12px;
padding: 0 16px; padding: 0 16px;
font-size: 17px; font-size: 17px;
selection-background-color: #E5E9FF; selection-background-color: #EEF1FA;
selection-color: #17264B; selection-color: #1A1C1F;
} }
QFrame#LoginCard QLineEdit#AccountEdit { QFrame#LoginCard QLineEdit#AccountEdit {
min-height: 52px; min-height: 52px;
@@ -525,9 +521,9 @@ class LoginWindow(QMainWindow):
padding-left: 52px; padding-left: 52px;
} }
QFrame#LoginCard QLineEdit:hover, QFrame#LoginCard QLineEdit:hover,
QFrame#LoginCard QSpinBox:hover { border-color: #9AA8FF; } QFrame#LoginCard QSpinBox:hover { border-color: #8B9AD9; }
QFrame#LoginCard QLineEdit:focus, QFrame#LoginCard QLineEdit:focus,
QFrame#LoginCard QSpinBox:focus { border: 1.5px solid #7080F7; } QFrame#LoginCard QSpinBox:focus { border: 1.5px solid #8B9AD9; }
QFrame#LoginCard QLineEdit#PasswordEdit { QFrame#LoginCard QLineEdit#PasswordEdit {
border: 0; border: 0;
border-radius: 0; border-radius: 0;
@@ -537,31 +533,31 @@ class LoginWindow(QMainWindow):
QFrame#LoginCard QCheckBox#DemoModeCheck { spacing: 10px; } QFrame#LoginCard QCheckBox#DemoModeCheck { spacing: 10px; }
QFrame#PasswordField { QFrame#PasswordField {
background-color: #FFFFFF; background-color: #FFFFFF;
border: 1px solid #D6DEED; border: 1px solid #E4E4E5;
border-radius: 12px; border-radius: 12px;
} }
QFrame#PasswordField:focus-within { border-color: #7080F7; } QFrame#PasswordField:focus-within { border-color: #8B9AD9; }
QFrame#LoginCard QCheckBox { QFrame#LoginCard QCheckBox {
color: #6F7FA3; color: #606163;
spacing: 13px; spacing: 13px;
font-size: 16px; font-size: 16px;
} }
QFrame#LoginCard QCheckBox::indicator { QFrame#LoginCard QCheckBox::indicator {
width: 22px; width: 22px;
height: 22px; height: 22px;
border: 1px solid #CFD8EB; border: 1px solid #E4E4E5;
border-radius: 6px; border-radius: 6px;
background-color: #FFFFFF; background-color: #FFFFFF;
} }
QFrame#LoginCard QCheckBox::indicator:hover { border-color: #7A8AF8; } QFrame#LoginCard QCheckBox::indicator:hover { border-color: #8B9AD9; }
QFrame#LoginCard QCheckBox::indicator:checked { QFrame#LoginCard QCheckBox::indicator:checked {
border-color: #6475F5; border-color: #4F63D9;
background-color: #6475F5; background-color: #4F63D9;
} }
QFrame#LoginCard QToolButton#PasswordReveal { QFrame#LoginCard QToolButton#PasswordReveal {
min-width: 87px; min-width: 87px;
max-width: 87px; max-width: 87px;
color: #8290B0; color: #6A6B6D;
background: transparent; background: transparent;
border: 0; border: 0;
padding: 0; padding: 0;
@@ -570,14 +566,11 @@ class LoginWindow(QMainWindow):
min-height: 58px; min-height: 58px;
max-height: 58px; max-height: 58px;
color: #FFFFFF; color: #FFFFFF;
background: qlineargradient( background-color: #4F63D9;
x1:0, y1:0, x2:1, y2:0,
stop:0 #5B6BF1, stop:0.55 #6675FA, stop:1 #5865F2
);
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 500;
} }
QFrame#LoginCard QPushButton#ServerSettingsToggle { QFrame#LoginCard QPushButton#ServerSettingsToggle {
min-height: 56px; min-height: 56px;
@@ -585,17 +578,17 @@ class LoginWindow(QMainWindow):
padding: 0; padding: 0;
} }
QFrame#LoginCard QPushButton[variant="primary"]:hover { QFrame#LoginCard QPushButton[variant="primary"]:hover {
background-color: #5262ED; background-color: #4156C4;
} }
QFrame#LoginCard QPushButton[variant="secondary"] { QFrame#LoginCard QPushButton[variant="secondary"] {
color: #4353BD; color: #1A1C1F;
background-color: #EDF0FF; background-color: #F0F0F0;
border: 1px solid #D3DAFC; border: 1px solid #E4E4E5;
border-radius: 9px; border-radius: 9px;
} }
QFrame#LoginCard QPushButton[variant="secondary"]:hover { QFrame#LoginCard QPushButton[variant="secondary"]:hover {
background-color: #DCE3FF; background-color: #E4E4E5;
border-color: #8B98F8; border-color: #8B9AD9;
} }
QScrollArea#LoginAreaScroll, QScrollArea#LoginAreaScroll,
QScrollArea#LoginAreaScroll > QWidget > QWidget { QScrollArea#LoginAreaScroll > QWidget > QWidget {
@@ -629,6 +622,12 @@ class LoginWindow(QMainWindow):
spacing = 20 spacing = 20
self.login_root.setContentsMargins(*margins) self.login_root.setContentsMargins(*margins)
self.login_root.setSpacing(spacing) self.login_root.setSpacing(spacing)
# Keep the form fully visible when the 60/40 desktop split would
# otherwise crop its fixed-width card. Small windows focus on login.
self.brand_panel.setVisible(width >= 1120)
self.login_area_layout.setContentsMargins(
0, min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
)
super().resizeEvent(event) super().resizeEvent(event)
def _build_brand_panel(self) -> QWidget: def _build_brand_panel(self) -> QWidget:
@@ -647,20 +646,19 @@ class LoginWindow(QMainWindow):
area.setFrameShape(QFrame.Shape.NoFrame) area.setFrameShape(QFrame.Shape.NoFrame)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
area.setMinimumWidth(492)
content = QWidget() content = QWidget()
content.setObjectName("LoginAreaContent") content.setObjectName("LoginAreaContent")
area.setWidget(content) area.setWidget(content)
self.login_scroll = area self.login_scroll = area
outer = QVBoxLayout(content) outer = QVBoxLayout(content)
# The supplied 1536×1024 capture contains a 60 px native title bar. self.login_area_layout = outer
# Its card begins at y=198, i.e. y=138 in the 1536×964 client area. outer.setContentsMargins(0, 40, 0, 12)
# The root starts at y=34, so the deterministic lead inset is 104 px.
outer.setContentsMargins(0, 104, 0, 0)
self.card = QFrame() self.card = QFrame()
self.card.setObjectName("LoginCard") self.card.setObjectName("LoginCard")
self.card.setFixedWidth(480) self.card.setFixedWidth(480)
self.card.setMinimumHeight(694) self.card.setMinimumHeight(620)
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum) self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
card_shadow = QGraphicsDropShadowEffect(self.card) card_shadow = QGraphicsDropShadowEffect(self.card)
card_shadow.setBlurRadius(38) card_shadow.setBlurRadius(38)
@@ -675,13 +673,13 @@ class LoginWindow(QMainWindow):
title = QLabel("欢迎回来") title = QLabel("欢迎回来")
title.setObjectName("LoginTitle") title.setObjectName("LoginTitle")
title.setStyleSheet("color:#14224A; font-size:33px; font-weight:700;") title.setStyleSheet("color:#1A1C1F; font-size:30px; font-weight:500;")
title.setContentsMargins(1, -3, 0, 3) title.setContentsMargins(1, -3, 0, 3)
title.setFixedHeight(46) title.setFixedHeight(46)
card_layout.addWidget(title) card_layout.addWidget(title)
subtitle = QLabel("使用医生账号登录工作站") subtitle = QLabel("使用医生账号登录工作站")
subtitle.setProperty("role", "muted") subtitle.setProperty("role", "muted")
subtitle.setStyleSheet("color:#7382A5; font-size:18px;") subtitle.setStyleSheet("color:#606163; font-size:18px;")
subtitle.setContentsMargins(1, 8, 0, 0) subtitle.setContentsMargins(1, 8, 0, 0)
subtitle.setFixedHeight(27) subtitle.setFixedHeight(27)
card_layout.addWidget(subtitle) card_layout.addWidget(subtitle)
@@ -691,7 +689,7 @@ class LoginWindow(QMainWindow):
card_layout.addWidget(self.error_banner) card_layout.addWidget(self.error_banner)
account_label = QLabel("账号") account_label = QLabel("账号")
account_label.setStyleSheet("color:#17264B; font-size:18px; font-weight:600;") account_label.setStyleSheet("color:#1A1C1F; font-size:16px; font-weight:500;")
account_label.setContentsMargins(0, -2, 0, 2) account_label.setContentsMargins(0, -2, 0, 2)
account_label.setFixedHeight(24) account_label.setFixedHeight(24)
card_layout.addWidget(account_label) card_layout.addWidget(account_label)
@@ -707,7 +705,7 @@ class LoginWindow(QMainWindow):
card_layout.addSpacing(21) card_layout.addSpacing(21)
password_label = QLabel("密码") password_label = QLabel("密码")
password_label.setStyleSheet("color:#17264B; font-size:18px; font-weight:600;") password_label.setStyleSheet("color:#1A1C1F; font-size:16px; font-weight:500;")
password_label.setContentsMargins(0, -3, 0, 3) password_label.setContentsMargins(0, -3, 0, 3)
password_label.setFixedHeight(24) password_label.setFixedHeight(24)
card_layout.addWidget(password_label) card_layout.addWidget(password_label)
@@ -777,16 +775,16 @@ class LoginWindow(QMainWindow):
divider.setSpacing(18) divider.setSpacing(18)
line_left = QFrame() line_left = QFrame()
line_left.setFrameShape(QFrame.Shape.HLine) line_left.setFrameShape(QFrame.Shape.HLine)
line_left.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;") line_left.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
divider.addWidget(line_left, 1) divider.addWidget(line_left, 1)
divider_text = QLabel("") divider_text = QLabel("")
divider_text.setAlignment(Qt.AlignmentFlag.AlignCenter) divider_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
divider_text.setStyleSheet("color:#7C89A8; font-size:16px;") divider_text.setStyleSheet("color:#6A6B6D; font-size:16px;")
divider_text.setFixedSize(38, 22) divider_text.setFixedSize(38, 22)
divider.addWidget(divider_text) divider.addWidget(divider_text)
line_right = QFrame() line_right = QFrame()
line_right.setFrameShape(QFrame.Shape.HLine) line_right.setFrameShape(QFrame.Shape.HLine)
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;") line_right.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
divider.addWidget(line_right, 1) divider.addWidget(line_right, 1)
debug_settings_layout.addLayout(divider) debug_settings_layout.addLayout(divider)
debug_settings_layout.addSpacing(20) debug_settings_layout.addSpacing(20)
@@ -873,7 +871,7 @@ class LoginWindow(QMainWindow):
footnote_row.addWidget(lock) footnote_row.addWidget(lock)
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。") footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
footnote.setProperty("role", "muted") footnote.setProperty("role", "muted")
footnote.setStyleSheet("color:#7A89AA; font-size:15px;") footnote.setStyleSheet("color:#6A6B6D; font-size:15px;")
footnote.setContentsMargins(0, -4, 0, 4) footnote.setContentsMargins(0, -4, 0, 4)
footnote.setWordWrap(True) footnote.setWordWrap(True)
footnote.setFixedHeight(40) footnote.setFixedHeight(40)
@@ -882,12 +880,12 @@ class LoginWindow(QMainWindow):
self.version_label = QLabel(f"当前版本 {__version__}") self.version_label = QLabel(f"当前版本 {__version__}")
self.version_label.setObjectName("LoginVersionLabel") self.version_label.setObjectName("LoginVersionLabel")
self.version_label.setProperty("role", "muted") self.version_label.setProperty("role", "muted")
self.version_label.setStyleSheet("color:#8B98B5; font-size:13px;") self.version_label.setStyleSheet("color:#6A6B6D; font-size:13px;")
self.version_label.setContentsMargins(0, 8, 0, 0) self.version_label.setContentsMargins(0, 8, 0, 0)
card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight) card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight)
outer.addWidget( outer.addWidget(
self.card, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop self.card, 0, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
) )
outer.addStretch(1) outer.addStretch(1)
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…") self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
@@ -899,28 +897,11 @@ class LoginWindow(QMainWindow):
@staticmethod @staticmethod
def _account_icon() -> QIcon: def _account_icon() -> QIcon:
pixmap = crisp_pixmap(24) return icons.icon("user", "muted", 24)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(_round_pen("#8292B6", 2))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(QPointF(12, 7.5), 4.2, 4.2)
painter.drawRoundedRect(QRectF(4.5, 14, 15, 7), 3.5, 3.5)
painter.end()
return QIcon(pixmap)
@staticmethod @staticmethod
def _lock_icon() -> QIcon: def _lock_icon() -> QIcon:
pixmap = crisp_pixmap(20) return icons.icon("lock", "muted", 20)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(_round_pen("#8FA0C4", 1.6))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(QRectF(5, 8, 10, 9), 2, 2)
painter.drawArc(QRectF(7, 3, 6, 9), 0, 180 * 16)
painter.drawLine(QPointF(10, 11), QPointF(10, 14))
painter.end()
return QIcon(pixmap)
def _restore_settings(self) -> None: def _restore_settings(self) -> None:
configured_account = getattr(self.config, "remembered_account", "") configured_account = getattr(self.config, "remembered_account", "")
+364
View File
@@ -0,0 +1,364 @@
"""Motion tokens and helpers.
The product had two `QGraphicsOpacityEffect` uses and no `QPropertyAnimation`
at all, so every state change was an instant cut: pages replaced each other
between one frame and the next, drawers appeared fully formed, toasts blinked
in and out. Nothing was slow - it just gave the eye no continuity to follow,
which is what reads as "not smooth" however fast the code underneath is.
Everything here is short. A workstation is used all day, so transitions are
tuned to be felt rather than watched: 110-260 ms, ease-out on entry, and travel
measured in single-digit pixels. Anything longer starts costing the user time.
Qt stylesheets have no `transition` property, so this is `QPropertyAnimation`
throughout. Two rules keep that safe:
* an animation must be owned, or PySide garbage-collects it mid-flight and the
widget freezes half-faded - :func:`_own` parks it on the target;
* a `QGraphicsOpacityEffect` forces the whole widget subtree through an
offscreen render path, which would make a table scroll badly for the rest of
the session - every fade here removes its effect when it finishes.
"""
from __future__ import annotations
import os
from collections.abc import Callable
from typing import Any
from PySide6.QtCore import (
QAbstractAnimation,
QEasingCurve,
QEvent,
QObject,
QPoint,
QPropertyAnimation,
Qt,
QTimer,
)
from PySide6.QtWidgets import (
QAbstractScrollArea,
QGraphicsOpacityEffect,
QStackedWidget,
QWidget,
)
#: Durations in milliseconds.
FAST = 110 # hover-scale feedback, small fades
BASE = 170 # the default: page and panel transitions
SLOW = 260 # large travel, e.g. a drawer crossing the workspace
#: Entering elements decelerate; elements that move between two known places
#: ease in and out; large travel gets a longer tail so it never looks linear.
EASE_ENTER = QEasingCurve.Type.OutCubic
EASE_MOVE = QEasingCurve.Type.InOutCubic
EASE_TRAVEL = QEasingCurve.Type.OutQuint
#: How far an entering surface rises, in device-independent pixels. Kept small
#: on purpose: a page that slides a long way reads as a slideshow, not an app.
RISE = 8
def reduced_motion() -> bool:
"""Whether animation should be skipped entirely.
Off by default under the offscreen platform so widget grabs in tests and in
the packaging smoke checks capture a settled frame rather than a frame from
the middle of a fade. ``DOCTOR_MOTION=on`` / ``off`` overrides either way.
"""
override = os.getenv("DOCTOR_MOTION", "").strip().lower()
if override in {"off", "0", "false", "none", "reduce"}:
return True
if override in {"on", "1", "true", "full"}:
return False
return os.getenv("QT_QPA_PLATFORM", "").strip().lower() == "offscreen"
def _own(target: QWidget, key: str, animation: QPropertyAnimation) -> QPropertyAnimation:
"""Park an animation on its target so Python does not collect it early."""
running: dict[str, QPropertyAnimation] = getattr(target, "_doctor_motion", None) or {}
previous = running.get(key)
if previous is not None:
previous.stop()
running[key] = animation
target._doctor_motion = running
return animation
def animate(
target: Any,
prop: bytes,
start: Any,
end: Any,
*,
duration: int = BASE,
easing: QEasingCurve.Type = EASE_ENTER,
key: str | None = None,
owner: QWidget | None = None,
on_finished: Callable[[], None] | None = None,
) -> QPropertyAnimation | None:
"""Animate one Qt property, or apply the end value outright if motion is off.
``owner`` keeps the animation alive independently of ``target``. Fades
animate a ``QGraphicsOpacityEffect`` that is deleted the moment the fade
ends, so parenting the animation to the effect would destroy the animation
from inside its own ``finished`` emission.
"""
if reduced_motion():
target.setProperty(prop.decode() if isinstance(prop, bytes) else prop, end)
if on_finished is not None:
on_finished()
return None
animation = QPropertyAnimation(target, prop, owner if owner is not None else target)
animation.setDuration(duration)
animation.setEasingCurve(easing)
animation.setStartValue(start)
animation.setEndValue(end)
if on_finished is not None:
animation.finished.connect(on_finished)
_own(owner if owner is not None else target, key or prop.decode(), animation)
animation.start(QAbstractAnimation.DeletionPolicy.KeepWhenStopped)
return animation
def _opacity_effect(widget: QWidget) -> QGraphicsOpacityEffect:
effect = widget.graphicsEffect()
if not isinstance(effect, QGraphicsOpacityEffect):
effect = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(effect)
effect.setEnabled(True)
return effect
def _drop_effect(widget: QWidget) -> None:
"""Detach the opacity effect once a fade is done.
Leaving it attached keeps the widget on Qt's offscreen composite path, which
is exactly the sort of quiet, permanent frame-rate tax this module exists to
avoid introducing.
The detach is deferred by one event-loop turn on purpose. ``finished`` is
emitted from inside the animation, and ``setGraphicsEffect(None)`` deletes
the old effect immediately - tearing down the object graph underneath a
signal that is still being delivered.
"""
def detach() -> None:
try:
if isinstance(widget.graphicsEffect(), QGraphicsOpacityEffect):
widget.setGraphicsEffect(None)
except RuntimeError: # the widget went away while the fade was running
pass
QTimer.singleShot(0, detach)
def fade_in(
widget: QWidget,
*,
duration: int = BASE,
start: float = 0.0,
easing: QEasingCurve.Type = EASE_ENTER,
) -> None:
"""Fade a widget up to full opacity, showing it first if needed."""
if reduced_motion():
widget.show()
return
effect = _opacity_effect(widget)
effect.setOpacity(start)
widget.show()
animate(
effect,
b"opacity",
start,
1.0,
duration=duration,
easing=easing,
key="fade",
owner=widget,
on_finished=lambda: _drop_effect(widget),
)
def fade_out(
widget: QWidget,
*,
duration: int = FAST,
hide: bool = True,
on_finished: Callable[[], None] | None = None,
) -> None:
"""Fade a widget down, optionally hiding it when the fade completes."""
if reduced_motion():
if hide:
widget.hide()
if on_finished is not None:
on_finished()
return
effect = _opacity_effect(widget)
def done() -> None:
if hide:
widget.hide()
_drop_effect(widget)
if on_finished is not None:
on_finished()
animate(
effect,
b"opacity",
float(effect.opacity()),
0.0,
duration=duration,
easing=EASE_MOVE,
key="fade",
owner=widget,
on_finished=done,
)
def enter(widget: QWidget, *, duration: int = BASE, rise: int = RISE) -> None:
"""Fade a surface in while it settles upward by a few pixels.
The rise is what makes a swap read as one surface replacing another rather
than as a repaint; keeping it under ten pixels stops it becoming a gesture
the user has to wait out.
"""
if reduced_motion():
widget.show()
return
fade_in(widget, duration=duration)
if rise:
origin = widget.pos()
widget.move(origin + QPoint(0, rise))
animate(
widget,
b"pos",
widget.pos(),
origin,
duration=duration,
easing=EASE_ENTER,
key="enter",
)
def switch_stack(stack: QStackedWidget, index: int, *, rise: int = RISE) -> None:
"""Change the current page of a stack with a short cross-fade.
``QStackedWidget`` swaps pages between two frames with nothing in between,
which is the single most-seen transition in this product - it happens on
every sidebar click and on every list that toggles to its empty state.
"""
if index < 0 or index >= stack.count() or stack.currentIndex() == index:
stack.setCurrentIndex(index)
return
stack.setCurrentIndex(index)
page = stack.currentWidget()
if page is None or reduced_motion():
return
enter(page, rise=rise)
# --- Smooth scrolling -----------------------------------------------------
#: One wheel notch travels this far, and takes this long to get there. Qt's
#: default is an instant jump of three lines per notch, which on a long clinical
#: record is the single jerkiest thing in the interface.
SCROLL_STEP = 120
SCROLL_MS = 190
class _SmoothScroller(QObject):
"""Animate a scroll area's wheel movement instead of jumping to it."""
def __init__(self, area: QAbstractScrollArea, *, orientation: Qt.Orientation) -> None:
super().__init__(area)
self._bar = (
area.verticalScrollBar()
if orientation is Qt.Orientation.Vertical
else area.horizontalScrollBar()
)
self._target = self._bar.value()
self._animation = QPropertyAnimation(self._bar, b"value", self)
self._animation.setEasingCurve(EASE_ENTER)
self._animation.setDuration(SCROLL_MS)
# Keyboard, programmatic and drag movements must not be fought over: when
# nothing is animating, the wheel target follows wherever the bar went.
self._bar.valueChanged.connect(self._sync_target)
area.viewport().installEventFilter(self)
def _sync_target(self, value: int) -> None:
if self._animation.state() != QAbstractAnimation.State.Running:
self._target = value
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 - Qt API
del watched
if event.type() is not QEvent.Type.Wheel or reduced_motion():
return False
delta = event.angleDelta().y() or event.angleDelta().x()
if not delta or event.modifiers() & Qt.KeyboardModifier.ControlModifier:
return False
lower, upper = self._bar.minimum(), self._bar.maximum()
# Re-clamp first: the range can shrink underneath a running animation
# when the content behind it reloads, which would otherwise leave the
# pending target past the end of the new content.
self._target = max(lower, min(upper, self._target))
target = self._target - round(delta / 120.0 * SCROLL_STEP)
target = max(lower, min(upper, target))
# At either end, hand the wheel back so an enclosing scroll area still
# gets it - swallowing it there is what makes nested panes feel stuck.
if target == self._target:
return False
self._target = target
self._animation.stop()
self._animation.setStartValue(self._bar.value())
self._animation.setEndValue(target)
self._animation.start()
return True
def install_smooth_scroll(
area: QAbstractScrollArea,
*,
orientation: Qt.Orientation = Qt.Orientation.Vertical,
) -> None:
"""Give a scroll area eased wheel scrolling."""
if getattr(area, "_doctor_smooth_scroll", None) is not None:
return
area._doctor_smooth_scroll = _SmoothScroller(area, orientation=orientation)
def press_feedback(widget: QWidget) -> None:
"""Mark a widget so the shared stylesheet can give it a pressed transform.
Qt has no CSS transitions, so the visual step itself lives in the palette's
pressed state; this only tags the widget as one that should get it.
"""
widget.setProperty("motionPress", True)
__all__ = [
"BASE",
"EASE_ENTER",
"EASE_MOVE",
"EASE_TRAVEL",
"FAST",
"RISE",
"SLOW",
"animate",
"enter",
"fade_in",
"fade_out",
"install_smooth_scroll",
"press_feedback",
"reduced_motion",
"switch_stack",
]
@@ -10,7 +10,7 @@ from types import MappingProxyType
from typing import Any from typing import Any
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QBrush, QColor, QDesktopServices, QPixmap from PySide6.QtGui import QBrush, QColor, QDesktopServices, QFont, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QButtonGroup, QButtonGroup,
@@ -27,12 +27,21 @@ from PySide6.QtWidgets import (
QMenu, QMenu,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QStyle,
QStyledItemDelegate,
QStyleOptionViewItem,
QTabBar, QTabBar,
QTextEdit, QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from ...core.appointment_modes import (
appointment_type_description,
appointment_type_value,
can_appointment_video,
)
from ..appointments_style import appointments_stylesheet
from ..dialogs import DiagnosisDialog from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
from ..dialogs.prescription import ( from ..dialogs.prescription import (
@@ -45,11 +54,14 @@ from ..dialogs.prescription_ai import (
can_open_diagnosis_ai_report, can_open_diagnosis_ai_report,
present_diagnosis_ai_report, present_diagnosis_ai_report,
) )
from ..filter_disclosure import FilterDisclosure
from ..icons import icon
from ..infinite_list import InfiniteList
from ..reception_style import heading_family
from ..theme import mark_business_dialog from ..theme import mark_business_dialog
from ..widgets import ( from ..widgets import (
MessageBanner, MessageBanner,
PageHeader, PageHeader,
Pager,
SortableTable, SortableTable,
TableColumn, TableColumn,
display_text, display_text,
@@ -84,219 +96,13 @@ NOTE_LIMIT = 500
TABLE_CELL_VERTICAL_PADDING = 10 TABLE_CELL_VERTICAL_PADDING = 10
_SEMANTIC_COLORS = { _SEMANTIC_COLORS = {
"success": "#159C79", "success": "#273244",
"warning": "#C17A16", "warning": "#273244",
"danger": "#EC5266", "danger": "#BE4B58",
"info": "#4776EE", "info": "#1555B6",
"muted": "#7481A3", "muted": "#5D6B80",
} }
APPOINTMENTS_LIGHT_QSS = """
/* 页头此前被压到只剩面包屑26px与其余列表页的面包屑+标题+副标题
骨架不一致这里给它与 PageHeader 自然高度相符的空间 */
#AppointmentsPage QWidget#PageHeader {
min-height: 62px;
max-height: 62px;
}
#AppointmentsPage QFrame#AppointmentFilterPanel {
min-height: 80px;
max-height: 80px;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
}
#AppointmentsPage QFrame#AppointmentMainCard {
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
}
#AppointmentsPage QPushButton[appointmentStat="true"] {
min-height: 30px;
max-height: 30px;
padding: 0 7px;
color: #59698E;
background-color: #F8F9FD;
border: 0;
border-radius: 8px;
font-size: 12px;
}
#AppointmentsPage QPushButton[appointmentStat="true"]:hover {
color: #5265F6;
background-color: #F0F2FF;
}
#AppointmentsPage QPushButton[appointmentStat="true"]:checked {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
font-weight: 600;
}
/* 这一排全是筛选片此前按业务语义分别染成紫/绿/琥珀一行出现四种底色
而且颜色和是否选中这一真正需要区分的状态互相打架筛选片一律保持中性
只有待分配医助在确有待办时才提示为琥珀色 */
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"] {
color: #C17A16;
background-color: #FFF8ED;
border-color: #F0DCB6;
}
#AppointmentsPage QLineEdit#AppointmentPatientSearch {
min-height: 30px;
max-height: 30px;
background-color: #FFFFFF;
border: 1px solid #DDE3F0;
border-radius: 7px;
}
#AppointmentsPage QLabel#FilterRowLabel {
color: #405074;
font-size: 12px;
font-weight: 600;
}
#AppointmentsPage QLabel#FilterDivider {
color: #E2E7F4;
padding: 0 2px;
}
#AppointmentsPage QTableWidget#AppointmentTable::item {
padding: 5px 7px;
}
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
min-height: 34px;
background-color: #F8FAFF;
}
#AppointmentsPage QTableWidget#AppointmentTable {
gridline-color: #E9EDF5;
selection-background-color: #FFFFFF;
selection-color: #15224A;
}
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator {
width: 13px;
height: 13px;
background-color: #FFFFFF;
border: 1px solid #CBD3E7;
border-radius: 2px;
}
#AppointmentsPage QCheckBox[appointmentSelector="true"]::indicator:checked {
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QLabel[tableAppointmentStatus="true"] {
min-height: 18px;
max-height: 18px;
padding: 0 6px;
color: #5265F6;
background-color: #EEF1FF;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
}
#AppointmentsPage QLabel[tableAppointmentStatusKind="warning"] {
color: #B97715;
background-color: #FFF4DF;
}
#AppointmentsPage QLabel[tableAppointmentMeta="true"] {
color: #59698E;
font-size: 10px;
}
#AppointmentsPage QPushButton[tableCancelAction="true"] {
min-height: 18px;
max-height: 18px;
padding: 0 5px;
color: #EC5266;
background-color: #FFF4F6;
border: 0;
border-radius: 4px;
font-size: 10px;
}
#AppointmentsPage QWidget[appointmentImHost="true"] {
background-color: transparent;
}
#AppointmentsPage QPushButton[appointmentImAction="true"] {
min-width: 74px;
min-height: 26px;
max-height: 26px;
padding: 0 9px;
color: #5265F6;
background-color: #F0F2FF;
border: 1px solid #D7DEFF;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:hover {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:focus {
border-color: #8D9BFF;
}
#AppointmentsPage QPushButton[appointmentImAction="true"]:disabled {
color: #98A3BC;
background-color: #F7F8FC;
border-color: #E4E8F2;
}
#AppointmentsPage QWidget[appointmentInfoHost="true"] {
background-color: #FFFFFF;
}
#AppointmentsPage QPushButton[compactAction="true"] {
min-height: 28px;
max-height: 28px;
padding: 0 8px;
border-radius: 7px;
font-size: 11px;
}
#AppointmentsPage QPushButton[variant="chip"] {
min-height: 34px;
max-height: 34px;
padding: 0 12px;
color: #7481A3;
background-color: #F8FAFF;
border: 1px solid #E2E7F4;
border-radius: 8px;
}
#AppointmentsPage QPushButton[variant="chip"]:hover {
color: #15224A;
background-color: #F0F3FC;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[variant="chip"]:checked {
color: #FFFFFF;
background-color: #5265F6;
border-color: #5265F6;
}
#AppointmentsPage QPushButton[variant="chip"]:focus { border-color: #8D9BFF; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
min-width: 62px;
min-height: 28px;
padding: 0 7px;
color: #7481A3;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
}
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:hover {
color: #15224A;
background-color: #F0F3FC;
}
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab:selected {
color: #3C4FD9;
background-color: #EEF1FF;
border-bottom-color: #5265F6;
}
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 28px;
max-height: 28px;
padding: 0 10px;
color: #405074;
background-color: transparent;
border: 1px solid transparent;
border-radius: 7px;
}
#AppointmentsPage QPushButton[filterChoice="true"]:checked {
color: #5265F6;
background-color: #F0F1FF;
border-color: #E0E4FF;
}
"""
def _as_int(value: Any, default: int = 0) -> int: def _as_int(value: Any, default: int = 0) -> int:
try: try:
@@ -511,7 +317,8 @@ def _appointment_info_cell(_value: Any, row: Any) -> str:
channel = display_text( channel = display_text(
first_value(row, "channel_name", "channel_source_name", "source_name"), "" first_value(row, "channel_name", "channel_source_name", "source_name"), ""
) )
return f"{status} {doctor}\n{date_text} {time_text}\n最近渠道:{channel}" mode = appointment_type_description(appointment_type_value(row))
return f"{status} {doctor}\n{date_text} {time_text} · {mode}\n最近渠道:{channel}"
def _revisit_cell(_value: Any, row: Any) -> str: def _revisit_cell(_value: Any, row: Any) -> str:
@@ -623,6 +430,20 @@ def _appointment_result_signature(rows: Sequence[Any], total: int, extend: Any)
) )
class _AppointmentInfoDelegate(QStyledItemDelegate):
"""Paint the row surface beneath the real appointment detail controls."""
def paint(self, painter: Any, option: Any, index: Any) -> None:
prepared = QStyleOptionViewItem(option)
self.initStyleOption(prepared, index)
# Preserve the item text for chronological sorting and tooltips while
# preventing it from showing through the transparent cell widget.
prepared.text = ""
prepared.widget.style().drawControl(
QStyle.ControlElement.CE_ItemViewItem, prepared, painter, prepared.widget
)
class AppointmentsPage(QWidget): class AppointmentsPage(QWidget):
"""Desktop appointment list with status tabs, filters, call and prescription.""" """Desktop appointment list with status tabs, filters, call and prescription."""
@@ -637,7 +458,8 @@ class AppointmentsPage(QWidget):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("AppointmentsPage") self.setObjectName("AppointmentsPage")
self.setStyleSheet(APPOINTMENTS_LIGHT_QSS) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(appointments_stylesheet())
self.repository = repository self.repository = repository
self.permissions = permissions self.permissions = permissions
self.current_user = current_user self.current_user = current_user
@@ -667,14 +489,25 @@ class AppointmentsPage(QWidget):
self._is_admin = _is_admin_user(current_user) self._is_admin = _is_admin_user(current_user)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(18, 3, 6, 8) root.setContentsMargins(30, 18, 27, 8)
root.setSpacing(4) root.setSpacing(12)
# 其余列表页都有“面包屑 + 标题 + 副标题”,这一页此前把标题隐藏了,
# 导致同一套列表页有两种页头形态。保留页头以对齐全局页面骨架。
self.header = PageHeader("挂号列表", "管理当日与近期挂号,确认到号、指派医助并进入接诊。") self.header = PageHeader("挂号列表", "管理当日与近期挂号,确认到号、指派医助并进入接诊。")
self.header.setMinimumHeight(101)
self.header.layout().setSpacing(18)
self.header.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
breadcrumb = self.header.layout().itemAt(0).layout()
for index in range(breadcrumb.count()):
label = breadcrumb.itemAt(index).widget()
if label is not None:
label.setFixedHeight(18)
self.header.actions.setAlignment(Qt.AlignmentFlag.AlignTop)
self.header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(10)
self.header.set_compact(True)
root.addWidget(self.header) root.addWidget(self.header)
self.filter_panel = self._build_filter_panel() self.filter_panel = self._build_filter_panel()
root.addWidget(self.filter_panel) root.addWidget(self.filter_panel)
self.filter_disclosure = FilterDisclosure(self, [self.filter_panel])
self.header.add_action(self.filter_disclosure.button)
self.banner = MessageBanner() self.banner = MessageBanner()
root.addWidget(self.banner) root.addWidget(self.banner)
self.content_host = self._build_content() self.content_host = self._build_content()
@@ -682,7 +515,7 @@ class AppointmentsPage(QWidget):
self.poll_timer = QTimer(self) self.poll_timer = QTimer(self)
self.poll_timer.setInterval(LIST_POLL_MS) self.poll_timer.setInterval(LIST_POLL_MS)
self.poll_timer.timeout.connect(lambda: self.refresh(silent=True)) self.poll_timer.timeout.connect(self._poll_refresh)
self._apply_responsive_layout() self._apply_responsive_layout()
def _diagnosis_dialog(self) -> DiagnosisDialog: def _diagnosis_dialog(self) -> DiagnosisDialog:
@@ -697,11 +530,11 @@ class AppointmentsPage(QWidget):
frame = QFrame() frame = QFrame()
frame.setObjectName("AppointmentFilterPanel") frame.setObjectName("AppointmentFilterPanel")
layout = QVBoxLayout(frame) layout = QVBoxLayout(frame)
layout.setContentsMargins(6, 4, 8, 6) layout.setContentsMargins(16, 16, 16, 14)
layout.setSpacing(4) layout.setSpacing(18)
date_row = QHBoxLayout() date_row = QHBoxLayout()
date_row.setSpacing(4) date_row.setSpacing(16)
self.date_buttons: dict[str, QPushButton] = {} self.date_buttons: dict[str, QPushButton] = {}
self._date_stat_labels: dict[str, str] = {} self._date_stat_labels: dict[str, str] = {}
for source_label, preset in DATE_PRESETS: for source_label, preset in DATE_PRESETS:
@@ -730,6 +563,10 @@ class AppointmentsPage(QWidget):
self.date_overflow_button.setMenu(date_menu) self.date_overflow_button.setMenu(date_menu)
date_row.addWidget(self.date_overflow_button) date_row.addWidget(self.date_overflow_button)
date_divider = QLabel("")
date_divider.setObjectName("FilterDivider")
date_row.addWidget(date_divider)
self.pending_stat_button = QPushButton("待预约 0") self.pending_stat_button = QPushButton("待预约 0")
self.pending_stat_button.setProperty("appointmentStat", True) self.pending_stat_button.setProperty("appointmentStat", True)
self.pending_stat_button.setMinimumWidth(0) self.pending_stat_button.setMinimumWidth(0)
@@ -746,23 +583,31 @@ class AppointmentsPage(QWidget):
self.unassigned_stat_button.setProperty("appointmentStatKind", "warning") self.unassigned_stat_button.setProperty("appointmentStatKind", "warning")
self.unassigned_stat_button.setMinimumWidth(0) self.unassigned_stat_button.setMinimumWidth(0)
self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter) self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter)
date_row.addWidget(self.unassigned_stat_button, 1) date_row.addWidget(self.unassigned_stat_button)
date_row.addSpacing(52)
self.patient_input = QLineEdit() search_row = QHBoxLayout()
search_row.setSpacing(8)
search_row.addStretch(1)
self.patient_input = QLineEdit(frame)
self.patient_input.setObjectName("AppointmentPatientSearch") self.patient_input.setObjectName("AppointmentPatientSearch")
self.patient_input.setPlaceholderText("患者姓名 / 手机号") self.patient_input.setPlaceholderText("患者姓名 / 手机号")
self.patient_input.setClearButtonEnabled(True) self.patient_input.setClearButtonEnabled(True)
self.patient_input.addAction(icon("search", "#5D6B80", 16), QLineEdit.ActionPosition.LeadingPosition)
self.patient_input.returnPressed.connect(self._search) self.patient_input.returnPressed.connect(self._search)
date_row.addWidget(self.patient_input, 2) self.patient_input.setFixedWidth(270)
search_row.addWidget(self.patient_input)
search = QPushButton("查询") search = QPushButton("查询")
search.setObjectName("AppointmentSearchButton")
search.setProperty("variant", "primary") search.setProperty("variant", "primary")
search.clicked.connect(self._search) search.clicked.connect(self._search)
date_row.addWidget(search) search_row.addWidget(search)
layout.addLayout(search_row)
layout.addLayout(date_row) layout.addLayout(date_row)
filter_row = QHBoxLayout() filter_row = QHBoxLayout()
filter_row.setSpacing(5) filter_row.setSpacing(8)
self.status_filter_label = QLabel("挂号状态:") self.status_filter_label = QLabel("挂号状态:")
self.status_filter_label.setObjectName("FilterRowLabel") self.status_filter_label.setObjectName("FilterRowLabel")
filter_row.addWidget(self.status_filter_label) filter_row.addWidget(self.status_filter_label)
@@ -806,23 +651,26 @@ class AppointmentsPage(QWidget):
filter_row.addWidget(button) filter_row.addWidget(button)
self.confirmed_buttons[""].setChecked(True) self.confirmed_buttons[""].setChecked(True)
divider = QLabel("") filter_row.addStretch(1)
divider.setObjectName("FilterDivider")
self.filter_dividers.append(divider)
filter_row.addWidget(divider)
self.more_filters_button = QPushButton("更多筛选") self.more_filters_button = QPushButton("更多筛选")
self.more_filters_button.setCheckable(True) self.more_filters_button.setCheckable(True)
self.more_filters_button.setProperty("filterChoice", True) self.more_filters_button.setProperty("filterChoice", True)
self.more_filters_button.clicked.connect(self._toggle_advanced_filters) self.more_filters_button.clicked.connect(self._toggle_advanced_filters)
filter_row.addWidget(self.more_filters_button) filter_row.addWidget(self.more_filters_button)
layout.addLayout(filter_row)
self.advanced_filters = QWidget(frame)
advanced_row = QHBoxLayout(self.advanced_filters)
advanced_row.setContentsMargins(0, 0, 0, 0)
advanced_row.setSpacing(10)
self.advanced_filters.hide()
self.dept_filter = QComboBox() self.dept_filter = QComboBox()
self.dept_filter.addItem("全部部门", "") self.dept_filter.addItem("全部部门", "")
self.dept_filter.setMinimumWidth(150) self.dept_filter.setMinimumWidth(150)
self.dept_filter.currentIndexChanged.connect(self._search) self.dept_filter.currentIndexChanged.connect(self._search)
self.dept_filter.hide() self.dept_filter.hide()
filter_row.addWidget(self.dept_filter) advanced_row.addWidget(self.dept_filter)
self.doctor_input = QLineEdit(frame) self.doctor_input = QLineEdit(frame)
self.doctor_input.setPlaceholderText("医生") self.doctor_input.setPlaceholderText("医生")
@@ -830,23 +678,23 @@ class AppointmentsPage(QWidget):
self.doctor_input.setMaximumWidth(120) self.doctor_input.setMaximumWidth(120)
self.doctor_input.hide() self.doctor_input.hide()
self.doctor_input.returnPressed.connect(self._search) self.doctor_input.returnPressed.connect(self._search)
filter_row.addWidget(self.doctor_input) advanced_row.addWidget(self.doctor_input)
filter_row.addStretch(1) advanced_row.addStretch(1)
custom = QPushButton("自定义日期") custom = QPushButton("自定义日期")
custom.setProperty("variant", "ghost") custom.setProperty("variant", "ghost")
custom.clicked.connect(self._open_custom_date) custom.clicked.connect(self._open_custom_date)
custom.hide() custom.hide()
self.custom_date_button = custom self.custom_date_button = custom
filter_row.addWidget(custom) advanced_row.addWidget(custom)
reset = QPushButton("重置") reset = QPushButton("重置")
reset.setProperty("variant", "ghost") reset.setProperty("variant", "ghost")
reset.clicked.connect(self._reset_filters) reset.clicked.connect(self._reset_filters)
reset.hide() reset.hide()
self.reset_filter_button = reset self.reset_filter_button = reset
filter_row.addWidget(reset) advanced_row.addWidget(reset)
layout.addLayout(filter_row) layout.addWidget(self.advanced_filters)
return frame return frame
def _build_content(self) -> QWidget: def _build_content(self) -> QWidget:
@@ -865,8 +713,8 @@ class AppointmentsPage(QWidget):
card.setObjectName("AppointmentMainCard") card.setObjectName("AppointmentMainCard")
card.setMinimumHeight(0) card.setMinimumHeight(0)
layout = QVBoxLayout(card) layout = QVBoxLayout(card)
layout.setContentsMargins(4, 8, 8, 8) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8) layout.setSpacing(0)
compatibility_host = QWidget(card) compatibility_host = QWidget(card)
compatibility_host.hide() compatibility_host.hide()
@@ -910,10 +758,14 @@ class AppointmentsPage(QWidget):
if isinstance(widget, QPushButton): if isinstance(widget, QPushButton):
widget.setProperty("compactAction", True) widget.setProperty("compactAction", True)
actions = QHBoxLayout() self.toolbar = QFrame(card)
actions.setSpacing(6) self.toolbar.setObjectName("AppointmentToolbar")
self.toolbar.setFixedHeight(58)
actions = QHBoxLayout(self.toolbar)
actions.setContentsMargins(10, 4, 18, 14)
actions.setSpacing(16)
self.toolbar_edit_button = QPushButton("编辑患者", card) self.toolbar_edit_button = QPushButton("编辑患者", card)
self.toolbar_edit_button.setProperty("variant", "primary") self.toolbar_edit_button.setProperty("variant", "secondary")
self.toolbar_edit_button.setProperty("compactAction", True) self.toolbar_edit_button.setProperty("compactAction", True)
self.toolbar_edit_button.setVisible( self.toolbar_edit_button.setVisible(
_canonical_allowed(self.permissions, "tcm.diagnosis/edit", default=False) _canonical_allowed(self.permissions, "tcm.diagnosis/edit", default=False)
@@ -950,30 +802,32 @@ class AppointmentsPage(QWidget):
refresh = QPushButton("刷新", card) refresh = QPushButton("刷新", card)
refresh.setProperty("variant", "ghost") refresh.setProperty("variant", "ghost")
refresh.setProperty("compactAction", True) refresh.setProperty("compactAction", True)
refresh.setIcon(icon("refresh", "#5D6B80", 16))
refresh.clicked.connect(lambda: self.refresh()) refresh.clicked.connect(lambda: self.refresh())
actions.addWidget(refresh) actions.addWidget(refresh)
layout.addLayout(actions) layout.addWidget(self.toolbar)
self.table = SortableTable( self.table = SortableTable(
[ [
TableColumn("_selected", "", 36, alignment=Qt.AlignmentFlag.AlignCenter), TableColumn("_selected", "", 48, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("id", "ID", 66), TableColumn("id", "ID", 56, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn( TableColumn(
"patient_name", "patient_name",
"患者", "患者",
100, 114,
alignment=Qt.AlignmentFlag.AlignCenter,
), ),
TableColumn("gender", "性别 / 年龄", 98, formatter=_gender_age_cell), TableColumn("gender", "性别 / 年龄", 122, formatter=_gender_age_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("appointment_date", "挂号信息", 202, formatter=_appointment_info_cell), TableColumn("appointment_date", "挂号信息", 296, formatter=_appointment_info_cell),
TableColumn("diagnosis_confirmed", "确认", 90, formatter=_confirmed_cell), TableColumn("diagnosis_confirmed", "确认", 100, formatter=_confirmed_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("revisit_time", "复诊", 100, formatter=_revisit_cell), TableColumn("revisit_time", "复诊", 80, formatter=_revisit_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("assistant_name", "助理", 110), TableColumn("assistant_name", "助理", 109, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("has_prescription", "开方", 108, formatter=_prescription_cell), TableColumn("has_prescription", "开方", 107, formatter=_prescription_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn("unserved_days", "未服务天数", 112, formatter=_unserved_cell), TableColumn("unserved_days", "未服务天数", 108, formatter=_unserved_cell, alignment=Qt.AlignmentFlag.AlignCenter),
TableColumn( TableColumn(
"_im_consult", "_im_consult",
"IM 问诊", "IM 问诊",
104, 129,
alignment=Qt.AlignmentFlag.AlignCenter, alignment=Qt.AlignmentFlag.AlignCenter,
), ),
] ]
@@ -981,24 +835,26 @@ class AppointmentsPage(QWidget):
self.table.setObjectName("AppointmentTable") self.table.setObjectName("AppointmentTable")
self.table.setMinimumHeight(0) self.table.setMinimumHeight(0)
self.table.setWordWrap(True) self.table.setWordWrap(True)
self.table.setItemDelegateForColumn(4, _AppointmentInfoDelegate(self.table))
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
header = self.table.horizontalHeader() header = self.table.horizontalHeader()
header.setFixedHeight(34) header.setFixedHeight(41)
header.setMinimumSectionSize(28)
header.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
header.setStretchLastSection(False) header.setStretchLastSection(False)
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed) header.setSectionResizeMode(10, QHeaderView.ResizeMode.Fixed)
self.table.verticalHeader().setDefaultSectionSize(60) self.table.verticalHeader().setDefaultSectionSize(88)
self.table.itemSelectionChanged.connect(self._selection_changed) self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail()) self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.table, 1) layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size) self.pager = InfiniteList(self._page_size)
self.pager.setMaximumHeight(38) self.pager.bind(self.table)
self.pager.page_changed.connect(self._page_changed)
layout.addWidget(self.pager) layout.addWidget(self.pager)
return card return card
def _apply_responsive_layout(self) -> None: def _apply_responsive_layout(self) -> None:
"""Collapse only the date filters when horizontal space is limited.""" """Keep the approved spacing, with denser controls on smaller desktops."""
narrow = self.width() < 1120 narrow = self.width() < 1120
for preset in ("yesterday", "day_before", "tomorrow", "day_after"): for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
@@ -1006,8 +862,32 @@ class AppointmentsPage(QWidget):
self.date_overflow_button.setVisible(narrow) self.date_overflow_button.setVisible(narrow)
self.status_filter_label.setVisible(not narrow) self.status_filter_label.setVisible(not narrow)
self.confirm_filter_label.setVisible(not narrow) self.confirm_filter_label.setVisible(not narrow)
compact = self.height() < 800
self.layout().setContentsMargins(30 if not narrow else 16, 18 if not compact else 12,
27 if not narrow else 16, 8)
self.patient_input.setFixedWidth(270 if self.width() >= 1180 else 200)
self.filter_panel.layout().setContentsMargins(16, 16 if not compact else 8, 16, 14 if not compact else 8)
self.filter_panel.layout().setSpacing(18 if not compact else 8)
self.toolbar.setFixedHeight(58 if not compact else 48)
self.toolbar.layout().setContentsMargins(10, 4, 18, 14 if not compact else 6)
self._compact_rows = compact
self._fit_table_columns()
self._fit_table_rows()
self._responsive_narrow = narrow self._responsive_narrow = narrow
def _fit_table_columns(self) -> None:
# Preserve all 11 columns; below the readable minimum the table scrolls.
widths = (48, 56, 114, 122, 296, 100, 80, 109, 107, 108, 129)
scale = min(1.0, max(0.85, (self.width() - 58) / sum(widths)))
for column, width in enumerate(widths):
if column != 4:
self.table.setColumnWidth(column, round(width * scale))
self.table.horizontalHeader().setSectionResizeMode(
4, QHeaderView.ResizeMode.Interactive if self.width() < 1080 else QHeaderView.ResizeMode.Stretch
)
if self.width() < 1080:
self.table.setColumnWidth(4, 270)
def resizeEvent(self, event: Any) -> None: def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event) super().resizeEvent(event)
self._apply_responsive_layout() self._apply_responsive_layout()
@@ -1068,6 +948,7 @@ class AppointmentsPage(QWidget):
self._search() self._search()
def _toggle_advanced_filters(self, checked: bool) -> None: def _toggle_advanced_filters(self, checked: bool) -> None:
self.advanced_filters.setVisible(checked)
self.dept_filter.setVisible(checked) self.dept_filter.setVisible(checked)
self.doctor_input.setVisible(checked and self._is_admin) self.doctor_input.setVisible(checked and self._is_admin)
self.custom_date_button.setVisible(checked) self.custom_date_button.setVisible(checked)
@@ -1171,10 +1052,6 @@ class AppointmentsPage(QWidget):
self.tab_bar.blockSignals(False) self.tab_bar.blockSignals(False)
self._set_date_preset("today") self._set_date_preset("today")
def _page_changed(self, page: int) -> None:
self._page = max(1, page)
self.refresh()
def _query_filters(self) -> dict[str, Any]: def _query_filters(self) -> dict[str, Any]:
filters: dict[str, Any] = { filters: dict[str, Any] = {
"include_status_counts": 1, "include_status_counts": 1,
@@ -1203,26 +1080,26 @@ class AppointmentsPage(QWidget):
return filters return filters
def refresh(self, *, silent: bool = False) -> None: def refresh(self, *, silent: bool = False) -> None:
if self._loading and not silent:
return
self._generation += 1 self._generation += 1
generation = self._generation generation = self._generation
self._loading = True self._loading = True
if not silent: # 列表加载不再挂横幅。横幅占布局空间,弹出与收起各触发一次重排,
self.banner.show_message("正在加载挂号列表…", "info") # 每刷新一次表格就上下跳一次——而轮询定时器每 5 秒就刷新一次。
filters = self._query_filters() # 已有数据时保持旧行可见、静默替换;失败仍然照常报错。
page = self._page filters = MappingProxyType(self._query_filters())
page_size = self._page_size page_size = self._page_size
run_async( self.pager.reload(
lambda: invoke( lambda page: invoke(
self.repository, self.repository,
"list_appointments", "list_appointments",
page_no=page, page_no=page,
page_size=page_size, page_size=page_size,
**filters, **filters,
), ),
on_success=lambda result: self._loaded(result, generation, silent), apply=lambda result: self._loaded(result, generation, silent),
on_error=lambda error: self._load_error(error, generation, silent), on_error=lambda error: self._load_error(error, generation, silent),
runner=run_async,
query_key=filters,
on_finished=lambda: self._load_finished(generation), on_finished=lambda: self._load_finished(generation),
) )
@@ -1231,6 +1108,7 @@ class AppointmentsPage(QWidget):
return return
rows = page_items(result) rows = page_items(result)
total = page_total(result) total = page_total(result)
self._page = self.pager.page
extend = get_value(result, "extend", {}) or {} extend = get_value(result, "extend", {}) or {}
signature = ( signature = (
self._page, self._page,
@@ -1335,23 +1213,28 @@ class AppointmentsPage(QWidget):
def _install_table_selectors(self) -> None: def _install_table_selectors(self) -> None:
for row_index in range(self.table.rowCount()): for row_index in range(self.table.rowCount()):
host = QWidget(self.table) host = QWidget(self.table)
host.setProperty("appointmentSelectionHost", True)
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QHBoxLayout(host) layout = QHBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
selector = QCheckBox(host) selector = QCheckBox(host)
selector.setProperty("appointmentSelector", True) selector.setProperty("appointmentSelector", True)
selector.setToolTip("选择患者") selector.setToolTip("选择患者")
selector.clicked.connect( selector.clicked.connect(lambda _checked=False, cell=host: self._select_patient(cell))
lambda checked=False, index=row_index: (
self.table.selectRow(index) if checked else None
)
)
layout.addWidget(selector) layout.addWidget(selector)
self.table.setCellWidget(row_index, 0, host) self.table.setCellWidget(row_index, 0, host)
item = self.table.item(row_index, 0) item = self.table.item(row_index, 0)
if item is not None: if item is not None:
item.setText("") item.setText("")
def _select_patient(self, cell: QWidget) -> None:
# Sorting moves cell widgets: resolve the visible row at click time.
row_index = self.table.indexAt(cell.pos()).row()
if row_index >= 0:
self.table.selectRow(row_index)
self._selection_changed()
def _install_appointment_info_cells(self) -> None: def _install_appointment_info_cells(self) -> None:
for row_index in range(self.table.rowCount()): for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0) source_item = self.table.item(row_index, 0)
@@ -1360,8 +1243,8 @@ class AppointmentsPage(QWidget):
host.setProperty("appointmentInfoHost", True) host.setProperty("appointmentInfoHost", True)
host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) host.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
layout = QVBoxLayout(host) layout = QVBoxLayout(host)
layout.setContentsMargins(7, 4, 5, 3) layout.setContentsMargins(15, 8, 15, 8)
layout.setSpacing(1) layout.setSpacing(4)
heading = QHBoxLayout() heading = QHBoxLayout()
heading.setContentsMargins(0, 0, 0, 0) heading.setContentsMargins(0, 0, 0, 0)
heading.setSpacing(5) heading.setSpacing(5)
@@ -1370,10 +1253,12 @@ class AppointmentsPage(QWidget):
status.setProperty("tableAppointmentStatus", True) status.setProperty("tableAppointmentStatus", True)
status.setProperty( status.setProperty(
"tableAppointmentStatusKind", "tableAppointmentStatusKind",
"warning" if "挂号" in status_text or _status_value(row) == 1 else "primary", {1: "warning", 4: "warning", 2: "muted"}.get(_status_value(row), "primary"),
) )
heading.addWidget(status) heading.addWidget(status)
doctor = QLabel(display_text(first_value(row, "doctor_name"), "未分配"), host) doctor = QLabel(display_text(first_value(row, "doctor_name"), "未分配"), host)
doctor.setToolTip(doctor.text())
doctor.setMinimumWidth(0)
heading.addWidget(doctor) heading.addWidget(doctor)
heading.addStretch(1) heading.addStretch(1)
if _status_value(row) == 1 and _canonical_allowed( if _status_value(row) == 1 and _canonical_allowed(
@@ -1394,19 +1279,31 @@ class AppointmentsPage(QWidget):
host, host,
) )
date_label.setProperty("tableAppointmentMeta", True) date_label.setProperty("tableAppointmentMeta", True)
layout.addWidget(date_label) date_row = QHBoxLayout()
date_row.setContentsMargins(0, 0, 0, 0)
date_row.setSpacing(6)
date_row.addWidget(date_label)
mode_label = QLabel(appointment_type_description(appointment_type_value(row)), host)
mode_label.setObjectName("AppointmentModeLabel")
mode_label.setProperty("tableAppointmentMeta", True)
date_row.addWidget(mode_label)
date_row.addStretch(1)
layout.addLayout(date_row)
channel = display_text( channel = display_text(
first_value(row, "channel_name", "channel_source_name", "source_name"), first_value(row, "channel_name", "channel_source_name", "source_name"),
"", "",
) )
channel_label = QLabel(f"最近渠道:{channel}", host) channel_label = QLabel(f"最近渠道:{channel}", host)
channel_label.setProperty("tableAppointmentMeta", True) channel_label.setProperty("tableAppointmentMeta", True)
channel_label.setToolTip(channel_label.text())
channel_label.setMinimumWidth(0)
layout.addWidget(channel_label) layout.addWidget(channel_label)
self.table.setCellWidget(row_index, 4, host) self.table.setCellWidget(row_index, 4, host)
patient = self.table.item(row_index, 2) patient = self.table.item(row_index, 2)
if patient is not None: if patient is not None:
font = patient.font() font = QFont(heading_family())
font.setBold(True) font.setPixelSize(14)
font.setWeight(QFont.Weight.Medium)
patient.setFont(font) patient.setFont(font)
def _install_im_consult_actions(self) -> None: def _install_im_consult_actions(self) -> None:
@@ -1428,7 +1325,8 @@ class AppointmentsPage(QWidget):
layout.setContentsMargins(5, 0, 5, 0) layout.setContentsMargins(5, 0, 5, 0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
button = QPushButton("IM 问诊", host) is_text = appointment_type_value(row) == "text"
button = QPushButton("图文沟通" if is_text else "IM 问诊", host)
button.setObjectName("AppointmentImConsultButton") button.setObjectName("AppointmentImConsultButton")
button.setProperty("appointmentImAction", True) button.setProperty("appointmentImAction", True)
patient_name = display_text(first_value(row, "patient_name"), "患者") patient_name = display_text(first_value(row, "patient_name"), "患者")
@@ -1456,7 +1354,8 @@ class AppointmentsPage(QWidget):
elif status_error: elif status_error:
tooltip = status_error tooltip = status_error
else: else:
tooltip = "打开患者 IM,可发送消息并从会话中发起视频" tooltip = ("打开图文问诊,可发送文字、图片和文件;不支持音视频通话"
if is_text else "打开患者 IM,可发送消息;通话能力由本次挂号决定")
button.setToolTip(tooltip) button.setToolTip(tooltip)
button.clicked.connect( button.clicked.connect(
lambda _checked=False, source=row: self._run_video_row_action( lambda _checked=False, source=row: self._run_video_row_action(
@@ -1484,7 +1383,18 @@ class AppointmentsPage(QWidget):
default=1, default=1,
) )
height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING
self.table.setRowHeight(row_index, max(60, min(66, height))) widget_height = max(
(widget.minimumSizeHint().height()
for column in range(self.table.columnCount())
if (widget := self.table.cellWidget(row_index, column)) is not None),
default=0,
)
# Cell padding is owned by the layouts, so long/multiline records
# can grow without shrinking the approved body font.
self.table.setRowHeight(
row_index, max(80 if getattr(self, "_compact_rows", False) else 88,
height, widget_height + 1)
)
def _load_error(self, error: Exception, generation: int, silent: bool) -> None: def _load_error(self, error: Exception, generation: int, silent: bool) -> None:
if generation != self._generation: if generation != self._generation:
@@ -1497,6 +1407,10 @@ class AppointmentsPage(QWidget):
if generation == self._generation: if generation == self._generation:
self._loading = False self._loading = False
def _poll_refresh(self) -> None:
if self.isVisible() and not self.pager.loading:
self.refresh(silent=True)
def _update_tab_badges(self) -> None: def _update_tab_badges(self) -> None:
for label, value in STATUS_TABS: for label, value in STATUS_TABS:
index = self._tab_indexes.get(value) index = self._tab_indexes.get(value)
@@ -1510,6 +1424,19 @@ class AppointmentsPage(QWidget):
self.tab_bar.setTabText(index, text) self.tab_bar.setTabText(index, text)
def _selection_changed(self) -> None: def _selection_changed(self) -> None:
for row_index in range(self.table.rowCount()):
host = self.table.cellWidget(row_index, 0)
if host is None:
continue
selected = self.table.selectionModel().isRowSelected(row_index)
selector = host.findChild(QCheckBox)
if selector is not None:
selector.setChecked(selected)
if host.property("selected") != selected:
host.setProperty("selected", selected)
host.style().unpolish(host)
host.style().polish(host)
host.update()
row = self.table.current_data() row = self.table.current_data()
has_row = row is not None has_row = row is not None
status = _status_value(row) if has_row else 0 status = _status_value(row) if has_row else 0
@@ -1517,6 +1444,7 @@ class AppointmentsPage(QWidget):
self.edit_button.setEnabled(has_row and _diagnosis_id(row) > 0) self.edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
self.qr_button.setEnabled( self.qr_button.setEnabled(
not_completed not_completed
and can_appointment_video(appointment_type_value(row))
and _video_patient_id(row) > 0 and _video_patient_id(row) > 0
and _as_int(first_value(row, "doctor_id", default=0)) > 0 and _as_int(first_value(row, "doctor_id", default=0)) > 0
) )
@@ -1540,6 +1468,7 @@ class AppointmentsPage(QWidget):
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0) self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
self.toolbar_qr_button.setEnabled( self.toolbar_qr_button.setEnabled(
not_completed not_completed
and can_appointment_video(appointment_type_value(row))
and _video_patient_id(row) > 0 and _video_patient_id(row) > 0
and _as_int(first_value(row, "doctor_id", default=0)) > 0 and _as_int(first_value(row, "doctor_id", default=0)) > 0
) )
@@ -1628,6 +1557,7 @@ class AppointmentsPage(QWidget):
{ {
"source": "appointments", "source": "appointments",
"appointment_id": appointment_id, "appointment_id": appointment_id,
"appointment_type": appointment_type_value(row),
"patient_id": patient_id, "patient_id": patient_id,
"diagnosis_id": diagnosis_id, "diagnosis_id": diagnosis_id,
"patient_name": first_value(row, "patient_name", default="患者"), "patient_name": first_value(row, "patient_name", default="患者"),
@@ -1652,6 +1582,9 @@ class AppointmentsPage(QWidget):
row = self._current_row() row = self._current_row()
if row is None or _status_value(row) == 3: if row is None or _status_value(row) == 3:
return return
if not can_appointment_video(appointment_type_value(row)):
show_toast(self, "本次挂号不支持视频问诊二维码。", "warning")
return
diagnosis_id = _diagnosis_id(row) diagnosis_id = _diagnosis_id(row)
patient_id = _video_patient_id(row) patient_id = _video_patient_id(row)
doctor_id = _as_int(first_value(row, "doctor_id", default=0)) doctor_id = _as_int(first_value(row, "doctor_id", default=0))
@@ -2276,7 +2209,7 @@ class AppointmentsPage(QWidget):
def _detail_html(self, detail: Mapping[str, Any]) -> str: def _detail_html(self, detail: Mapping[str, Any]) -> str:
def cell(label: str, value: Any) -> str: def cell(label: str, value: Any) -> str:
return ( return (
f"<tr><th style='text-align:left;color:#667085;padding:4px 12px 4px 0;'>" f"<tr><th style='text-align:left;color:#606163;padding:4px 12px 4px 0;'>"
f"{escape(label)}</th>" f"{escape(label)}</th>"
f"<td style='padding:4px 0;'>{escape(display_text(value))}</td></tr>" f"<td style='padding:4px 0;'>{escape(display_text(value))}</td></tr>"
) )
@@ -2304,7 +2237,7 @@ class AppointmentsPage(QWidget):
("时段", period_text), ("时段", period_text),
( (
"预约类型", "预约类型",
first_value(detail, "appointment_type_desc", "appointment_type_text"), appointment_type_description(appointment_type_value(detail)),
), ),
], ],
), ),
@@ -10,11 +10,12 @@ from html import escape
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any
from PySide6.QtCore import QDate, Qt, QTimer, QUrl, Signal from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QPixmap from PySide6.QtGui import QDesktopServices, QPixmap
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QCompleter,
QDateEdit, QDateEdit,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
@@ -22,7 +23,6 @@ from PySide6.QtWidgets import (
QFormLayout, QFormLayout,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QInputDialog, QInputDialog,
QLabel, QLabel,
QLineEdit, QLineEdit,
@@ -38,11 +38,12 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ...core.appointment_modes import appointment_type_value, can_appointment_video
from .. import icons
from ..consultations_style import consultations_stylesheet
from ..diagnosis_index_widgets import ( from ..diagnosis_index_widgets import (
DIAGNOSIS_INDEX_QSS,
DiagnosisChip, DiagnosisChip,
DiagnosisLoadingOverlay, DiagnosisLoadingOverlay,
DiagnosisPager,
DiagnosisTableHost, DiagnosisTableHost,
FlowWidget, FlowWidget,
prescription_action, prescription_action,
@@ -56,6 +57,8 @@ from ..dialogs.prescription import (
build_prescription_clinical_diagnosis, build_prescription_clinical_diagnosis,
build_prescription_visit_no, build_prescription_visit_no,
) )
from ..filter_disclosure import FilterDisclosure
from ..infinite_list import InfiniteList
from ..theme import mark_business_dialog from ..theme import mark_business_dialog
from ..widgets import ( from ..widgets import (
MessageBanner, MessageBanner,
@@ -72,118 +75,9 @@ from ..widgets import (
show_toast, show_toast,
) )
_PAGE_HEADER_HEIGHT = 62 _STATUS_CARD_HEIGHT = 54
_STATUS_CARD_HEIGHT = 50 _FILTERS_BASE_HEIGHT = 236
_FILTERS_COLLAPSED_HEIGHT = 90
CONSULTATIONS_REFERENCE_QSS = """
#DiagnosisIndex QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#DiagnosisIndex QLabel[role="pageTitle"] {
color: #15224A; font-size: 22px; font-weight: 700;
}
#DiagnosisIndex QLabel[role="muted"] { color: #7481A3; font-size: 12px; }
#DiagnosisIndex QLabel[role="breadcrumb"],
#DiagnosisIndex QLabel[role="breadcrumbCurrent"] { font-size: 12px; }
#DiagnosisIndex QFrame#DiagnosisStatusCard,
#DiagnosisIndex QFrame#DiagnosisFilterCard,
#DiagnosisIndex QFrame#DiagnosisListCard {
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard {
min-height: 50px; max-height: 50px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"] {
min-height: 34px; max-height: 34px; min-width: 56px;
padding: 0 8px; border: 0; border-radius: 8px;
background: transparent; color: #25345E; font-size: 13px; font-weight: 600;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"]:hover {
background: #F7F8FF; color: #5265F6;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"]:checked {
background: #F0F1FF; color: #5265F6;
border-bottom: 2px solid #6573F7;
}
#DiagnosisIndex QWidget#DiagnosisStatusSearch QLineEdit {
min-width: 78px; max-width: 138px;
}
#DiagnosisIndex QDateEdit#DiagnosisCustomDate {
min-width: 116px; max-width: 116px;
}
#DiagnosisIndex QFrame#DiagnosisFilterCard {
min-height: 88px;
}
#DiagnosisIndex QWidget#DiagnosisDateFilters,
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters {
background: transparent; border: 0;
}
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters QLineEdit#DiagnosisKeyword {
min-width: 260px; max-width: 380px;
}
#DiagnosisIndex QComboBox#DiagnosisConfirmationFilter { min-width: 136px; max-width: 136px; }
#DiagnosisIndex QComboBox#DiagnosisDepartmentFilter { min-width: 210px; max-width: 230px; }
#DiagnosisIndex QToolButton[diagnosisChip="true"] {
min-height: 20px; padding: 6px 12px; border-radius: 8px;
background: #F7F9FE; color: #405074; font-size: 12px;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked {
background: #EEF1FF; color: #5265F6; border-color: #C9D0FF;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="primary"],
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="info"] {
background: #EEF1FF; color: #5265F6; border-color: #C9D0FF;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="success"] {
background: #EAF9F4; color: #159C79; border-color: #BFE9DC;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"]:checked[semantic="warning"] {
background: #FFF5E4; color: #C17A16; border-color: #F2D49D;
}
#DiagnosisIndex QToolButton[diagnosisChip="true"][small="true"] {
min-height: 18px; padding: 4px 10px; font-size: 12px;
}
#DiagnosisIndex QLineEdit,
#DiagnosisIndex QComboBox,
#DiagnosisIndex QDateEdit,
#DiagnosisIndex QSpinBox {
min-height: 32px; max-height: 32px; border-radius: 8px;
background: #FFFFFF; border-color: #E2E7F4; color: #15224A;
font-size: 12px;
}
#DiagnosisIndex QPushButton {
min-height: 34px; max-height: 34px; padding: 0 14px;
border-radius: 8px; font-size: 12px;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar {
min-height: 44px; max-height: 44px; background: #FFFFFF;
border-bottom: 1px solid #E7EBF5;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton {
padding: 0 13px;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton[consultationTool="true"] {
color: #5265F6; background: #F7F8FF; border: 1px solid #E0E5F8;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton[consultationDanger="true"] {
color: #F15B67; background: #FFF7F8; border: 1px solid #FFD6DB;
}
#DiagnosisIndex QFrame#DiagnosisListCard { border-radius: 12px; }
#DiagnosisIndex QHeaderView::section {
min-height: 38px; max-height: 38px; padding: 0 8px;
background: #F7F9FE; color: #7481A3;
border-bottom: 1px solid #E7EBF5; font-size: 12px;
}
#DiagnosisIndex QTableView { background: #FFFFFF; alternate-background-color: #FBFCFF; }
#DiagnosisIndex QToolButton[rowLink] { font-size: 11px; padding: 2px; }
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 42px; max-height: 42px; }
#DiagnosisIndex QToolButton[pagerButton="true"] {
min-width: 32px; min-height: 32px; max-height: 32px;
border: 1px solid #E2E7F4; border-radius: 7px; background: #FFFFFF;
}
#DiagnosisIndex QToolButton[pagerButton="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
"""
APPOINTMENT_STATUS = { APPOINTMENT_STATUS = {
1: ("已预约", "warning"), 1: ("已预约", "warning"),
@@ -206,6 +100,31 @@ def _as_int(value: Any, default: int = 0) -> int:
return default return default
def _department_options(rows: Any) -> list[tuple[int, str]]:
"""Flatten the server-scoped tree without deriving extra options from profiles."""
if not isinstance(rows, (list, tuple)):
raise ValueError("部门数据格式异常,请重试")
options: list[tuple[int, str]] = []
seen: set[int] = set()
def visit(nodes: Sequence[Any], parents: tuple[str, ...] = ()) -> None:
for node in nodes:
if not isinstance(node, Mapping):
continue
label = str(node.get("name") or "").strip()
department_id = _as_int(node.get("id"))
path = (*parents, label) if label else parents
if department_id > 0 and label and department_id not in seen:
options.append((department_id, " / ".join(path)))
seen.add(department_id)
children = node.get("children")
if isinstance(children, (list, tuple)):
visit(children, path)
visit(rows)
return options
def _as_bool(value: Any) -> bool: def _as_bool(value: Any) -> bool:
if isinstance(value, str): if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"} return value.strip().lower() in {"1", "true", "yes", "on"}
@@ -308,7 +227,7 @@ def _appointment_status(record: Any) -> Any:
def _appointment_id(record: Any) -> int: def _appointment_id(record: Any) -> int:
value = first_value(record, "appointment_id", default=None) value = first_value(record, "appointment_id", "latest_appointment_id", default=None)
if value is None: if value is None:
value = first_value(get_value(record, "latest_appointment", None), "id", default=None) value = first_value(get_value(record, "latest_appointment", None), "id", default=None)
if value is None: if value is None:
@@ -372,6 +291,7 @@ def _video_payload(record: Any) -> dict[str, Any]:
return { return {
"source": "consultations", "source": "consultations",
"appointment_id": _appointment_id(record), "appointment_id": _appointment_id(record),
"appointment_type": appointment_type_value(record),
"patient_id": first_value(record, "patient_id", "source_patient_id"), "patient_id": first_value(record, "patient_id", "source_patient_id"),
"diagnosis_id": first_value(record, "diagnosis_id", "id"), "diagnosis_id": first_value(record, "diagnosis_id", "id"),
"patient_name": first_value(record, "patient_name", default="患者"), "patient_name": first_value(record, "patient_name", default="患者"),
@@ -829,8 +749,8 @@ class _QrImagePreview(QLabel):
self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setWordWrap(True) self.setWordWrap(True)
self.setStyleSheet( self.setStyleSheet(
"QLabel#DiagnosisOrderQrPreview{background:#FFFFFF;border:1px solid #E1E6F2;" "QLabel#DiagnosisOrderQrPreview{background:#FFFFFF;border:1px solid #EDEDEE;"
"border-radius:12px;color:#7481A3;padding:8px;}" "border-radius:12px;color:#606163;padding:8px;}"
) )
self._manager = QNetworkAccessManager(self) self._manager = QNetworkAccessManager(self)
self._reply: QNetworkReply | None = None self._reply: QNetworkReply | None = None
@@ -973,7 +893,7 @@ class _DiagnosisOrderQrDialog(QDialog):
self.qrcode_url = "" self.qrcode_url = ""
self.url_edit.clear() self.url_edit.clear()
self.status_label.setText("正在生成付款二维码...") self.status_label.setText("正在生成付款二维码...")
self.status_label.setStyleSheet("color:#7481A3;") self.status_label.setStyleSheet("color:#606163;")
self.preview.show_loading() self.preview.show_loading()
self.open_button.setEnabled(False) self.open_button.setEnabled(False)
self.retry_button.setEnabled(False) self.retry_button.setEnabled(False)
@@ -982,7 +902,7 @@ class _DiagnosisOrderQrDialog(QDialog):
self.qrcode_url = "" self.qrcode_url = ""
self.url_edit.clear() self.url_edit.clear()
self.status_label.setText(message) self.status_label.setText(message)
self.status_label.setStyleSheet("color:#EC5266;") self.status_label.setStyleSheet("color:#BE4B58;")
self.preview.show_failure("付款二维码生成失败") self.preview.show_failure("付款二维码生成失败")
self.open_button.setEnabled(False) self.open_button.setEnabled(False)
self.retry_button.setEnabled(retryable) self.retry_button.setEnabled(retryable)
@@ -993,7 +913,7 @@ class _DiagnosisOrderQrDialog(QDialog):
self.url_edit.setCursorPosition(0) self.url_edit.setCursorPosition(0)
self.url_edit.setToolTip(qrcode_url) self.url_edit.setToolTip(qrcode_url)
self.status_label.setText("付款二维码已生成") self.status_label.setText("付款二维码已生成")
self.status_label.setStyleSheet("color:#159C79;") self.status_label.setStyleSheet("color:#287B65;")
self.preview.load_url(qrcode_url) self.preview.load_url(qrcode_url)
self.open_button.setEnabled(True) self.open_button.setEnabled(True)
self.retry_button.setEnabled(True) self.retry_button.setEnabled(True)
@@ -1003,6 +923,19 @@ class _DiagnosisOrderQrDialog(QDialog):
QDesktopServices.openUrl(QUrl.fromUserInput(self.qrcode_url)) QDesktopServices.openUrl(QUrl.fromUserInput(self.qrcode_url))
def _tool_icon(button: QPushButton, kind: str, role: str = "soft") -> QPushButton:
"""Put a shared glyph on a toolbar button.
This page's toolbar was the only one in the product with no icons at all -
seven bare text buttons in a row - and it faked the one it did want with a
full-width "" character in the label.
"""
button.setIcon(icons.icon(kind, role, 15))
button.setIconSize(QSize(15, 15))
return button
class ConsultationsPage(QWidget): class ConsultationsPage(QWidget):
"""Diagnosis workspace with canonical filters and guarded row actions.""" """Diagnosis workspace with canonical filters and guarded row actions."""
@@ -1017,7 +950,7 @@ class ConsultationsPage(QWidget):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("DiagnosisIndex") self.setObjectName("DiagnosisIndex")
self.setStyleSheet(DIAGNOSIS_INDEX_QSS + CONSULTATIONS_REFERENCE_QSS) self.setStyleSheet(consultations_stylesheet())
self.repository = repository self.repository = repository
self.permissions = permissions self.permissions = permissions
self.current_user = current_user self.current_user = current_user
@@ -1026,6 +959,9 @@ class ConsultationsPage(QWidget):
self._generation = 0 self._generation = 0
self._count_generation = 0 self._count_generation = 0
self._options_generation = 0 self._options_generation = 0
self._departments_generation = 0
self._departments_loaded = False
self._departments_loading = False
self._prescription_generation = 0 self._prescription_generation = 0
self._mutation_generation = 0 self._mutation_generation = 0
self._menu_generation = 0 self._menu_generation = 0
@@ -1076,15 +1012,20 @@ class ConsultationsPage(QWidget):
content.setAutoFillBackground(False) content.setAutoFillBackground(False)
self.page_scroll.setWidget(content) self.page_scroll.setWidget(content)
page_layout = QVBoxLayout(content) page_layout = QVBoxLayout(content)
page_layout.setContentsMargins(18, 10, 18, 10) page_layout.setContentsMargins(27, 24, 26, 8)
page_layout.setSpacing(8) page_layout.setSpacing(10)
self.page_header = PageHeader( self.page_header = PageHeader(
"问诊列表", "问诊列表",
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。", "按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
content, content,
) )
self.page_header.setFixedHeight(_PAGE_HEADER_HEIGHT) self.page_header.setFixedHeight(90)
self.page_header.layout().setContentsMargins(0, 0, 0, 6)
self.page_header.layout().setSpacing(12)
self.page_header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(10)
self.page_header.actions.setAlignment(Qt.AlignmentFlag.AlignTop)
self.page_header.set_compact(True)
page_layout.addWidget(self.page_header) page_layout.addWidget(self.page_header)
status_card = QFrame() status_card = QFrame()
@@ -1092,8 +1033,11 @@ class ConsultationsPage(QWidget):
status_card.setFixedHeight(_STATUS_CARD_HEIGHT) status_card.setFixedHeight(_STATUS_CARD_HEIGHT)
self.status_card = status_card self.status_card = status_card
status_card_layout = QHBoxLayout(status_card) status_card_layout = QHBoxLayout(status_card)
status_card_layout.setContentsMargins(12, 5, 12, 5) status_card_layout.setContentsMargins(0, 0, 0, 0)
status_card_layout.setSpacing(10) status_card_layout.setSpacing(8)
status_label = self._filter_label("问诊状态:")
status_label.setFixedWidth(80)
status_card_layout.addWidget(status_label)
status_tabs = QWidget(status_card) status_tabs = QWidget(status_card)
status_tabs.setObjectName("DiagnosisStatusTabs") status_tabs.setObjectName("DiagnosisStatusTabs")
@@ -1114,31 +1058,36 @@ class ConsultationsPage(QWidget):
button.clicked.connect( button.clicked.connect(
lambda _checked=False, selected=value: self._choose_status(selected) lambda _checked=False, selected=value: self._choose_status(selected)
) )
status_tabs_layout.addWidget(button, 1) status_tabs_layout.addWidget(button)
self.status_buttons[value] = button self.status_buttons[value] = button
self.completed_button = self.status_buttons["3"] self.completed_button = self.status_buttons["3"]
status_card_layout.addWidget(status_tabs, 3) status_card_layout.addWidget(status_tabs)
status_card_layout.addStretch(1)
status_search = QWidget(status_card) status_search = QWidget(status_card)
status_search.setObjectName("DiagnosisStatusSearch") status_search.setObjectName("DiagnosisStatusSearch")
status_search.setFixedWidth(518)
status_search_layout = QHBoxLayout(status_search) status_search_layout = QHBoxLayout(status_search)
status_search_layout.setContentsMargins(0, 0, 0, 0) status_search_layout.setContentsMargins(0, 0, 0, 0)
status_search_layout.setSpacing(6) status_search_layout.setSpacing(16)
self.patient_name_edit = QLineEdit(status_search) self.patient_name_edit = QLineEdit(status_search)
self.patient_name_edit.setObjectName("DiagnosisPatientName") self.patient_name_edit.setObjectName("DiagnosisPatientName")
self.patient_name_edit.setFixedWidth(172)
self.patient_name_edit.addAction(icons.icon("search", "muted", 16), QLineEdit.ActionPosition.LeadingPosition)
self.patient_name_edit.setPlaceholderText("患者姓名") self.patient_name_edit.setPlaceholderText("患者姓名")
self.patient_name_edit.setClearButtonEnabled(True) self.patient_name_edit.setClearButtonEnabled(True)
self.patient_name_edit.returnPressed.connect(self._search) self.patient_name_edit.returnPressed.connect(self._search)
status_search_layout.addWidget(self.patient_name_edit, 1) status_search_layout.addWidget(self.patient_name_edit, 1)
self.doctor_edit = QLineEdit(status_search) self.doctor_edit = QLineEdit(status_search)
self.doctor_edit.setObjectName("DiagnosisDoctorName") self.doctor_edit.setObjectName("DiagnosisDoctorName")
self.doctor_edit.setFixedWidth(153)
self.doctor_edit.setPlaceholderText("医生") self.doctor_edit.setPlaceholderText("医生")
self.doctor_edit.setClearButtonEnabled(True) self.doctor_edit.setClearButtonEnabled(True)
self.doctor_edit.returnPressed.connect(self._search) self.doctor_edit.returnPressed.connect(self._search)
status_search_layout.addWidget(self.doctor_edit, 1) status_search_layout.addWidget(self.doctor_edit, 1)
self.search_button = QPushButton("查询", status_search) self.search_button = QPushButton("查询", status_search)
self.search_button.setProperty("variant", "primary") self.search_button.setProperty("variant", "primary")
self.search_button.setFixedWidth(62) self.search_button.setFixedWidth(73)
self.search_button.clicked.connect(self._search) self.search_button.clicked.connect(self._search)
status_search_layout.addWidget(self.search_button) status_search_layout.addWidget(self.search_button)
self.custom_date_edit = QDateEdit(status_search) self.custom_date_edit = QDateEdit(status_search)
@@ -1150,29 +1099,38 @@ class ConsultationsPage(QWidget):
self.custom_date_edit.setDate(_OPTIONAL_DATE_MINIMUM) self.custom_date_edit.setDate(_OPTIONAL_DATE_MINIMUM)
self.custom_date_edit.setToolTip("自定义挂号日期") self.custom_date_edit.setToolTip("自定义挂号日期")
self.custom_date_edit.dateChanged.connect(self._custom_date_changed) self.custom_date_edit.dateChanged.connect(self._custom_date_changed)
status_search_layout.addWidget(self.custom_date_edit) self.custom_date_edit.setFixedWidth(202)
self.reset_button = QPushButton("重置", status_search) self.reset_button = QPushButton("重置", status_search)
self.reset_button.setFixedWidth(58) self.reset_button.setFixedWidth(72)
self.reset_button.clicked.connect(self._reset) self.reset_button.clicked.connect(self._reset)
status_search_layout.addWidget(self.reset_button) status_search_layout.addWidget(self.reset_button)
status_card_layout.addWidget(status_search, 2)
page_layout.addWidget(status_card)
filters = QFrame() filters = QFrame()
filters.setObjectName("DiagnosisFilterCard") filters.setObjectName("DiagnosisFilterCard")
self.filters_card = filters self.filters_card = filters
filter_layout = QVBoxLayout(filters) filter_layout = QVBoxLayout(filters)
filter_layout.setContentsMargins(12, 6, 12, 6) filter_layout.setContentsMargins(18, 8, 18, 12)
filter_layout.setSpacing(4) filter_layout.setSpacing(8)
search_row = QHBoxLayout()
search_row.setContentsMargins(0, 0, 0, 0)
search_row.addStretch(1)
search_row.addWidget(status_search)
status_search.setFixedHeight(44)
filter_layout.addLayout(search_row)
filter_layout.addWidget(status_card)
date_filters = QWidget(filters) date_filters = QWidget(filters)
date_filters.setObjectName("DiagnosisDateFilters") date_filters.setObjectName("DiagnosisDateFilters")
date_filters.setMinimumHeight(48)
self.date_filters = date_filters
main_filters = QHBoxLayout(date_filters) main_filters = QHBoxLayout(date_filters)
main_filters.setContentsMargins(0, 0, 0, 0) main_filters.setContentsMargins(0, 0, 0, 0)
main_filters.setSpacing(8) main_filters.setSpacing(8)
main_filters.setAlignment(Qt.AlignmentFlag.AlignVCenter) main_filters.setAlignment(Qt.AlignmentFlag.AlignVCenter)
main_filters.addWidget(self._filter_label("日期:")) date_label = self._filter_label("日期:")
self.main_chip_flow = FlowWidget(horizontal_spacing=6, vertical_spacing=6) date_label.setFixedWidth(80)
main_filters.addWidget(date_label)
self.main_chip_flow = FlowWidget(horizontal_spacing=24, vertical_spacing=6)
self.date_buttons: dict[str, DiagnosisChip] = {} self.date_buttons: dict[str, DiagnosisChip] = {}
self._date_button_labels: dict[str, str] = {} self._date_button_labels: dict[str, str] = {}
for label, offset in ( for label, offset in (
@@ -1184,6 +1142,7 @@ class ConsultationsPage(QWidget):
): ):
value = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd") value = QDate.currentDate().addDays(offset).toString("yyyy-MM-dd")
button = DiagnosisChip(label) button = DiagnosisChip(label)
button.setProperty("dateChoice", True)
button.clicked.connect( button.clicked.connect(
lambda _checked=False, date_value=value: self._choose_date(date_value) lambda _checked=False, date_value=value: self._choose_date(date_value)
) )
@@ -1191,6 +1150,7 @@ class ConsultationsPage(QWidget):
self._date_button_labels[value] = label self._date_button_labels[value] = label
self.main_chip_flow.flow.addWidget(button) self.main_chip_flow.flow.addWidget(button)
all_button = DiagnosisChip("全部") all_button = DiagnosisChip("全部")
all_button.setProperty("dateChoice", True)
all_button.clicked.connect(lambda _checked=False: self._choose_date("")) all_button.clicked.connect(lambda _checked=False: self._choose_date(""))
self.date_buttons[""] = all_button self.date_buttons[""] = all_button
self._date_button_labels[""] = "全部" self._date_button_labels[""] = "全部"
@@ -1229,6 +1189,7 @@ class ConsultationsPage(QWidget):
self.pending_assign_filters.hide() self.pending_assign_filters.hide()
self.pending_assign_wrap.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign")) self.pending_assign_wrap.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
main_filters.addWidget(self.main_chip_flow, 1) main_filters.addWidget(self.main_chip_flow, 1)
main_filters.addWidget(self.custom_date_edit)
filter_layout.addWidget(date_filters) filter_layout.addWidget(date_filters)
self.has_appointment_combo = self._fixed_combo( self.has_appointment_combo = self._fixed_combo(
@@ -1244,13 +1205,19 @@ class ConsultationsPage(QWidget):
self.confirmed_combo = self.diagnosis_confirmed_combo self.confirmed_combo = self.diagnosis_confirmed_combo
self.department_combo = self._fixed_combo((("全部部门", ""),)) self.department_combo = self._fixed_combo((("全部部门", ""),))
self.department_combo.setObjectName("DiagnosisDepartmentFilter") self.department_combo.setObjectName("DiagnosisDepartmentFilter")
department_name = display_text( self.department_combo.setEditable(True)
first_value(current_user, "department_name", "department", "dept_name", default=""), self.department_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
"", self.department_combo.completer().setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
) self.department_combo.completer().setFilterMode(Qt.MatchFlag.MatchContains)
department_id = first_value(current_user, "department_id", "dept_id", default="") self.department_combo.completer().setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
if department_name: self.department_combo.lineEdit().setPlaceholderText("输入部门名称搜索")
self.department_combo.addItem(department_name, department_id or department_name) self.department_combo.lineEdit().returnPressed.connect(self._search)
self.department_retry_button = QToolButton()
self.department_retry_button.setObjectName("DiagnosisDepartmentRetry")
self.department_retry_button.setText("重试")
self.department_retry_button.setAccessibleName("重新加载部门")
self.department_retry_button.clicked.connect(lambda: self._load_departments(force=True))
self.department_retry_button.hide()
self.unserved_sort_combo = self._fixed_combo( self.unserved_sort_combo = self._fixed_combo(
( (
("未服务天数默认排序", ""), ("未服务天数默认排序", ""),
@@ -1261,26 +1228,39 @@ class ConsultationsPage(QWidget):
secondary_filters = QWidget(filters) secondary_filters = QWidget(filters)
secondary_filters.setObjectName("DiagnosisSecondaryFilters") secondary_filters.setObjectName("DiagnosisSecondaryFilters")
secondary_filters.setMinimumHeight(46)
self.secondary_filters = secondary_filters
secondary_layout = QHBoxLayout(secondary_filters) secondary_layout = QHBoxLayout(secondary_filters)
secondary_layout.setContentsMargins(0, 0, 0, 0) secondary_layout.setContentsMargins(0, 0, 0, 0)
secondary_layout.setSpacing(8) secondary_layout.setSpacing(8)
secondary_layout.addWidget(self._filter_label("确认诊单:")) self.diagnosis_confirmed_combo.setFixedWidth(126)
secondary_layout.addWidget(self.diagnosis_confirmed_combo) self.department_combo.setFixedWidth(178)
secondary_layout.addWidget(self._filter_label("部门:")) self.secondary_filter_flow = FlowWidget(horizontal_spacing=40, vertical_spacing=8)
secondary_layout.addWidget(self.department_combo) self.secondary_filter_flow.flow.addWidget(
self._filter_field("确认诊单:", self.diagnosis_confirmed_combo)
)
department_field = self._filter_field("部门:", self.department_combo)
department_field.layout().addWidget(self.department_retry_button)
self.secondary_filter_flow.flow.addWidget(department_field)
self.keyword_edit = QLineEdit(secondary_filters) self.keyword_edit = QLineEdit(secondary_filters)
self.keyword_edit.setObjectName("DiagnosisKeyword") self.keyword_edit.setObjectName("DiagnosisKeyword")
self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号") self.keyword_edit.setPlaceholderText("搜索患者姓名、手机号、病历号")
self.keyword_edit.setClearButtonEnabled(True) self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.setMinimumWidth(240) self.keyword_edit.addAction(icons.icon("search", "muted", 16), QLineEdit.ActionPosition.LeadingPosition)
self.keyword_edit.setMaximumWidth(380) self.keyword_edit.setMinimumWidth(185)
self.keyword_edit.setMaximumWidth(340)
self.keyword_edit.returnPressed.connect(self._search) self.keyword_edit.returnPressed.connect(self._search)
secondary_layout.addWidget(self.keyword_edit, 1) self.keyword_edit.setFixedWidth(340)
secondary_layout.addStretch(1) self.secondary_filter_flow.flow.addWidget(self.keyword_edit)
secondary_layout.addWidget(self.secondary_filter_flow, 1)
self.more_filter_button = QToolButton(secondary_filters) self.more_filter_button = QToolButton(secondary_filters)
self.more_filter_button.setObjectName("DiagnosisMoreFilter") self.more_filter_button.setObjectName("DiagnosisMoreFilter")
self.more_filter_button.setText("更多筛选") self.more_filter_button.setText("更多筛选")
self.more_filter_button.setArrowType(Qt.ArrowType.DownArrow) # The same Fusion solid triangle the row overflow used - a filled mark in
# an all-stroke set. A disclosure caret does belong before its label, so
# only the glyph changes here, not the side.
self.more_filter_button.setIcon(icons.icon("down", "muted", 14))
self.more_filter_button.setIconSize(QSize(14, 14))
self.more_filter_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) self.more_filter_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.more_filter_button.setCheckable(True) self.more_filter_button.setCheckable(True)
self.more_filter_button.clicked.connect(self._toggle_advanced_filters) self.more_filter_button.clicked.connect(self._toggle_advanced_filters)
@@ -1367,8 +1347,10 @@ class ConsultationsPage(QWidget):
advanced_layout.addWidget(self.advanced_filter_flow) advanced_layout.addWidget(self.advanced_filter_flow)
self.advanced_filters.hide() self.advanced_filters.hide()
filter_layout.addWidget(self.advanced_filters) filter_layout.addWidget(self.advanced_filters)
filters.setFixedHeight(_FILTERS_COLLAPSED_HEIGHT) filters.setFixedHeight(_FILTERS_BASE_HEIGHT)
page_layout.addWidget(filters) page_layout.addWidget(filters)
self.filter_disclosure = FilterDisclosure(self, [filters])
self.page_header.add_action(self.filter_disclosure.button)
card = QFrame() card = QFrame()
card.setObjectName("DiagnosisListCard") card.setObjectName("DiagnosisListCard")
@@ -1380,14 +1362,17 @@ class ConsultationsPage(QWidget):
toolbar = QFrame() toolbar = QFrame()
toolbar.setObjectName("DiagnosisListToolbar") toolbar.setObjectName("DiagnosisListToolbar")
toolbar_layout = QHBoxLayout(toolbar) toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(12, 4, 12, 4) toolbar_layout.setContentsMargins(18, 12, 18, 12)
toolbar_layout.setSpacing(6) toolbar_layout.setSpacing(8)
self.add_button = QPushButton(" 新增患者", toolbar) self.list_toolbar = toolbar
self.add_button = _tool_icon(QPushButton("新增患者", toolbar), "plus", "inverse")
self.add_button.setProperty("variant", "primary") self.add_button.setProperty("variant", "primary")
self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add")) self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add"))
self.add_button.clicked.connect(self._add_diagnosis) self.add_button.clicked.connect(self._add_diagnosis)
toolbar_layout.addWidget(self.add_button) toolbar_layout.addWidget(self.add_button)
self.video_qr_toolbar_button = QPushButton("视频二维码", toolbar) self.video_qr_toolbar_button = _tool_icon(
QPushButton("视频二维码", toolbar), "qr"
)
self.video_qr_toolbar_button.setProperty("consultationTool", True) self.video_qr_toolbar_button.setProperty("consultationTool", True)
self.video_qr_toolbar_button.setVisible( self.video_qr_toolbar_button.setVisible(
bool( bool(
@@ -1397,28 +1382,34 @@ class ConsultationsPage(QWidget):
) )
self.video_qr_toolbar_button.clicked.connect(self._request_video_qr) self.video_qr_toolbar_button.clicked.connect(self._request_video_qr)
toolbar_layout.addWidget(self.video_qr_toolbar_button) toolbar_layout.addWidget(self.video_qr_toolbar_button)
self.call_toolbar_button = QPushButton("通话", toolbar) self.call_toolbar_button = _tool_icon(QPushButton("通话", toolbar), "video")
self.call_toolbar_button.setProperty("consultationTool", True) self.call_toolbar_button.setProperty("consultationTool", True)
self.call_toolbar_button.setVisible(self._native_video_capable) self.call_toolbar_button.setVisible(self._native_video_capable)
self.call_toolbar_button.clicked.connect(self._request_video) self.call_toolbar_button.clicked.connect(self._request_video)
toolbar_layout.addWidget(self.call_toolbar_button) toolbar_layout.addWidget(self.call_toolbar_button)
self.complete_toolbar_button = QPushButton("完成", toolbar) self.complete_toolbar_button = _tool_icon(
QPushButton("完成", toolbar), "check_circle"
)
self.complete_toolbar_button.setProperty("consultationTool", True) self.complete_toolbar_button.setProperty("consultationTool", True)
self.complete_toolbar_button.setToolTip("查看当前问诊详情并完成问诊") self.complete_toolbar_button.setToolTip("查看当前问诊详情并完成问诊")
self.complete_toolbar_button.clicked.connect(self._open_readonly) self.complete_toolbar_button.clicked.connect(self._open_readonly)
toolbar_layout.addWidget(self.complete_toolbar_button) toolbar_layout.addWidget(self.complete_toolbar_button)
self.prescription_toolbar_button = QPushButton("开方", toolbar) self.prescription_toolbar_button = _tool_icon(
QPushButton("开方", toolbar), "prescriptions"
)
self.prescription_toolbar_button.setProperty("consultationTool", True) self.prescription_toolbar_button.setProperty("consultationTool", True)
self.prescription_toolbar_button.setVisible( self.prescription_toolbar_button.setVisible(
_canonical_allowed(permissions, "tcm.diagnosis/kaifang") _canonical_allowed(permissions, "tcm.diagnosis/kaifang")
) )
self.prescription_toolbar_button.clicked.connect(self._open_prescription) self.prescription_toolbar_button.clicked.connect(self._open_prescription)
toolbar_layout.addWidget(self.prescription_toolbar_button) toolbar_layout.addWidget(self.prescription_toolbar_button)
self.case_toolbar_button = QPushButton("病历", toolbar) self.case_toolbar_button = _tool_icon(QPushButton("病历", toolbar), "document")
self.case_toolbar_button.setProperty("consultationTool", True) self.case_toolbar_button.setProperty("consultationTool", True)
self.case_toolbar_button.clicked.connect(self._open_readonly) self.case_toolbar_button.clicked.connect(self._open_readonly)
toolbar_layout.addWidget(self.case_toolbar_button) toolbar_layout.addWidget(self.case_toolbar_button)
self.cancel_toolbar_button = QPushButton("取消挂号", toolbar) self.cancel_toolbar_button = _tool_icon(
QPushButton("取消挂号", toolbar), "stop", "danger"
)
self.cancel_toolbar_button.setProperty("consultationDanger", True) self.cancel_toolbar_button.setProperty("consultationDanger", True)
self.cancel_toolbar_button.setVisible( self.cancel_toolbar_button.setVisible(
bool( bool(
@@ -1428,7 +1419,9 @@ class ConsultationsPage(QWidget):
) )
self.cancel_toolbar_button.clicked.connect(self._cancel_selected_appointment) self.cancel_toolbar_button.clicked.connect(self._cancel_selected_appointment)
toolbar_layout.addWidget(self.cancel_toolbar_button) toolbar_layout.addWidget(self.cancel_toolbar_button)
self.batch_assign_button = QPushButton("批量指派医助", toolbar) self.batch_assign_button = _tool_icon(
QPushButton("批量指派医助", toolbar), "users", "inverse"
)
self.batch_assign_button.setProperty("variant", "success") self.batch_assign_button.setProperty("variant", "success")
self.batch_assign_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign")) self.batch_assign_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/assign"))
self.batch_assign_button.clicked.connect(self._batch_assign) self.batch_assign_button.clicked.connect(self._batch_assign)
@@ -1477,16 +1470,29 @@ class ConsultationsPage(QWidget):
self.delete_button = QPushButton("删除", self._compat_actions) self.delete_button = QPushButton("删除", self._compat_actions)
self.delete_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/delete")) self.delete_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/delete"))
self.delete_button.clicked.connect(self._delete_selected) self.delete_button.clicked.connect(self._delete_selected)
self.refresh_button = QPushButton("刷新", toolbar) self.refresh_button = _tool_icon(QPushButton("刷新", toolbar), "refresh")
self.refresh_button.clicked.connect(lambda: self.refresh()) self.refresh_button.clicked.connect(lambda: self.refresh())
toolbar_layout.addWidget(self.refresh_button) toolbar_layout.addWidget(self.refresh_button)
self.toolbar_flow = FlowWidget(horizontal_spacing=8, vertical_spacing=8)
for index in reversed(range(toolbar_layout.count())):
if toolbar_layout.itemAt(index).spacerItem() is not None:
toolbar_layout.takeAt(index)
for button in (
self.add_button, self.video_qr_toolbar_button, self.call_toolbar_button,
self.complete_toolbar_button, self.prescription_toolbar_button,
self.case_toolbar_button, self.cancel_toolbar_button,
):
toolbar_layout.removeWidget(button)
self.toolbar_flow.flow.addWidget(button)
toolbar_layout.insertWidget(0, self.toolbar_flow, 1)
table_wrap = QWidget() table_wrap = QWidget()
table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
table_wrap_layout = QVBoxLayout(table_wrap) table_wrap_layout = QVBoxLayout(table_wrap)
table_wrap_layout.setContentsMargins(12, 4, 12, 0) table_wrap_layout.setContentsMargins(0, 0, 0, 0)
table_wrap_layout.setSpacing(0) table_wrap_layout.setSpacing(0)
self.table_host = DiagnosisTableHost( self.table_host = DiagnosisTableHost(
tech_blue=True,
action_policy={ action_policy={
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"), "view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"), "edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
@@ -1520,7 +1526,6 @@ class ConsultationsPage(QWidget):
} }
) )
self.table = self.table_host.main self.table = self.table_host.main
self.table.horizontalHeader().setSectionResizeMode(9, QHeaderView.ResizeMode.Stretch)
# The shared host's gradient is useful on dark pages but renders as a # The shared host's gradient is useful on dark pages but renders as a
# heavy black strip in this light reference layout. # heavy black strip in this light reference layout.
self.table_host.fixed_shadow.hide() self.table_host.fixed_shadow.hide()
@@ -1534,9 +1539,8 @@ class ConsultationsPage(QWidget):
table_wrap_layout.addWidget(self.table_host, 1) table_wrap_layout.addWidget(self.table_host, 1)
self.loading_overlay = DiagnosisLoadingOverlay(self.table_host) self.loading_overlay = DiagnosisLoadingOverlay(self.table_host)
card_layout.addWidget(table_wrap, 1) card_layout.addWidget(table_wrap, 1)
self.pager = DiagnosisPager(self._page_size) self.pager = InfiniteList(self._page_size)
self.pager.page_changed.connect(self._change_page) self.pager.bind(self.table)
self.pager.page_size_changed.connect(self._change_page_size)
card_layout.addWidget(self.pager) card_layout.addWidget(self.pager)
page_layout.addWidget(card, 1) page_layout.addWidget(card, 1)
@@ -1586,7 +1590,9 @@ class ConsultationsPage(QWidget):
layout = QHBoxLayout(host) layout = QHBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5) layout.setSpacing(5)
layout.addWidget(self._filter_label(label_text)) label = self._filter_label(label_text)
label.setMinimumWidth(85 if label_text == "确认诊单:" else 46)
layout.addWidget(label)
layout.addWidget(control) layout.addWidget(control)
return host return host
@@ -1706,17 +1712,53 @@ class ConsultationsPage(QWidget):
def _toggle_advanced_filters(self, checked: bool) -> None: def _toggle_advanced_filters(self, checked: bool) -> None:
self.advanced_filters.setVisible(checked) self.advanced_filters.setVisible(checked)
self.filters_card.setFixedHeight(
_FILTERS_COLLAPSED_HEIGHT + self.advanced_filters.sizeHint().height() + 8
if checked
else _FILTERS_COLLAPSED_HEIGHT
)
self.more_filter_button.setText("收起" if checked else "更多筛选") self.more_filter_button.setText("收起" if checked else "更多筛选")
self.more_filter_button.setArrowType( self.more_filter_button.setIcon(
Qt.ArrowType.UpArrow if checked else Qt.ArrowType.DownArrow icons.icon("up" if checked else "down", "muted", 14)
) )
self.main_chip_flow.updateGeometry() self.main_chip_flow.updateGeometry()
self.quick_filter_flow.updateGeometry() self.quick_filter_flow.updateGeometry()
self._apply_responsive_layout()
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
if hasattr(self, "pager"):
self._apply_responsive_layout()
def _apply_responsive_layout(self) -> None:
"""Retain reference geometry while letting dense filters wrap on small windows."""
compact = self.height() < 800
narrow = self.width() < 1050
layout = self.page_scroll.widget().layout()
layout.setContentsMargins(20 if narrow else 27, 16 if compact else 24,
20 if narrow else 26, 8)
self.diagnosis_confirmed_combo.setFixedWidth(100 if narrow else 154)
self.department_combo.setFixedWidth(135 if narrow else 201)
self.keyword_edit.setFixedWidth(225 if narrow else 354)
self.secondary_filter_flow.flow._horizontal_spacing = 16 if narrow else 40
self.status_card.setFixedHeight(48 if compact else _STATUS_CARD_HEIGHT)
self.date_filters.setMinimumHeight(40 if compact else 48)
self.filters_card.layout().setSpacing(5 if compact else 8)
self.filters_card.layout().setContentsMargins(18, 6 if compact else 8,
18, 8 if compact else 12)
# Reserve the page scrollbar even before Qt has resolved its visibility.
# Otherwise the last field can wrap only after the height was measured.
usable = max(320, self.width() - (40 if narrow else 53) - 45)
date_height = max(40 if compact else 48,
self.main_chip_flow.flow.heightForWidth(usable - 298))
secondary_height = max(46, self.secondary_filter_flow.flow.heightForWidth(usable - 105))
self.date_filters.setFixedHeight(date_height)
self.secondary_filters.setFixedHeight(secondary_height)
filter_height = (44 + (5 if compact else 8)
+ (48 if compact else 54) + date_height + secondary_height
+ (24 if compact else 36))
if not self.advanced_filters.isHidden():
advanced_height = (self.quick_filter_flow.flow.heightForWidth(usable)
+ self.advanced_filter_flow.flow.heightForWidth(usable) + 25)
filter_height += advanced_height
self.filters_card.setFixedHeight(filter_height)
action_width = max(200, usable - self.refresh_button.sizeHint().width() - 8)
self.list_toolbar.setFixedHeight(self.toolbar_flow.flow.heightForWidth(action_width) + 24)
def _server_sort_changed(self, _index: int) -> None: def _server_sort_changed(self, _index: int) -> None:
if not hasattr(self, "table_host"): if not hasattr(self, "table_host"):
@@ -1812,6 +1854,8 @@ class ConsultationsPage(QWidget):
self.pending_assign_filters.setVisible(bool(self._pending_assign)) self.pending_assign_filters.setVisible(bool(self._pending_assign))
for flow in (self.main_chip_flow, self.quick_filter_flow): for flow in (self.main_chip_flow, self.quick_filter_flow):
flow.updateGeometry() flow.updateGeometry()
if hasattr(self, "pager") and self.isVisible():
self._apply_responsive_layout()
def _clear_latest_appointment_filters(self) -> None: def _clear_latest_appointment_filters(self) -> None:
self.latest_appointment_start_date.setDate(_OPTIONAL_DATE_MINIMUM) self.latest_appointment_start_date.setDate(_OPTIONAL_DATE_MINIMUM)
@@ -1858,6 +1902,15 @@ class ConsultationsPage(QWidget):
self.refresh() self.refresh()
def _validate_ranges(self) -> bool: def _validate_ranges(self) -> bool:
if (
self.department_combo.isEnabled()
and self.department_combo.currentText().strip()
and self.department_combo.currentText() != self.department_combo.itemText(
self.department_combo.currentIndex()
)
):
self.banner.show_message("请从部门搜索结果中选择部门,或清空部门筛选。", "warning")
return False
pairs = ( pairs = (
( (
self._date_value(self.latest_appointment_start_date), self._date_value(self.latest_appointment_start_date),
@@ -1906,23 +1959,16 @@ class ConsultationsPage(QWidget):
self._page = 1 self._page = 1
self.refresh() self.refresh()
def _change_page(self, page: int) -> None:
self._page = page
self.refresh(silent=True)
def _change_page_size(self, page_size: int) -> None:
if page_size not in {15, 20, 30, 40}:
return
self._page_size = page_size
self._page = 1
self.refresh(silent=True)
def _shared_filters(self) -> dict[str, Any]: def _shared_filters(self) -> dict[str, Any]:
return { return {
"keyword": self.keyword_edit.text().strip(), "keyword": self.keyword_edit.text().strip(),
"patient_name": self.patient_name_edit.text().strip(), "patient_name": self.patient_name_edit.text().strip(),
"doctor_name": self.doctor_edit.text().strip(), "doctor_name": self.doctor_edit.text().strip(),
"department_id": self._combo_value(self.department_combo), "assistant_dept_id": (
self.department_combo.currentData() or ""
if self.department_combo.currentText().strip()
else ""
),
"has_appointment": self._combo_value(self.has_appointment_combo), "has_appointment": self._combo_value(self.has_appointment_combo),
"diagnosis_confirmed": self._combo_value(self.diagnosis_confirmed_combo), "diagnosis_confirmed": self._combo_value(self.diagnosis_confirmed_combo),
"diagnosis_type": self._combo_value(self.diagnosis_type_combo), "diagnosis_type": self._combo_value(self.diagnosis_type_combo),
@@ -1978,7 +2024,7 @@ class ConsultationsPage(QWidget):
self._loading = True self._loading = True
self.refresh_button.setEnabled(False) self.refresh_button.setEnabled(False)
filters = MappingProxyType(self._filters()) filters = MappingProxyType(self._filters())
page = self._page page_size = self._page_size
if not silent: if not silent:
self._visible_loading_generation = generation self._visible_loading_generation = generation
self.table_host.begin_loading() self.table_host.begin_loading()
@@ -1986,16 +2032,18 @@ class ConsultationsPage(QWidget):
self._refresh_counts() self._refresh_counts()
elif self.loading_overlay.isVisible(): elif self.loading_overlay.isVisible():
self._visible_loading_generation = generation self._visible_loading_generation = generation
run_async( self.pager.reload(
lambda: invoke( lambda page: invoke(
self.repository, self.repository,
"consultations", "consultations",
**filters, **filters,
page=page, page=page,
page_size=self._page_size, page_size=page_size,
), ),
on_success=lambda result: self._apply_result(result, generation), apply=lambda result: self._apply_result(result, generation),
on_error=lambda error: self._load_error(error, generation), on_error=lambda error: self._load_error(error, generation),
runner=run_async,
query_key=filters,
on_finished=lambda: self._load_finished(generation), on_finished=lambda: self._load_finished(generation),
) )
@@ -2004,16 +2052,12 @@ class ConsultationsPage(QWidget):
return return
rows = page_items(result) rows = page_items(result)
total = page_total(result, len(rows)) total = page_total(result, len(rows))
last_page = max(1, (total + self._page_size - 1) // self._page_size) self._page = self.pager.page
if self._page > last_page:
self._page = last_page
self.refresh(silent=True)
return
self.table_host.force_open_prescription = bool(self._pending_assign) self.table_host.force_open_prescription = bool(self._pending_assign)
self.table_host.set_rows(rows) self.table_host.set_rows(rows)
self.pager.update_state(self._page, total) self.pager.update_state(self._page, total)
self.summary_label.setText( self.summary_label.setText(
f"{total} 条 · {self._page} 页 · 每页 {self._page_size}" f"{total} 条 · 已加载 {len(rows)}"
) )
self.banner.clear() self.banner.clear()
self._selection_changed() self._selection_changed()
@@ -2136,6 +2180,59 @@ class ConsultationsPage(QWidget):
} }
self._update_quick_buttons() self._update_quick_buttons()
def _load_departments(self, *, force: bool = False) -> None:
if not force and (self._departments_loaded or self._departments_loading):
return
self._departments_generation += 1
generation = self._departments_generation
self._departments_loading = True
self.department_combo.setEnabled(False)
self.department_combo.setToolTip("正在加载可选部门…")
self.department_retry_button.hide()
def load() -> list[tuple[int, str]]:
# Call directly: invoke() can discard unsupported kwargs, which would
# silently remove the permission scope on an older repository.
return _department_options(self.repository.list_departments(apply_data_scope=True))
run_async(
load,
on_success=lambda options: self._apply_departments(options, generation),
on_error=lambda error: self._departments_error(error, generation),
)
def _apply_departments(self, options: list[tuple[int, str]], generation: int) -> None:
if generation != self._departments_generation:
return
current = self.department_combo.currentData()
blocked = self.department_combo.blockSignals(True)
self.department_combo.clear()
self.department_combo.addItem("全部部门" if options else "暂无可选部门", "")
for department_id, label in options:
self.department_combo.addItem(label, department_id)
self.department_combo.setCurrentIndex(max(0, self.department_combo.findData(current)))
self.department_combo.blockSignals(blocked)
self._departments_loaded = True
self._departments_loading = False
self.department_combo.setEnabled(bool(options))
self.department_combo.setToolTip(
"输入部门名称搜索;选择父级包含下级部门" if options else "当前账号暂无可选部门"
)
self.department_retry_button.setVisible(not options)
def _departments_error(self, error: Exception, generation: int) -> None:
if generation != self._departments_generation:
return
self._departments_loading = False
self._departments_loaded = False
self.department_combo.clear()
self.department_combo.addItem("部门加载失败", "")
self.department_combo.setEnabled(False)
message = f"部门加载失败:{friendly_error(error)}。点击重试。"
self.department_combo.setToolTip(message)
self.department_retry_button.setToolTip(message)
self.department_retry_button.show()
def _load_filter_options(self) -> None: def _load_filter_options(self) -> None:
if self._options_loaded: if self._options_loaded:
return return
@@ -2561,6 +2658,10 @@ class ConsultationsPage(QWidget):
).strip() ).strip()
def _request_video_qr(self) -> None: def _request_video_qr(self) -> None:
record = self.table.current_data()
if record is not None and not can_appointment_video(appointment_type_value(record)):
show_toast(self, "本次挂号不支持视频问诊二维码。", "warning")
return
self._request_qr( self._request_qr(
capability="video_qr", capability="video_qr",
permission="tcm.diagnosis/videoQr", permission="tcm.diagnosis/videoQr",
@@ -2955,9 +3056,17 @@ class ConsultationsPage(QWidget):
self.video_button.setEnabled( self.video_button.setEnabled(
has_record has_record
and is_video_available(record) and is_video_available(record)
and video_call_is_live(record) and (appointment_type_value(record) == "text" or video_call_is_live(record))
and valid_ids and valid_ids
) )
self.call_toolbar_button.setEnabled(self.video_button.isEnabled())
self.video_qr_toolbar_button.setEnabled(has_record and can_appointment_video(appointment_type_value(record)))
self.complete_toolbar_button.setEnabled(has_record)
self.case_toolbar_button.setEnabled(has_record)
self.prescription_toolbar_button.setEnabled(has_record and not self._prescription_busy)
self.cancel_toolbar_button.setEnabled(
has_record and _single_cancellable_appointment(record) is not None
)
@property @property
def _diagnosis_dialog(self) -> DiagnosisDialog: def _diagnosis_dialog(self) -> DiagnosisDialog:
@@ -3395,7 +3504,7 @@ class ConsultationsPage(QWidget):
if record is None or not is_video_available(record): if record is None or not is_video_available(record):
self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning") self.banner.show_message("仅当前“已预约”的挂号可进入视频问诊。", "warning")
return return
if not video_call_is_live(record): if appointment_type_value(record) != "text" and not video_call_is_live(record):
self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning") self.banner.show_message("医生尚未发起视频会话,请等待会话开始。", "warning")
return return
payload = _video_payload(record) payload = _video_payload(record)
@@ -3412,7 +3521,7 @@ class ConsultationsPage(QWidget):
if ( if (
not self.isVisible() not self.isVisible()
or self._loading or self.pager.loading
or self._order_flow_generation is not None or self._order_flow_generation is not None
): ):
return return
@@ -3421,6 +3530,7 @@ class ConsultationsPage(QWidget):
def showEvent(self, event: Any) -> None: def showEvent(self, event: Any) -> None:
super().showEvent(event) super().showEvent(event)
self._load_departments()
self._load_filter_options() self._load_filter_options()
if not self.poll_timer.isActive(): if not self.poll_timer.isActive():
self.poll_timer.start() self.poll_timer.start()
File diff suppressed because it is too large Load Diff
@@ -8,29 +8,38 @@ from typing import Any
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor, QFont from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDialog, QDialog,
QFrame, QFrame,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QLabel, QLabel,
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QScrollArea,
QStackedWidget, QStackedWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from .. import motion
from ..dialogs.prescription import PrescriptionTemplateDialog from ..dialogs.prescription import PrescriptionTemplateDialog
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
from ..filter_disclosure import FilterDisclosure
from ..infinite_list import InfiniteList
from ..prescription_library_style import (
LibraryComboBox,
LibraryItemDelegate,
LibraryTable,
library_stylesheet,
)
from ..reception_style import body_family
from ..widgets import ( from ..widgets import (
BusinessPager,
EmptyState, EmptyState,
MessageBanner, MessageBanner,
MetricCard, MetricCard,
PageHeader, PageHeader,
SortableTable,
TableColumn, TableColumn,
display_text, display_text,
first_value, first_value,
@@ -45,131 +54,12 @@ from ..widgets import (
show_toast, show_toast,
) )
from .prescriptions import ( from .prescriptions import (
_cell_host, _ROLE_LEAD_ICON,
_ROLE_TAG_KIND,
_painted_icon, _painted_icon,
_row_action_button, _row_action_button,
_style_row_host,
_tag_label,
) )
PRESCRIPTION_LIBRARY_PAGE_QSS = """
#PrescriptionLibraryPage { background: #F8FAFF; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 14px; max-height: 14px;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px;
}
#PrescriptionLibraryPage QFrame#MetricCard {
min-height: 64px; max-height: 64px;
border: 1px solid #E2E7F4; border-radius: 12px; background: #FFFFFF;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
color: #405074; font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
color: #5265F6; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"] {
border: 1px solid #DCE3FF; border-radius: 11px; background: #EEF1FF;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="info"] {
border-color: #E5DFFF; background: #F2EEFF;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="success"] {
border-color: #CDEFE3; background: #E8F8F2;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"][kind="warning"] {
border-color: #F8DFC2; background: #FFF3E5;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar {
background: #FFFFFF; border: 0; border-bottom: 1px solid #E7EBF5;
border-top-left-radius: 13px; border-top-right-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 82px; min-height: 32px; max-height: 32px;
padding: 0 8px; margin: 0 4px 0 0;
color: #59698E; background: transparent; border: 0;
border-bottom: 2px solid transparent; border-radius: 0;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: #5265F6; background: transparent; border-bottom-color: #5265F6;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QLineEdit,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QComboBox {
min-height: 32px; max-height: 32px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QPushButton {
min-height: 34px; max-height: 34px; padding: 0 14px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton {
min-height: 32px; max-height: 32px; padding: 0 13px;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 27px; max-width: 27px; min-height: 27px; max-height: 27px;
padding: 0; border-radius: 7px; background: #FFFFFF;
border: 1px solid #DCE3F5;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover {
background: #F3F5FF; border-color: #AAB7FF;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"] {
background: #FFF9FA; border-color: #FFD9DE;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover {
background: #FFF1F3; border-color: #FFACB8;
}
#PrescriptionLibraryPage QTableWidget {
border: 0; border-radius: 0; background: #FFFFFF;
alternate-background-color: #FBFCFF; font-size: 12px;
}
#PrescriptionLibraryPage QTableWidget::item {
padding: 4px 8px; border-bottom: 1px solid #EDF0F7;
}
#PrescriptionLibraryPage QTableWidget::item:selected {
color: #26365F; background: #FCFDFF;
}
#PrescriptionLibraryPage QHeaderView::section {
min-height: 38px; max-height: 38px; padding: 0 8px;
background: #F7F9FE; color: #7481A3;
border: 0; border-bottom: 1px solid #E7EBF5;
font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QWidget#Pager { min-height: 42px; }
#PrescriptionLibraryPage QWidget#Pager QPushButton {
min-width: 34px; max-height: 32px; min-height: 32px; padding: 0 10px;
}
#PrescriptionLibraryPage QWidget#Pager QLabel#PagerActive {
min-width: 48px; min-height: 30px; border-radius: 7px;
background: #5265F6; color: #FFFFFF; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"] {
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton {
min-height: 32px; max-height: 32px;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionLibraryPage QWidget#BusinessPager QLabel[pagerSize="true"] {
min-width: 64px; color: #7481A3; font-size: 12px;
}
"""
def _formula_text(value: Any, _row: Any = None) -> str: def _formula_text(value: Any, _row: Any = None) -> str:
text = str(value or "").strip().lower() text = str(value or "").strip().lower()
@@ -197,7 +87,8 @@ def _herbs_detail(_value: Any, row: Any) -> str:
for herb in herbs: for herb in herbs:
name = first_value(herb, "name", "medicine_name", default="药材") name = first_value(herb, "name", "medicine_name", default="药材")
dosage = first_value(herb, "dosage", "amount", default="") dosage = first_value(herb, "dosage", "amount", default="")
pieces.append(f"{name} {dosage}g".strip()) unit = "" if str(dosage).strip().lower().endswith("g") else "g"
pieces.append(f"{name} {dosage}{unit}".strip())
return "".join(pieces) if pieces else "暂无药材" return "".join(pieces) if pieces else "暂无药材"
@@ -220,22 +111,16 @@ def _create_time_cell(value: Any, _row: Any) -> str:
return format_record_time(raw) return format_record_time(raw)
def _metric_card(title: str, kind: str = "accent") -> MetricCard: def _metric_card(title: str, kind: str = "accent", glyph: str = "layers") -> MetricCard:
card = MetricCard(title, "0", kind=kind, glyph="") card = MetricCard(title, "0", kind=kind, glyph="")
card.setFixedHeight(64) card.setFixedHeight(86)
card.layout().setContentsMargins(16, 8, 14, 8) card.layout().setContentsMargins(18, 14, 18, 14)
icon = QLabel(card) icon = QLabel(card)
icon.setProperty("metricIcon", True) icon.setProperty("metricIcon", True)
icon.setProperty("kind", kind)
icon.setAlignment(Qt.AlignmentFlag.AlignCenter) icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon.setFixedSize(36, 36) icon.setFixedSize(32, 32)
colors = { color = "#1769E8" if kind == "accent" else "#60789F"
"accent": "#5365F5", icon.setPixmap(_painted_icon(glyph, color, 25).pixmap(25, 25))
"info": "#8268E8",
"success": "#23A77D",
"warning": "#E6932C",
}
icon.setPixmap(_painted_icon("document", colors.get(kind, "#5365F5"), 18).pixmap(18, 18))
card.layout().addWidget(icon) card.layout().addWidget(icon)
return card return card
@@ -252,7 +137,11 @@ class PrescriptionLibraryPage(QWidget):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("PrescriptionLibraryPage") self.setObjectName("PrescriptionLibraryPage")
self.setStyleSheet(PRESCRIPTION_LIBRARY_PAGE_QSS) self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(library_stylesheet())
font = QFont(body_family())
font.setPixelSize(14)
self.setFont(font)
self.repository = repository self.repository = repository
self.permissions = permissions self.permissions = permissions
self.current_user = current_user self.current_user = current_user
@@ -262,19 +151,35 @@ class PrescriptionLibraryPage(QWidget):
self._page = 1 self._page = 1
self._page_size = 15 self._page_size = 15
root = QVBoxLayout(self) outer = QVBoxLayout(self)
root.setContentsMargins(24, 19, 24, 14) outer.setContentsMargins(28, 16, 26, 8)
root.setSpacing(12) self.scroll = QScrollArea()
self.scroll.setObjectName("PrescriptionLibraryScroll")
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.content = QWidget()
self.content.setObjectName("PrescriptionLibraryContent")
root = QVBoxLayout(self.content)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(10)
self.scroll.setWidget(self.content)
outer.addWidget(self.scroll)
header = PageHeader( header = PageHeader(
"处方库", "处方库",
"管理常用处方模板,支持 AI 解析辅助开方。", "管理常用处方模板,支持 AI 解析辅助开方。",
) )
header.layout().setSpacing(4) self.header = header
header.setFixedHeight(88)
header.layout().setSpacing(12)
header.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
header.layout().itemAt(1).layout().itemAt(0).layout().setSpacing(7)
header.actions.setSpacing(16) header.actions.setSpacing(16)
self.new_button = QPushButton("新增处方", header) self.new_button = QPushButton("新增处方", header)
self.new_button.setObjectName("PrescriptionLibraryAddButton")
self.new_button.setMinimumWidth(124) self.new_button.setMinimumWidth(124)
self.new_button.setProperty("variant", "primary") self.new_button.setProperty("variant", "primary")
self.new_button.setIcon(_painted_icon("plus", "#FFFFFF", 15)) self.new_button.setIcon(_painted_icon("plus", "inverse", 15))
self.new_button.setIconSize(QSize(15, 15)) self.new_button.setIconSize(QSize(15, 15))
self.new_button.setCursor(Qt.CursorShape.PointingHandCursor) self.new_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add")) self.new_button.setVisible(has_permission(permissions, "wcf.prescription/add"))
@@ -282,18 +187,19 @@ class PrescriptionLibraryPage(QWidget):
header.add_action(self.new_button) header.add_action(self.new_button)
root.addWidget(header) root.addWidget(header)
metrics = QHBoxLayout() self.metrics_panel = QWidget(self.content)
metrics = QHBoxLayout(self.metrics_panel)
metrics.setContentsMargins(0, 0, 0, 0) metrics.setContentsMargins(0, 0, 0, 0)
metrics.setSpacing(16) metrics.setSpacing(16)
self.metric_cards = { self.metric_cards = {
"total": _metric_card("全部处方"), "total": _metric_card("全部处方", "accent", "layers"),
"private": _metric_card("仅自己", "info"), "private": _metric_card("仅自己", "info", "lock"),
"public": _metric_card("公开处方", "success"), "public": _metric_card("公开处方", "success", "users"),
"month": _metric_card("本月新增", "warning"), "month": _metric_card("本月新增", "warning", "calendar"),
} }
for metric in self.metric_cards.values(): for metric in self.metric_cards.values():
metrics.addWidget(metric) metrics.addWidget(metric)
root.addLayout(metrics) root.addWidget(self.metrics_panel)
self.hint_banner = MessageBanner(parent=self) self.hint_banner = MessageBanner(parent=self)
self.hint_banner.show_message( self.hint_banner.show_message(
@@ -304,43 +210,47 @@ class PrescriptionLibraryPage(QWidget):
filters = QFrame() filters = QFrame()
filters.setObjectName("PrescriptionLibraryFilterBar") filters.setObjectName("PrescriptionLibraryFilterBar")
filters.setFixedHeight(52) self.filter_card = filters
filters.setFixedHeight(90)
grid = QGridLayout(filters) grid = QGridLayout(filters)
grid.setContentsMargins(16, 9, 16, 9) self.filter_grid = grid
grid.setContentsMargins(18, 24, 18, 24)
grid.setVerticalSpacing(14)
grid.setHorizontalSpacing(12) grid.setHorizontalSpacing(12)
self.name_filter = QLineEdit() self.name_filter = QLineEdit()
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词") self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
self.name_filter.setClearButtonEnabled(True) self.name_filter.setClearButtonEnabled(True)
self.name_filter.returnPressed.connect(self._search) self.name_filter.returnPressed.connect(self._search)
grid.addWidget(self.name_filter, 0, 0) grid.addWidget(self.name_filter, 0, 0)
self.formula_filter = QComboBox() self.formula_filter = LibraryComboBox()
self.formula_filter.addItem("全部类型", "") self.formula_filter.addItem("全部类型", "")
self.formula_filter.addItem("主方", "主方") self.formula_filter.addItem("主方", "主方")
self.formula_filter.addItem("辅方", "辅方") self.formula_filter.addItem("辅方", "辅方")
grid.addWidget(self.formula_filter, 0, 1) grid.addWidget(self.formula_filter, 0, 1)
self.visibility_filter = QComboBox() self.visibility_filter = LibraryComboBox()
self.visibility_filter.addItem("全部公开范围", "") self.visibility_filter.addItem("全部公开范围", "")
self.visibility_filter.addItem("仅自己可见", 0) self.visibility_filter.addItem("仅自己可见", 0)
self.visibility_filter.addItem("所有人可见", 1) self.visibility_filter.addItem("所有人可见", 1)
grid.addWidget(self.visibility_filter, 0, 2) grid.addWidget(self.visibility_filter, 0, 2)
self.effect_filter = QComboBox() self.effect_filter = LibraryComboBox()
self.effect_filter.addItem("全部功效类型", "") self.effect_filter.addItem("全部功效类型", "")
self.effect_filter.addItem("益气养阴", "益气养阴") self.effect_filter.addItem("益气养阴", "益气养阴")
self.effect_filter.addItem("清热祛湿", "清热祛湿") self.effect_filter.addItem("清热祛湿", "清热祛湿")
self.effect_filter.addItem("滋阴补肾", "滋阴补肾") self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
grid.addWidget(self.effect_filter, 0, 3) grid.addWidget(self.effect_filter, 0, 3)
self.name_filter.setMinimumWidth(220) self.name_filter.setMinimumWidth(260)
self.formula_filter.setMinimumWidth(132) self.formula_filter.setMinimumWidth(140)
self.visibility_filter.setMinimumWidth(148) self.visibility_filter.setMinimumWidth(168)
self.effect_filter.setMinimumWidth(144) self.effect_filter.setMinimumWidth(162)
self.query_button = QPushButton("查询") self.query_button = QPushButton("查询")
self.query_button.setFixedWidth(66) self.query_button.setObjectName("PrescriptionLibraryQueryButton")
self.query_button.setFixedWidth(84)
self.query_button.setProperty("variant", "secondary") self.query_button.setProperty("variant", "secondary")
self.query_button.setCursor(Qt.CursorShape.PointingHandCursor) self.query_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.query_button.clicked.connect(self._search) self.query_button.clicked.connect(self._search)
grid.addWidget(self.query_button, 0, 4) grid.addWidget(self.query_button, 0, 4)
self.reset_button = QPushButton("重置") self.reset_button = QPushButton("重置")
self.reset_button.setFixedWidth(66) self.reset_button.setFixedWidth(84)
self.reset_button.setProperty("variant", "ghost") self.reset_button.setProperty("variant", "ghost")
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor) self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.reset_button.clicked.connect(self._reset_filters) self.reset_button.clicked.connect(self._reset_filters)
@@ -350,19 +260,31 @@ class PrescriptionLibraryPage(QWidget):
grid.setColumnStretch(2, 1) grid.setColumnStretch(2, 1)
grid.setColumnStretch(3, 1) grid.setColumnStretch(3, 1)
root.addWidget(filters) root.addWidget(filters)
self.filter_disclosure = FilterDisclosure(self, [self.metrics_panel, filters])
header.add_action(self.filter_disclosure.button)
header.set_compact()
self.banner = MessageBanner() self.banner = MessageBanner()
root.addWidget(self.banner) root.addWidget(self.banner)
card = QFrame() card = QFrame()
card.setObjectName("PrescriptionLibraryTableCard") card.setObjectName("PrescriptionLibraryTableCard")
self.table_card = card
card.setMinimumHeight(320)
card_layout = QVBoxLayout(card) card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(0, 0, 0, 0) card_layout.setContentsMargins(1, 0, 1, 1)
card_layout.setSpacing(0) card_layout.setSpacing(0)
toolbar_host = QFrame(card) toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionLibraryToolbar") toolbar_host.setObjectName("PrescriptionLibraryToolbar")
toolbar_host.setFixedHeight(46) toolbar_host.setFixedHeight(64)
toolbar_scroll = QScrollArea(card)
toolbar_scroll.setObjectName("PrescriptionLibraryToolbarScroll")
toolbar_scroll.setFrameShape(QFrame.Shape.NoFrame)
toolbar_scroll.setWidgetResizable(True)
toolbar_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
toolbar_scroll.setFixedHeight(64)
toolbar_scroll.setWidget(toolbar_host)
toolbar = QHBoxLayout(toolbar_host) toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 7, 16, 7) toolbar.setContentsMargins(18, 7, 18, 7)
toolbar.setSpacing(8) toolbar.setSpacing(8)
self.all_tab = QPushButton("处方列表", toolbar_host) self.all_tab = QPushButton("处方列表", toolbar_host)
self.all_tab.setProperty("toolbarTab", True) self.all_tab.setProperty("toolbarTab", True)
@@ -377,7 +299,7 @@ class PrescriptionLibraryPage(QWidget):
toolbar.addWidget(self.favorite_tab) toolbar.addWidget(self.favorite_tab)
toolbar.addStretch(1) toolbar.addStretch(1)
self.view_button = QPushButton("查看", card) self.view_button = QPushButton("查看", card)
self.view_button.setIcon(_painted_icon("eye", "#5265F6", 15)) self.view_button.setIcon(_painted_icon("eye", "accent", 15))
self.view_button.setIconSize(QSize(15, 15)) self.view_button.setIconSize(QSize(15, 15))
self.view_button.setCursor(Qt.CursorShape.PointingHandCursor) self.view_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read")) self.view_button.setVisible(has_permission(permissions, "wcf.prescription/read"))
@@ -386,7 +308,7 @@ class PrescriptionLibraryPage(QWidget):
toolbar.addWidget(self.view_button) toolbar.addWidget(self.view_button)
self.ai_button = QPushButton("AI解释", card) self.ai_button = QPushButton("AI解释", card)
self.ai_button.setProperty("variant", "secondary") self.ai_button.setProperty("variant", "secondary")
self.ai_button.setIcon(_painted_icon("spark", "#5265F6", 15)) self.ai_button.setIcon(_painted_icon("spark", "accent", 15))
self.ai_button.setIconSize(QSize(15, 15)) self.ai_button.setIconSize(QSize(15, 15))
self.ai_button.setCursor(Qt.CursorShape.PointingHandCursor) self.ai_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.ai_button.setVisible(can_open_ai_explain(permissions)) self.ai_button.setVisible(can_open_ai_explain(permissions))
@@ -394,7 +316,7 @@ class PrescriptionLibraryPage(QWidget):
self.ai_button.clicked.connect(self._explain_selected) self.ai_button.clicked.connect(self._explain_selected)
toolbar.addWidget(self.ai_button) toolbar.addWidget(self.ai_button)
self.edit_button = QPushButton("编辑", card) self.edit_button = QPushButton("编辑", card)
self.edit_button.setIcon(_painted_icon("pencil", "#5265F6", 15)) self.edit_button.setIcon(_painted_icon("pencil", "accent", 15))
self.edit_button.setIconSize(QSize(15, 15)) self.edit_button.setIconSize(QSize(15, 15))
self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor) self.edit_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit")) self.edit_button.setVisible(has_permission(permissions, "wcf.prescription/edit"))
@@ -403,7 +325,7 @@ class PrescriptionLibraryPage(QWidget):
toolbar.addWidget(self.edit_button) toolbar.addWidget(self.edit_button)
self.delete_button = QPushButton("删除", card) self.delete_button = QPushButton("删除", card)
self.delete_button.setProperty("variant", "danger") self.delete_button.setProperty("variant", "danger")
self.delete_button.setIcon(_painted_icon("trash", "#F34E64", 15)) self.delete_button.setIcon(_painted_icon("trash", "danger", 15))
self.delete_button.setIconSize(QSize(15, 15)) self.delete_button.setIconSize(QSize(15, 15))
self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete")) self.delete_button.setVisible(has_permission(permissions, "wcf.prescription/delete"))
@@ -412,18 +334,19 @@ class PrescriptionLibraryPage(QWidget):
toolbar.addWidget(self.delete_button) toolbar.addWidget(self.delete_button)
refresh = QPushButton("刷新") refresh = QPushButton("刷新")
refresh.setProperty("variant", "ghost") refresh.setProperty("variant", "ghost")
refresh.setIcon(_painted_icon("refresh", "#5D6E96", 15)) refresh.setIcon(_painted_icon("refresh", "soft", 15))
refresh.setIconSize(QSize(15, 15)) refresh.setIconSize(QSize(15, 15))
refresh.setCursor(Qt.CursorShape.PointingHandCursor) refresh.setCursor(Qt.CursorShape.PointingHandCursor)
refresh.clicked.connect(self.refresh) refresh.clicked.connect(self.refresh)
toolbar.addWidget(refresh) toolbar.addWidget(refresh)
card_layout.addWidget(toolbar_host) card_layout.addWidget(toolbar_scroll)
self.stack = QStackedWidget() self.stack = QStackedWidget()
table_host = QWidget() table_host = QWidget()
table_layout = QVBoxLayout(table_host) table_layout = QVBoxLayout(table_host)
table_layout.setContentsMargins(0, 0, 0, 0) table_layout.setContentsMargins(0, 0, 0, 0)
self.table = SortableTable( table_layout.setSpacing(0)
self.table = LibraryTable(
[ [
TableColumn("id", "ID", 62), TableColumn("id", "ID", 62),
TableColumn("prescription_name", "处方名称", 162), TableColumn("prescription_name", "处方名称", 162),
@@ -437,15 +360,27 @@ class PrescriptionLibraryPage(QWidget):
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""), TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
] ]
) )
self.table.verticalHeader().setDefaultSectionSize(36) self.table.setObjectName("PrescriptionLibraryTable")
self.table.horizontalHeader().setFixedHeight(38) self.table.setItemDelegate(LibraryItemDelegate(self.table))
self.table.setMouseTracking(True)
self.table.setAlternatingRowColors(False)
self.table.verticalHeader().setMinimumSectionSize(78)
self.table.verticalHeader().setDefaultSectionSize(78)
self.table.horizontalHeader().setStretchLastSection(False)
self.table.horizontalHeader().setFixedHeight(44)
self.table.horizontalHeader().setMinimumSectionSize(36)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
self.table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
for column, width in enumerate((54, 170, 76, 76, 260, 90, 134, 136, 122, 150)):
self.table.setColumnWidth(column, width)
for column in (0, 2, 3, 5, 9):
self.table.horizontalHeaderItem(column).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setWordWrap(False) self.table.setWordWrap(False)
self.table.itemSelectionChanged.connect(self._selection_changed) self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected()) self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1) table_layout.addWidget(self.table, 1)
self.pager = BusinessPager(self._page_size) self.pager = InfiniteList(self._page_size)
self.pager.page_changed.connect(self._change_page) self.pager.bind(self.table)
table_layout.addWidget(self.pager)
self.stack.addWidget(table_host) self.stack.addWidget(table_host)
empty = EmptyState( empty = EmptyState(
"还没有处方模板", "还没有处方模板",
@@ -454,10 +389,43 @@ class PrescriptionLibraryPage(QWidget):
) )
empty.action_button.setVisible(self.new_button.isVisible()) empty.action_button.setVisible(self.new_button.isVisible())
empty.action_requested.connect(self._new_template) empty.action_requested.connect(self._new_template)
empty.layout().itemAt(0).setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.stack.addWidget(empty) self.stack.addWidget(empty)
card_layout.addWidget(self.stack, 1) card_layout.addWidget(self.stack, 1)
card_layout.addWidget(self.pager)
root.addWidget(card, 1) root.addWidget(card, 1)
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
if hasattr(self, "filter_grid"):
compact_height = self.height() < 760
self.layout().setContentsMargins(28, 16, 26, 8)
self.content.layout().setSpacing(8 if compact_height else 10)
self._reflow_filters()
def _reflow_filters(self) -> None:
compact = self.width() < 1120
if getattr(self, "_compact_filters", None) == compact:
return
self._compact_filters = compact
controls = (self.name_filter, self.formula_filter, self.visibility_filter,
self.effect_filter, self.query_button, self.reset_button)
for control in controls:
self.filter_grid.removeWidget(control)
for column in range(6):
self.filter_grid.setColumnStretch(column, 0)
if compact:
positions = ((0, 0, 2), (0, 2, 1), (0, 3, 1), (1, 0, 2), (1, 2, 1), (1, 3, 1))
stretches = (2, 2, 1, 1)
else:
positions = tuple((0, column, 1) for column in range(6))
stretches = (4, 1, 1, 1, 0, 0)
for control, (row, column, span) in zip(controls, positions, strict=True):
self.filter_grid.addWidget(control, row, column, 1, span)
for column, stretch in enumerate(stretches):
self.filter_grid.setColumnStretch(column, stretch)
self.filter_card.setFixedHeight(144 if compact else 90)
def _search(self) -> None: def _search(self) -> None:
self._page = 1 self._page = 1
self.refresh() self.refresh()
@@ -481,14 +449,7 @@ class PrescriptionLibraryPage(QWidget):
self.effect_filter.setCurrentIndex(0) self.effect_filter.setCurrentIndex(0)
self._search() self._search()
def _change_page(self, page: int) -> None:
self._page = page
self.refresh()
def refresh(self) -> None: def refresh(self) -> None:
if self._loading:
self._refresh_pending = True
return
self._loading = True self._loading = True
self._refresh_pending = False self._refresh_pending = False
self._generation += 1 self._generation += 1
@@ -497,27 +458,26 @@ class PrescriptionLibraryPage(QWidget):
"prescription_name": self.name_filter.text().strip(), "prescription_name": self.name_filter.text().strip(),
"formula_type": self.formula_filter.currentData(), "formula_type": self.formula_filter.currentData(),
"is_public": self.visibility_filter.currentData(), "is_public": self.visibility_filter.currentData(),
"page": self._page,
"page_size": self._page_size, "page_size": self._page_size,
} }
requested_page = self._page # 列表加载不再挂横幅。横幅占布局空间,弹出与收起各触发一次重排,
self.banner.show_message("正在加载处方库…", "info") # 每刷新一次表格就上下跳一次——而轮询定时器每 5 秒就刷新一次。
run_async( # 已有数据时保持旧行可见、静默替换;失败仍然照常报错。
lambda: invoke( effect = str(self.effect_filter.currentData() or "").strip()
self.repository, self.pager.reload(
"prescription_library", lambda page: invoke(self.repository, "prescription_library", page=page, **query),
**query, lambda result: self._apply_result(result, generation, self.pager.page, effect),
), lambda error: self._load_error(error, generation),
on_success=lambda result: self._apply_result(result, generation, requested_page), runner=run_async,
on_error=lambda error: self._load_error(error, generation), query_key=(query, effect),
on_finished=lambda: self._load_finished(generation), on_finished=lambda: self._load_finished(generation),
) )
def _apply_result(self, result: Any, generation: int, requested_page: int) -> None: def _apply_result(self, result: Any, generation: int, requested_page: int, effect_filter: str | None = None) -> None:
if generation != self._generation: if generation != self._generation:
return return
rows = page_items(result) rows = page_items(result)
effect = str(self.effect_filter.currentData() or "").strip() effect = str(self.effect_filter.currentData() or "").strip() if effect_filter is None else effect_filter
if effect: if effect:
rows = [ rows = [
row row
@@ -554,11 +514,26 @@ class PrescriptionLibraryPage(QWidget):
self.metric_cards["private"].set_value(max(0, len(rows) - public_count)) self.metric_cards["private"].set_value(max(0, len(rows) - public_count))
self.metric_cards["public"].set_value(public_count) self.metric_cards["public"].set_value(public_count)
self.metric_cards["month"].set_value(month_count) self.metric_cards["month"].set_value(month_count)
self.stack.setCurrentIndex(0 if rows else 1) motion.switch_stack(self.stack, 0 if rows or self.pager.has_more else 1)
self.banner.clear() self.banner.clear()
self._selection_changed() self._selection_changed()
def _decorate_rows(self, rows: list[Any]) -> None: def _decorate_rows(self, rows: list[Any]) -> None:
# set_rows restores the active sort before decorating. Bind each action
# to the object now shown in that row, and keep rows still while tags
# are assigned (the type or visibility column may itself be sorted).
visible_rows = [
self.table.item(index, 0).data(Qt.ItemDataRole.UserRole)
for index in range(self.table.rowCount())
]
sorting = self.table.isSortingEnabled()
self.table.setSortingEnabled(False)
try:
self._decorate_rows_locked(visible_rows)
finally:
self.table.setSortingEnabled(sorting)
def _decorate_rows_locked(self, rows: list[Any]) -> None:
"""Install compact tags and permission-aware row actions from the comp.""" """Install compact tags and permission-aware row actions from the comp."""
for row_index, row in enumerate(rows): for row_index, row in enumerate(rows):
@@ -570,44 +545,32 @@ class PrescriptionLibraryPage(QWidget):
name_item = self.table.item(row_index, 1) name_item = self.table.item(row_index, 1)
if name_item is not None: if name_item is not None:
name_item.setForeground(QColor("#24355F")) name_item.setForeground(QColor("#1A1C1F"))
font = name_item.font() font = name_item.font()
font.setWeight(QFont.Weight.DemiBold) font.setWeight(QFont.Weight.Medium)
name_item.setFont(font) name_item.setFont(font)
# 处方类型与公开范围改由 _RowDecorationDelegate 绘制:原先每行为这两列
# 各挂一个 QWidgetsetCellWidget 是刷新路径上最贵的调用,而且白色的
# 宿主控件会把整行的选中底色挖出两个缺口。
formula = _formula_text(first_value(row, "formula_type", default="主方")) formula = _formula_text(first_value(row, "formula_type", default="主方"))
formula_kind = "success" if formula == "主方" else "accent" formula_item = self.table.item(row_index, 2)
self.table.setCellWidget( if formula_item is not None:
row_index, formula_item.setText(formula)
2, formula_item.setData(
_style_row_host( _ROLE_TAG_KIND, "success" if formula == "主方" else "accent"
_cell_host( )
_tag_label(formula, formula_kind, self.table.viewport()) formula_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
),
row_index,
),
)
visibility_host = QWidget(self.table.viewport()) visibility_item = self.table.item(row_index, 6)
_style_row_host(visibility_host, row_index) if visibility_item is not None:
visibility_layout = QHBoxLayout(visibility_host) visibility_item.setText(
visibility_layout.setContentsMargins(8, 0, 6, 0) _visibility_text(first_value(row, "is_public", default=0))
visibility_layout.setSpacing(6) )
visibility_icon = QLabel(visibility_host) visibility_item.setData(_ROLE_LEAD_ICON, "lock")
visibility_icon.setFixedSize(15, 15)
visibility_icon.setPixmap(_painted_icon("lock", "#60709A", 14).pixmap(14, 14))
visibility_layout.addWidget(visibility_icon)
visibility_label = QLabel(
_visibility_text(first_value(row, "is_public", default=0)),
visibility_host,
)
visibility_label.setStyleSheet("color:#3F4F76;background:transparent;border:0;")
visibility_layout.addWidget(visibility_label)
visibility_layout.addStretch(1)
self.table.setCellWidget(row_index, 6, visibility_host)
# 保持宿主透明,让整行的斑马底色与选中底色透过操作列。
actions_host = QWidget(self.table.viewport()) actions_host = QWidget(self.table.viewport())
_style_row_host(actions_host, row_index)
actions = QHBoxLayout(actions_host) actions = QHBoxLayout(actions_host)
actions.setContentsMargins(6, 0, 6, 0) actions.setContentsMargins(6, 0, 6, 0)
actions.setSpacing(5) actions.setSpacing(5)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
"""Scoped surfaces for the approved patient order-management workspace."""
from string import Template
from .reception_style import body_family
def patient_orders_stylesheet() -> str:
return Template(_ORDERS).substitute(body=body_family())
_ORDERS = """
#PatientOrdersWorkspace { background: transparent; color: #273244; }
#OrderWorkspaceContent, #OrderWorkspaceScroll { background: transparent; border: 0; }
#PatientOrdersWorkspace QLabel, #PatientOrdersWorkspace QPushButton,
#PatientOrdersWorkspace QToolButton, #PatientOrdersWorkspace QLineEdit,
#PatientOrdersWorkspace QComboBox, #PatientOrdersWorkspace QDateEdit,
#PatientOrdersWorkspace QCheckBox, #PatientOrdersWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientOrdersWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientOrdersWorkspace QFrame#OrderFilterCard,
#PatientOrdersWorkspace QFrame#OrderSummaryStrip,
#PatientOrdersWorkspace QFrame#OrderTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientOrdersWorkspace QPushButton, #PatientOrdersWorkspace QToolButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QPushButton:hover, #PatientOrdersWorkspace QToolButton:hover {
background: #F2F7FF; border-color: #B6CDEE; color: #1555B6;
}
#PatientOrdersWorkspace QPushButton:pressed, #PatientOrdersWorkspace QToolButton:pressed { background: #DCEAFF; }
#PatientOrdersWorkspace QPushButton:focus, #PatientOrdersWorkspace QToolButton:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QPushButton#OrderSearchButton,
#PatientOrdersWorkspace QPushButton#OrderDetailButton {
color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientOrdersWorkspace QPushButton#OrderSearchButton:hover,
#PatientOrdersWorkspace QPushButton#OrderDetailButton:hover { background: #155BCC; border-color: #155BCC; }
#PatientOrdersWorkspace QPushButton:disabled, #PatientOrdersWorkspace QToolButton:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientOrdersWorkspace QLineEdit, #PatientOrdersWorkspace QComboBox,
#PatientOrdersWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 10px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PatientOrdersWorkspace QLineEdit:focus, #PatientOrdersWorkspace QComboBox:focus,
#PatientOrdersWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientOrdersWorkspace QLineEdit QToolButton {
min-height: 0; max-height: 24px; min-width: 0; border: 0; padding: 0; background: transparent;
}
#PatientOrdersWorkspace QComboBox { padding-right: 30px; }
#PatientOrdersWorkspace QComboBox::drop-down { border: 0; width: 28px; background: transparent; }
#PatientOrdersWorkspace QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PatientOrdersWorkspace QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PatientOrdersWorkspace QDateEdit { padding-right: 26px; }
#PatientOrdersWorkspace QDateEdit::drop-down { border: 0; width: 24px; background: transparent; }
#PatientOrdersWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QDateEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PatientOrdersWorkspace QCalendarWidget {
font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PatientOrdersWorkspace QCalendarWidget QToolButton {
min-height: 28px; max-height: 28px; padding: 0 8px; border: 0; background: transparent;
font-family: "$body"; font-size: 13px; color: #273244;
}
#PatientOrdersWorkspace QCalendarWidget QToolButton:hover { background: #EAF2FF; }
#PatientOrdersWorkspace QCalendarWidget QAbstractItemView {
font-family: "$body"; font-size: 13px; background: #FFFFFF; alternate-background-color: #FFFFFF;
color: #273244; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0;
}
#PatientOrdersWorkspace QCheckBox { spacing: 9px; font-size: 13px; }
#PatientOrdersWorkspace QCheckBox::indicator {
width: 15px; height: 15px; border: 1px solid #C5D5EB; border-radius: 3px; background: #FFFFFF;
}
#PatientOrdersWorkspace QCheckBox::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientOrdersWorkspace QCheckBox::indicator:hover { border-color: #75A5F0; }
#PatientOrdersWorkspace QCheckBox:focus { color: #1555B6; }
#PatientOrdersWorkspace QFrame#OrderMetricCell { background: transparent; border: 0; }
#PatientOrdersWorkspace QFrame#OrderMetricDivider { border: 0; background: #DBE5F2; }
#PatientOrdersWorkspace QLabel[orderMetricCaption="true"] { color: #5D6B80; font-size: 13px; }
#PatientOrdersWorkspace QLabel[orderMetricValue="true"] { color: #273244; font-size: 18px; font-weight: 500; }
#PatientOrdersWorkspace QLabel#OrderAmountMetric { color: #1769E8; }
#PatientOrdersWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientOrdersWorkspace QTableWidget#OrderTable {
border: 1px solid #E6EDF6; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F5F8FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientOrdersWorkspace QTableWidget#OrderTable::item { padding: 0; border: 0; }
#PatientOrdersWorkspace QTableWidget#OrderTable QHeaderView::section {
min-height: 44px; padding: 0 16px; background: #F8FAFD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientOrdersWorkspace QWidget#OrderActionBar { background: #FFFFFF; }
#PatientOrdersWorkspace QToolButton#OrderActionButton { padding-right: 28px; }
#PatientOrdersWorkspace QToolButton#OrderActionButton::menu-indicator { image: none; width: 0; height: 0; }
#PatientOrdersWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientOrdersWorkspace QMenu, QMenu#OrderActionMenu {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; padding: 5px;
}
#PatientOrdersWorkspace QMenu::item, QMenu#OrderActionMenu::item { padding: 8px 24px; }
#PatientOrdersWorkspace QMenu::item:selected, QMenu#OrderActionMenu::item:selected { color: #1555B6; background: #EAF2FF; }
#PatientOrdersWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientOrdersWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientOrdersWorkspace QScrollBar::add-line, #PatientOrdersWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientOrdersWorkspace QScrollBar::add-page, #PatientOrdersWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,54 @@
"""Local typography and surfaces for the approved consultation-progress page."""
from string import Template
from .reception_style import body_family
def patient_progress_stylesheet() -> str:
return Template(_PROGRESS).substitute(body=body_family())
_PROGRESS = """
#PatientProgressWorkspace { background: transparent; color: #273244; }
#ProgressWorkspaceContent, #ProgressWorkspaceScroll { background: transparent; border: 0; }
#PatientProgressWorkspace QLabel, #PatientProgressWorkspace QPushButton,
#PatientProgressWorkspace QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientProgressWorkspace QLabel[role="muted"] { font-size: 13px; color: #5D6B80; }
#PatientProgressWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; }
#PatientProgressWorkspace QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PatientProgressWorkspace QFrame#ProgressOverviewCard,
#PatientProgressWorkspace QFrame#ProgressScheduleCard,
#PatientProgressWorkspace QFrame#ProgressQueueCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientProgressWorkspace QFrame#ProgressMetricCell { background: transparent; border: 0; }
#PatientProgressWorkspace QFrame#ProgressMetricDivider { background: #DBE5F2; border: 0; }
#PatientProgressWorkspace QLabel[progressMetricValue="true"] { font-size: 18px; font-weight: 500; color: #273244; }
#PatientProgressWorkspace QLabel#ProgressTotalMetric { color: #1769E8; }
#PatientProgressWorkspace QSplitter#ProgressSplitter { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle { background: transparent; }
#PatientProgressWorkspace QSplitter#ProgressSplitter::handle:hover { background: #EAF2FF; }
#PatientProgressWorkspace QTableWidget#ProgressScheduleTable,
#PatientProgressWorkspace QTableWidget#ProgressQueueTable {
border: 0; border-radius: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientProgressWorkspace QTableWidget::item { border: 0; padding: 0; }
#PatientProgressWorkspace QHeaderView::section {
min-height: 0; padding: 0 16px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientProgressWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientProgressWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientProgressWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientProgressWorkspace QScrollBar::add-line, #PatientProgressWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientProgressWorkspace QScrollBar::add-page, #PatientProgressWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,141 @@
"""Scoped technology-blue surfaces for the approved patient-list workspace."""
from string import Template
from .reception_style import body_family, heading_family
def patients_chrome_stylesheet() -> str:
return Template(_CHROME).substitute(body=body_family(), heading=heading_family())
def patient_list_stylesheet() -> str:
return Template(_LIST).substitute(body=body_family())
_CHROME = """
#PatientsPage { background: #F3F7FD; }
#PatientsPage QWidget#PageHeader QLabel {
font-family: "$body"; font-size: 13px; font-weight: 400; color: #5D6B80;
}
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PatientsPage QPushButton#PatientRefreshButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-height: 38px; max-height: 38px; padding: 0 20px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientsPage QPushButton#PatientRefreshButton:hover { background: #F2F7FF; border-color: #B6CDEE; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane { border: 0; background: transparent; top: 0; }
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
min-width: 84px; min-height: 42px; padding: 0 10px; margin-right: 22px;
border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:selected {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs > QTabBar::tab:hover { color: #1769E8; background: #EAF2FF; }
"""
_LIST = """
#PatientListWorkspace { background: transparent; color: #273244; }
#PatientWorkspaceContent, #PatientWorkspaceScroll { background: transparent; border: 0; }
#PatientListWorkspace QLabel, #PatientListWorkspace QPushButton,
#PatientListWorkspace QToolButton, #PatientListWorkspace QDateEdit,
#PatientListWorkspace QTableWidget, #PatientSearchToolbar QLineEdit,
#PatientSearchToolbar QPushButton {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PatientListWorkspace QLabel[role="muted"], #PatientListWorkspace QLabel[filterLabel="true"] {
color: #5D6B80; font-size: 13px;
}
#PatientListWorkspace QFrame#PatientFilterCard,
#PatientListWorkspace QFrame#PatientSummaryStrip,
#PatientListWorkspace QFrame#PatientListCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PatientListWorkspace QPushButton, #PatientSearchToolbar QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px;
border: 1px solid #DBE5F2; border-radius: 5px; background: #FFFFFF;
}
#PatientListWorkspace QPushButton:hover, #PatientSearchToolbar QPushButton:hover {
color: #1555B6; background: #F2F7FF; border-color: #B6CDEE;
}
#PatientListWorkspace QPushButton:pressed, #PatientSearchToolbar QPushButton:pressed { background: #DCEAFF; }
#PatientListWorkspace QPushButton:focus, #PatientSearchToolbar QPushButton:focus { border-color: #75A5F0; }
#PatientSearchToolbar QPushButton#PatientSearchButton {
min-height: 38px; max-height: 38px; color: #FFFFFF; background: #1769E8; border-color: #1769E8;
}
#PatientSearchToolbar QPushButton#PatientSearchButton:hover { background: #155BCC; }
#PatientSearchToolbar QLineEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; background: #FFFFFF;
border: 1px solid #DBE5F2; border-radius: 5px; selection-background-color: #DCEAFF;
}
#PatientSearchToolbar QLineEdit:focus { border-color: #75A5F0; }
#PatientSearchToolbar QLineEdit QToolButton { border: 0; padding: 0; background: transparent; }
#PatientListWorkspace QPushButton[patientStatusChip="true"] {
min-height: 44px; max-height: 44px; padding: 0 16px; font-size: 13px;
color: #273244; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8; background: transparent;
}
#PatientListWorkspace QPushButton[patientStatusChip="true"]:hover { color: #1769E8; background: #F2F7FF; }
#PatientListWorkspace QPushButton[patientQuickDate="true"] { padding: 0 12px; font-size: 13px; }
#PatientListWorkspace QPushButton[patientQuickDate="true"]:checked,
#PatientListWorkspace QPushButton#PatientCustomDateButton:checked {
color: #FFFFFF; background: #1769E8; border: 1px solid #1769E8;
}
#PatientListWorkspace QDateEdit {
min-height: 38px; max-height: 38px; padding: 0 8px; font-size: 13px;
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 5px;
selection-background-color: #DCEAFF;
}
#PatientListWorkspace QDateEdit:focus { border-color: #75A5F0; }
#PatientListWorkspace QDateEdit:disabled {
color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2;
}
#PatientListWorkspace QDateEdit::drop-down { border: 0; width: 20px; background: transparent; }
#PatientListWorkspace QDateEdit::down-arrow { image: none; width: 0; height: 0; }
#PatientListWorkspace QPushButton[summaryCard="true"] {
min-height: 64px; max-height: 64px; padding: 0; background: transparent; border: 0; border-radius: 5px;
}
#PatientListWorkspace QPushButton[summaryCard="true"]:hover { background: #F2F7FF; }
#PatientListWorkspace QFrame#PatientSummaryDivider { border: 0; background: #E1E9F4; }
#PatientListWorkspace QWidget#PatientListHeading { background: transparent; }
#PatientListWorkspace QLabel[role="sectionTitle"] { font-size: 14px; font-weight: 600; color: #273244; }
#PatientListWorkspace QTableWidget#PatientTable {
background: #FFFFFF; alternate-background-color: #FFFFFF; border: 0; border-radius: 0;
selection-background-color: #EAF2FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PatientListWorkspace QTableWidget#PatientTable::item { padding: 0; border: 0; }
#PatientListWorkspace QTableWidget#PatientTable QHeaderView::section {
min-height: 41px; padding: 0 10px; background: #F5F8FD; color: #5D6B80;
border: 0; border-top: 1px solid #E6EDF6; border-bottom: 1px solid #E1E9F4;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PatientListWorkspace QWidget#RowActions, #PatientListWorkspace QWidget#PatientSelectorHost { background: transparent; }
#PatientListWorkspace QPushButton[rowAction="true"],
#PatientListWorkspace QToolButton#RowActionsMore {
min-height: 28px; max-height: 28px; padding: 0 5px; border: 0;
border-radius: 4px; background: transparent; color: #1769E8; font-size: 13px;
}
#PatientListWorkspace QToolButton#RowActionsMore { color: #273244; padding-right: 16px; }
#PatientListWorkspace QPushButton[rowAction="true"]:hover,
#PatientListWorkspace QToolButton#RowActionsMore:hover { background: #DCEAFF; }
#PatientListWorkspace QMenu { background: #FFFFFF; color: #273244; border: 1px solid #DBE5F2; padding: 4px; }
#PatientListWorkspace QMenu::item { padding: 7px 22px; font-size: 13px; }
#PatientListWorkspace QMenu::item:selected { background: #EAF2FF; color: #1555B6; }
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator {
width: 13px; height: 13px; background: #FFFFFF; border: 1px solid #CBDAED; border-radius: 3px;
}
#PatientListWorkspace QCheckBox[patientSelector="true"]::indicator:checked { background: #1769E8; border-color: #1769E8; }
#PatientListWorkspace QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PatientListWorkspace QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PatientListWorkspace QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PatientListWorkspace QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PatientListWorkspace QScrollBar::add-line, #PatientListWorkspace QScrollBar::sub-line { width: 0; height: 0; }
#PatientListWorkspace QScrollBar::add-page, #PatientListWorkspace QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,359 @@
"""Local technology-blue rendering for the approved prescription library."""
from __future__ import annotations
from collections.abc import Iterable
from string import Template
from typing import Any
from PySide6.QtCore import QModelIndex, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontMetrics, QIcon, QPainter
from PySide6.QtWidgets import (
QComboBox,
QPushButton,
QStyle,
QStyledItemDelegate,
QToolTip,
QWidget,
)
from .reception_style import body_family, heading_family
from .widgets import SortableTable, first_value
def library_stylesheet() -> str:
return Template(_LIBRARY).substitute(body=body_family(), heading=heading_family())
def _library_icon(kind: str, color: str = "#1769E8", size: int = 15) -> QIcon:
# Defer the page helper lookup so importing this module alone does not
# recurse through pages.__init__ and the library page's own style import.
from .pages.prescriptions import _painted_icon
result = QIcon()
for mode, tint in (
(QIcon.Mode.Normal, color),
(QIcon.Mode.Active, color),
(QIcon.Mode.Selected, color),
(QIcon.Mode.Disabled, "#A4ADBA"),
):
pixmap = _painted_icon(kind, tint, size).pixmap(QSize(size, size))
for state in (QIcon.State.Off, QIcon.State.On):
result.addPixmap(pixmap, mode, state)
return result
class LibraryComboBox(QComboBox):
"""Keep native combo interaction while painting the local caret."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().paintEvent(event)
painter = QPainter(self)
try:
rect = QRectF(self.width() - 25, (self.height() - 14) / 2, 14, 14)
mode = QIcon.Mode.Normal if self.isEnabled() else QIcon.Mode.Disabled
_library_icon("chevron_down", "#5D6B80", 14).paint(painter, rect.toRect(), mode=mode)
finally:
painter.end()
class LibraryTable(SortableTable):
"""Repaint existing action widgets and restore selection by template ID."""
def set_rows(self, rows: Iterable[Any]) -> None:
selected_id = first_value(self.current_data(), "id", "template_id", default=None)
super().set_rows(rows)
# The shared table restores an unsorted row index after enabling sort.
# Locate the same source object in the finished visual order instead.
if selected_id is not None:
for row_index in range(self.rowCount()):
item = self.item(row_index, 0)
row = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
row_id = first_value(row, "id", "template_id", default=None)
if row_id is not None and str(row_id) == str(selected_id):
self.selectRow(row_index)
return
self.clearSelection()
self.setCurrentCell(-1, -1)
def setCellWidget(self, row: int, column: int, widget: QWidget | None) -> None: # noqa: N802
super().setCellWidget(row, column, widget)
if widget is None or column != 9:
return
widget.setObjectName("PrescriptionLibraryTableCellHost")
widget.setAutoFillBackground(False)
glyphs = {
"查看处方模板": "eye",
"AI 解释": "spark",
"编辑处方模板": "pencil",
"删除处方模板": "trash",
}
for button in widget.findChildren(QPushButton):
if not button.property("rowAction"):
continue
glyph = glyphs.get(button.accessibleName()) or glyphs.get(button.toolTip())
if glyph is not None:
color = "#BE4657" if button.property("danger") else "#1769E8"
button.setIcon(_library_icon(glyph, color, button.iconSize().width()))
def _library_font(size: int = 14, *, medium: bool = False) -> QFont:
font = QFont(heading_family() if medium else body_family())
font.setPixelSize(size)
font.setWeight(QFont.Weight.Medium if medium else QFont.Weight.Normal)
return font
def _elide(text: str, metrics: QFontMetrics, width: int) -> str:
return metrics.elidedText(text, Qt.TextElideMode.ElideRight, max(1, width))
def _herb_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
"""Wrap between complete herb entries; elide only the displayed lines."""
entries = text.split("")
first = entries[0]
next_index = 1
while next_index < min(2, len(entries)):
candidate = first + "" + entries[next_index]
if metrics.horizontalAdvance(candidate) > width:
break
first = candidate
next_index += 1
lines = [_elide(first, metrics, width)]
if next_index < len(entries):
lines.append(_elide("".join(entries[next_index:]), metrics, width))
return lines
def _name_lines(text: str, metrics: QFontMetrics, width: int) -> list[str]:
if metrics.horizontalAdvance(text) <= width:
return [text]
split = 1
while split < len(text) and metrics.horizontalAdvance(text[: split + 1]) <= width:
split += 1
return [_elide(text[:split], metrics, width), _elide(text[split:], metrics, width)]
class LibraryItemDelegate(QStyledItemDelegate):
"""Paint the ten existing columns without changing display or source roles."""
def sizeHint(self, option: Any, index: QModelIndex) -> QSize: # noqa: N802
size = super().sizeHint(option, index)
size.setHeight(78)
return size
def helpEvent(self, event: Any, view: Any, option: Any, index: QModelIndex) -> bool: # noqa: N802
if event is not None and index.isValid() and index.column() != 9:
text = index.data(Qt.ItemDataRole.ToolTipRole) or index.data(Qt.ItemDataRole.DisplayRole)
if text:
QToolTip.showText(event.globalPos(), str(text), view)
return True
return super().helpEvent(event, view, option, index)
@staticmethod
def _paint_lines(
painter: QPainter,
rect: QRectF,
lines: list[str],
*,
secondary_muted: bool = False,
) -> None:
line_height = 26
top = rect.center().y() - len(lines) * line_height / 2
for line_index, line in enumerate(lines):
if secondary_muted and line_index:
painter.setFont(_library_font(13))
painter.setPen(QColor("#5D6B80"))
line_rect = QRectF(rect.left(), top + line_index * line_height, rect.width(), line_height)
painter.drawText(line_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, line)
def paint(self, painter: QPainter, option: Any, index: QModelIndex) -> None:
painter.save()
try:
painter.setClipRect(option.rect)
selected = bool(option.state & QStyle.StateFlag.State_Selected)
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
background = "#F2F7FF" if selected else "#F8FAFE" if hovered else "#FFFFFF"
painter.fillRect(option.rect, QColor(background))
painter.setPen(QColor("#E6EDF6"))
painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight())
if selected and index.column() == 0:
stripe = QRectF(option.rect.left(), option.rect.top(), 3, option.rect.height() - 1)
painter.fillRect(stripe, QColor("#1769E8"))
if option.state & QStyle.StateFlag.State_HasFocus:
painter.setPen(QColor("#75A5F0"))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(option.rect.adjusted(1, 1, -2, -2))
column = index.column()
if column == 9:
return
rect = QRectF(option.rect.adjusted(12, 0, -12, -1))
if rect.width() <= 0:
return
value = index.data(Qt.ItemDataRole.DisplayRole)
text = "" if value is None else str(value)
font = _library_font(medium=column == 1)
painter.setFont(font)
painter.setPen(QColor("#273244"))
metrics = QFontMetrics(font)
width = int(rect.width())
if column == 2:
painter.setFont(_library_font(13))
metrics = painter.fontMetrics()
label = _elide(text, metrics, width - 12)
pill_width = min(rect.width(), metrics.horizontalAdvance(label) + 14)
pill = QRectF(rect.center().x() - pill_width / 2, rect.center().y() - 12, pill_width, 24)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#EAF2FF"))
painter.drawRoundedRect(pill, 4, 4)
painter.setPen(QColor("#1769E8"))
painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, label)
elif column == 6:
glyph = "users" if text == "所有人可见" else "lock"
icon_rect = QRectF(rect.left(), rect.center().y() - 7, 14, 14)
_library_icon(glyph, "#5D6B80", 14).paint(painter, icon_rect.toRect())
label_rect = rect.adjusted(22, 0, 0, 0)
painter.drawText(
label_rect,
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
_elide(text, metrics, int(label_rect.width())),
)
elif column == 4:
self._paint_lines(painter, rect, _herb_lines(text, metrics, width))
elif column == 8:
parts = text.splitlines() if "\n" in text else text.rsplit(" ", 1)
lines = [_elide(parts[0], metrics, width)]
if len(parts) > 1:
lines.append(_elide(" ".join(parts[1:]), QFontMetrics(_library_font(13)), width))
self._paint_lines(painter, rect, lines, secondary_muted=True)
elif column == 1:
self._paint_lines(painter, rect, _name_lines(text, metrics, width))
else:
alignment = Qt.AlignmentFlag.AlignCenter if column in (0, 3, 5) else (
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
painter.drawText(rect, alignment, _elide(text, metrics, width))
finally:
painter.restore()
_LIBRARY = """
#PrescriptionLibraryPage { background: #F3F7FD; color: #273244; }
#PrescriptionLibraryPage QWidget#PrescriptionLibraryContent,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryScroll,
#PrescriptionLibraryPage QScrollArea#PrescriptionLibraryToolbarScroll {
background: transparent; border: 0;
}
#PrescriptionLibraryPage QLabel, #PrescriptionLibraryPage QPushButton,
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox,
#PrescriptionLibraryPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionLibraryPage QLabel[role="muted"] { color: #5D6B80; font-size: 13px; }
#PrescriptionLibraryPage QFrame#MetricCard,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
font-family: "$body"; color: #5D6B80; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
font-family: "$heading"; color: #273244; font-size: 18px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[metricIcon="true"] {
border: 0; border-radius: 0; background: transparent;
}
#PrescriptionLibraryPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionLibraryPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionLibraryPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionLibraryPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton {
background: #1769E8; border-color: #1769E8; color: #FFFFFF;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:hover,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryAddButton:pressed,
#PrescriptionLibraryPage QPushButton#PrescriptionLibraryQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionLibraryPage QPushButton[variant="danger"] { color: #BE4657; border-color: #E9D8DE; }
#PrescriptionLibraryPage QPushButton:disabled,
#PrescriptionLibraryPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionLibraryPage QLineEdit, #PrescriptionLibraryPage QComboBox {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionLibraryPage QLineEdit:focus, #PrescriptionLibraryPage QComboBox:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QLineEdit QToolButton { border: 0; background: transparent; padding: 0; }
#PrescriptionLibraryPage QComboBox { padding-right: 30px; font-size: 13px; }
#PrescriptionLibraryPage QComboBox::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionLibraryPage QComboBox::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionLibraryPage QComboBox:disabled { color: #97A4B6; background: #F6F8FC; }
#PrescriptionLibraryPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionLibraryPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar { background: transparent; border: 0; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 80px; min-height: 40px; max-height: 40px; padding: 0 10px; margin-right: 8px;
background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; color: #273244; font-size: 14px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:checked {
color: #1769E8; border-bottom-color: #1769E8;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"]:hover {
color: #1555B6; background: #F2F7FF;
}
#PrescriptionLibraryPage QTableWidget {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionLibraryPage QTableWidget::item { padding: 0; border: 0; }
#PrescriptionLibraryPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionLibraryPage QWidget#PrescriptionLibraryTableCellHost { background: transparent; border: 0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionLibraryPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionLibraryPage QPushButton[rowAction="true"]:disabled { background: transparent; border-color: transparent; color: #A4ADBA; }
#PrescriptionLibraryPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionLibraryPage QLabel#EmptyStateGlyph {
font-size: 24px; color: #1769E8; background: #F2F7FF;
border: 1px solid #DBE5F2; border-radius: 22px;
}
#PrescriptionLibraryPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionLibraryPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionLibraryPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionLibraryPage QScrollBar::add-line, #PrescriptionLibraryPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionLibraryPage QScrollBar::add-page, #PrescriptionLibraryPage QScrollBar::sub-page { background: transparent; }
"""
__all__ = ["library_stylesheet", "LibraryComboBox", "LibraryTable", "LibraryItemDelegate"]
@@ -0,0 +1,107 @@
"""Scoped surfaces and real fonts for the approved issued-prescription page."""
from string import Template
from .reception_style import body_family, heading_family
def prescriptions_stylesheet() -> str:
return Template(_PRESCRIPTIONS).substitute(body=body_family(), heading=heading_family())
_PRESCRIPTIONS = """
#PrescriptionsPage { background: #F3F7FD; color: #273244; }
#PrescriptionWorkspaceContent, #PrescriptionWorkspaceScroll { background: transparent; border: 0; }
#PrescriptionsPage QLabel, #PrescriptionsPage QPushButton, #PrescriptionsPage QLineEdit,
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit, #PrescriptionsPage QTableWidget {
font-family: "$body"; font-size: 14px; font-weight: 400; color: #273244;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
font-family: "$heading"; font-size: 20px; font-weight: 600; color: #202C3F;
}
#PrescriptionsPage QLabel[role="sectionTitle"] {
font-family: "$heading"; font-size: 14px; font-weight: 600;
}
#PrescriptionsPage QLabel[role="muted"], #PrescriptionsPage QLabel#PrescriptionCountBadge {
color: #5D6B80; font-size: 13px; background: transparent; border: 0; padding: 0;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar, #PrescriptionsPage QFrame#PrescriptionTableCard {
background: #FFFFFF; border: 1px solid #DBE5F2; border-radius: 8px;
}
#PrescriptionsPage QFrame#PrescriptionToolbar { background: transparent; border: 0; }
#PrescriptionsPage QPushButton {
min-height: 38px; max-height: 38px; padding: 0 16px; border-radius: 5px;
border: 1px solid #DBE5F2; background: #FFFFFF; color: #273244;
}
#PrescriptionsPage QPushButton:hover { background: #F2F7FF; border-color: #B6CDEE; color: #1555B6; }
#PrescriptionsPage QPushButton:pressed { background: #DCEAFF; }
#PrescriptionsPage QPushButton:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton#PrescriptionAddButton, #PrescriptionsPage QPushButton#PrescriptionQueryButton {
background: #1769E8; color: #FFFFFF; border-color: #1769E8;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:hover, #PrescriptionsPage QPushButton#PrescriptionQueryButton:hover {
background: #155BCC; border-color: #155BCC;
}
#PrescriptionsPage QPushButton#PrescriptionAddButton:pressed, #PrescriptionsPage QPushButton#PrescriptionQueryButton:pressed {
background: #124EA9; border-color: #124EA9;
}
#PrescriptionsPage QPushButton[variant="danger"] { color: #BE4657; background: #FFFFFF; border-color: #E9D8DE; }
#PrescriptionsPage QPushButton:disabled, #PrescriptionsPage QPushButton[variant="danger"]:disabled {
color: #97A4B6; background: #F6F8FC; border-color: #E2E9F2;
}
#PrescriptionsPage QLineEdit, #PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit {
min-height: 38px; max-height: 38px; padding: 0 12px; border: 1px solid #DBE5F2;
border-radius: 5px; background: #FFFFFF; color: #273244;
selection-background-color: #DCEAFF; selection-color: #273244;
}
#PrescriptionsPage QLineEdit:focus, #PrescriptionsPage QComboBox:focus, #PrescriptionsPage QDateTimeEdit:focus { border-color: #75A5F0; }
#PrescriptionsPage QLineEdit QToolButton { min-width: 0; min-height: 0; border: 0; background: transparent; padding: 0; }
#PrescriptionsPage QComboBox, #PrescriptionsPage QDateTimeEdit { padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox::drop-down, #PrescriptionsPage QDateTimeEdit::drop-down { width: 28px; border: 0; background: transparent; }
#PrescriptionsPage QComboBox::down-arrow, #PrescriptionsPage QDateTimeEdit::down-arrow { image: none; width: 0; height: 0; }
#PrescriptionsPage QDateTimeEdit:disabled { color: #8A97A9; background: #F6F8FC; border-color: #E2E9F2; }
#PrescriptionsPage QPushButton#PrescriptionDoctorButton { text-align: left; padding-right: 30px; font-size: 13px; }
#PrescriptionsPage QComboBox QAbstractItemView {
font-family: "$body"; font-size: 13px; color: #273244; background: #FFFFFF;
border: 1px solid #DBE5F2; outline: 0; selection-background-color: #EAF2FF; selection-color: #1555B6;
}
#PrescriptionsPage QComboBox QAbstractItemView::item { min-height: 30px; padding: 3px 10px; }
#PrescriptionsPage QCalendarWidget { font-family: "$body"; font-size: 13px; background: #FFFFFF; color: #273244; }
#PrescriptionsPage QCalendarWidget QWidget#qt_calendar_navigationbar { background: #F5F8FD; }
#PrescriptionsPage QCalendarWidget QToolButton { min-height: 28px; border: 0; padding: 0 8px; color: #273244; background: transparent; }
#PrescriptionsPage QCalendarWidget QAbstractItemView { color: #273244; background: #FFFFFF; selection-background-color: #1769E8; selection-color: #FFFFFF; outline: 0; }
#PrescriptionsPage QFrame#PrescriptionToolbar QPushButton { padding: 0 12px; font-size: 13px; }
#PrescriptionsPage QTableWidget#PrescriptionTable {
border: 0; background: #FFFFFF; alternate-background-color: #FFFFFF;
selection-background-color: #F2F7FF; selection-color: #273244; gridline-color: #E6EDF6;
}
#PrescriptionsPage QTableWidget#PrescriptionTable::item { border: 0; padding: 0; }
#PrescriptionsPage QHeaderView::section {
min-height: 0; padding: 0 12px; background: #F5F8FD; color: #5D6B80;
border: 0; border-bottom: 1px solid #DBE5F2;
font-family: "$body"; font-size: 13px; font-weight: 400;
}
#PrescriptionsPage QWidget#PrescriptionTableCellHost { border: 0; background: transparent; }
#PrescriptionsPage QPushButton[rowAction="true"] {
min-width: 28px; max-width: 28px; min-height: 28px; max-height: 28px;
padding: 0; border: 1px solid transparent; border-radius: 4px; background: transparent;
}
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
min-width: 64px; max-width: 64px; color: #1769E8; font-size: 13px; padding: 0;
}
#PrescriptionsPage QPushButton[rowAction="true"]:hover { background: #EAF2FF; border-color: #B6CDEE; }
#PrescriptionsPage QPushButton[rowAction="true"]:focus { border-color: #75A5F0; }
#PrescriptionsPage QPushButton[rowAction="true"][danger="true"]:hover { background: #FFF0F2; border-color: #EAC8D0; }
#PrescriptionsPage QPushButton[rowAction="true"]:disabled,
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"]:disabled {
background: transparent; border-color: transparent; color: #A4ADBA;
}
#PrescriptionsPage QWidget#InfiniteList { background: #FFFFFF; border-top: 1px solid #E6EDF6; }
#PrescriptionsPage QLabel#EmptyStateGlyph { font-size: 24px; color: #1769E8; background: #F2F7FF; border: 1px solid #DBE5F2; border-radius: 22px; }
#PrescriptionsPage QWidget#EmptyState QLabel[role="muted"] { min-width: 300px; }
#PrescriptionsPage QScrollBar:vertical { background: #F4F7FC; width: 7px; margin: 0; }
#PrescriptionsPage QScrollBar:horizontal { background: #F4F7FC; height: 7px; margin: 0; }
#PrescriptionsPage QScrollBar::handle { background: #C8D5E6; border-radius: 3px; min-width: 24px; min-height: 24px; }
#PrescriptionsPage QScrollBar::add-line, #PrescriptionsPage QScrollBar::sub-line { width: 0; height: 0; }
#PrescriptionsPage QScrollBar::add-page, #PrescriptionsPage QScrollBar::sub-page { background: transparent; }
"""
@@ -0,0 +1,106 @@
"""Palette and real font families for the approved reception page only."""
from __future__ import annotations
import os
import sys
from pathlib import Path
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.resources import resource_path
TECH_BLUE = {
"accent": "#1769E8",
"accent_hover": "#155BCC",
"accent_pressed": "#124EA9",
"selection": "#EAF2FF",
"selected_text": "#1555B6",
"canvas": "#F3F7FD",
"sidebar": "#EDF4FF",
"surface": "#FFFFFF",
"raised": "#F7FAFE",
"line": "#DBE5F2",
"text": "#273244",
"heading": "#202C3F",
"muted": "#5D6B80",
"focus": "#75A5F0",
}
def _families() -> dict[str, str]:
"""Resolve after Qt starts; font registration does not change its theme."""
app = QApplication.instance()
if app is None:
raise RuntimeError("Reception font families require a QApplication")
cached = getattr(app, "_reception_font_families", None)
if cached is not None:
return cached
heading = getattr(app, "_doctor_bundled_font_family", None)
if not heading:
font_path = resource_path("fonts", "NotoSansSC-VF.ttf")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
registered = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
if registered:
heading = registered[0]
app._doctor_bundled_font_family = heading
available = set(QFontDatabase.families())
# Qt's offscreen platform does not enumerate Windows fonts automatically.
# Register only files already installed on this machine, never substitutes
# downloaded or installed into the user's Windows font registry.
if sys.platform == "win32":
font_dir = (
Path(os.environ.get("SYSTEMROOT") or os.environ.get("WINDIR") or "C:/Windows") / "Fonts"
)
for family, filenames in (
("Microsoft YaHei UI", ("msyh.ttc",)),
("Segoe UI", ("segoeui.ttf", "seguisb.ttf")),
):
if family in available:
continue
for filename in filenames:
font_path = font_dir / filename
if not font_path.is_file():
continue
font_id = QFontDatabase.addApplicationFont(str(font_path))
if font_id >= 0:
available.update(QFontDatabase.applicationFontFamilies(font_id))
fallback = heading or next(
(
family
for family in ("Noto Sans SC", "Noto Sans CJK SC", "PingFang SC")
if family in available
),
app.font().family(),
)
resolved = {
"body": "Microsoft YaHei UI" if "Microsoft YaHei UI" in available else fallback,
"heading": heading or fallback,
"number": "Segoe UI" if "Segoe UI" in available else fallback,
}
app._reception_font_families = resolved
return resolved
def body_family() -> str:
"""Regular Chinese body copy; YaHei UI has no genuine Medium face."""
return _families()["body"]
def heading_family() -> str:
"""Bundled Noto Sans SC supplies genuine Medium and Semibold faces."""
return _families()["heading"]
def number_family() -> str:
"""Segoe UI supplies Regular and Semibold for numeric labels."""
return _families()["number"]
File diff suppressed because it is too large Load Diff
+363 -139
View File
@@ -1,13 +1,4 @@
"""Application-wide visual system for the AI consultation workstation. """Shared typography and neutral reading surfaces for the clinical workspace."""
The palette and density follow the supplied product references: a quiet blue
canvas, crisp white data surfaces, luminous indigo actions and compact tables.
Every size in the interface comes from the token tables below. Before they
existed the UI had grown 19 distinct font sizes and 8 control heights, which is
what made neighbouring controls look subtly mismatched; keep new work on the
scale instead of introducing another one-off pixel value.
"""
from __future__ import annotations from __future__ import annotations
@@ -17,12 +8,15 @@ from pathlib import Path
from string import Template from string import Template
from typing import Any from typing import Any
from PySide6.QtCore import QEvent, QObject, Qt from PySide6.QtCore import QEvent, QObject, QPointF, Qt
from PySide6.QtGui import ( from PySide6.QtGui import (
QColor, QColor,
QFont, QFont,
QFontDatabase, QFontDatabase,
QPainter,
QPainterPath,
QPalette, QPalette,
QPen,
QPixmap, QPixmap,
QTextBlockFormat, QTextBlockFormat,
QTextCharFormat, QTextCharFormat,
@@ -35,50 +29,57 @@ from PySide6.QtWidgets import (
QFileDialog, QFileDialog,
QInputDialog, QInputDialog,
QMessageBox, QMessageBox,
QPlainTextEdit,
QScrollArea,
QTextBrowser, QTextBrowser,
QTextEdit,
) )
# Canonical semantic tokens. The legacy teal/ink aliases remain available to from doctor_workstation.resources import resource_path
# callers while the stylesheet itself is generated from this mapping.
# ``motion`` deliberately imports nothing from this module, so this stays a
# one-way dependency: tokens here, animation there.
from . import motion
# Neutral reading colors follow the installed Codex light chrome defaults.
# Brand accents are independent of reading ink; use opaque text for stable contrast.
# The legacy teal/ink aliases remain for existing callers.
COLORS = { COLORS = {
# Sampled from the supplied 1710 x 920 product comps. The window edge is "canvas": "#F4F6FA",
# the only blue-tinted surface; the application workspace itself is an "canvas_mid": "#FFFFFF",
# almost-white #FCFDFE field. "canvas_glow": "#EEF1FA",
"canvas": "#EEF3FD",
"canvas_mid": "#F7F9FE",
"canvas_glow": "#E5ECFD",
"surface": "#FFFFFF", "surface": "#FFFFFF",
"surface_alt": "#FAFBFE", "surface_alt": "#F7F7F7",
"raised": "#F5F7FC", "raised": "#F7F7F7",
"glass": "rgba(255, 255, 255, 252)", "glass": "#FFFFFF",
"glass_alt": "rgba(248, 250, 255, 252)", "glass_alt": "#F7F7F7",
"line": "#E6EAF5", "line": "#EDEDEE",
"line_soft": "rgba(82, 97, 246, 40)", "line_soft": "#E4E4E5",
"text": "#111F46", "text": "#1A1C1F",
"text_soft": "#3F4E75", "text_soft": "#606163",
"muted": "#7886AA", "muted": "#6A6B6D",
"disabled_surface": "#F0F2F8", "disabled_surface": "#F2F2F2",
"disabled_text": "#A4ADC3", "disabled_text": "#8E8F90",
"indigo": "#5761F4", "indigo": "#4F63D9",
"indigo_hover": "#4C57E9", "indigo_hover": "#4156C4",
"indigo_pressed": "#4451E2", "indigo_pressed": "#354BB4",
"indigo_pale": "#F0F2FF", "indigo_pale": "#EEF1FA",
"focus": "#8D9BFF", "focus": "#8B9AD9",
"selection": "#EDF0FF", "selection": "#EEF1FA",
"success": "#17A77D", "success": "#287B65",
"success_pale": "#EAF9F3", "success_pale": "#EEF7F3",
"warning": "#D38625", "warning": "#A9691D",
"warning_pale": "#FFF5E6", "warning_pale": "#FCF5E9",
"danger": "#F15B67", "danger": "#BE4B58",
"danger_pale": "#FFF1F3", "danger_pale": "#FCF0F2",
"info": "#4D69ED", "info": "#4F63D9",
"info_pale": "#F0F4FF", "info_pale": "#EEF1FA",
# Backward-compatible names used by older UI code and integrations. # Backward-compatible names used by older UI code and integrations.
"ink": "#111F46", "ink": "#1A1C1F",
"ink_soft": "#3F4E75", "ink_soft": "#606163",
"teal": "#5761F4", "teal": "#4F63D9",
"teal_dark": "#4451E2", "teal_dark": "#354BB4",
"teal_pale": "#F0F2FF", "teal_pale": "#EEF1FA",
} }
@@ -87,32 +88,47 @@ COLORS = {
# more ink than Latin at the same pixel size, so the steps are spaced widely # more ink than Latin at the same pixel size, so the steps are spaced widely
# enough that two adjacent levels are always distinguishable. # enough that two adjacent levels are always distinguishable.
TYPE = { TYPE = {
"fs_caption": "12px", # table headers, hints, badges, timestamps "fs_caption": "13px", # table headers, hints, badges, timestamps
"fs_body": "13px", # default UI text "fs_body": "14px", # default UI text
"fs_strong": "14px", # emphasised body, dialog prompts "fs_strong": "15px", # emphasised body, dialog prompts
"fs_section": "16px", # card and section titles "fs_section": "16px", # card and section titles
"fs_title": "20px", # page titles, dialog titles "fs_title": "20px", # page titles, dialog titles
"fs_display": "26px", # metric values, empty-state glyphs "fs_display": "26px", # metric values, empty-state glyphs
} }
# The bundled variable font supplies real Regular, Medium and Semibold faces.
# Keep ordinary controls at Medium and reserve Semibold for headings.
WEIGHTS = {
"fw_body": "400",
"fw_control": "500",
"fw_heading": "600",
}
# --- Control metrics ------------------------------------------------------ # --- Control metrics ------------------------------------------------------
# Three interactive heights. ``h_default`` drops from the previous 36px: the # Controls leave room for the 14px reading size without increasing table density.
# old value made every toolbar, filter row and inline action read as heavy,
# which is the main reason the workspace felt clunky.
METRICS = { METRICS = {
"h_compact": "28px", # inline row actions, chips, links "h_compact": "28px", # inline row actions, chips, links
"h_default": "32px", # buttons, inputs, combos, tabs "h_default": "34px", # buttons, inputs, combos, tabs
"h_cta": "38px", # primary dialog actions, sidebar navigation "h_cta": "38px", # primary dialog actions, sidebar navigation
"h_bar": "52px", # dialog header / footer bars "h_bar": "52px", # dialog header / footer bars
"r_sm": "6px", # Corner radii. These existed before but the pages invented their own, so
"r_md": "8px", # the same role - a card - shipped at 10, 11, 12, 13, 14 and 16 px across
"r_lg": "12px", # six pages. Nothing in a product is a "10px card"; it is either a card or
"r_xl": "16px", # it is not, and it should round like every other card next to it.
#
# Nesting rule: an element sitting flush inside a rounded container takes
# ``outer - gap``. Where the gap is bigger than the outer radius the inner
# element is far enough from the corner that its own radius is free.
"r_xs": "4px", # chips, badges, tiny status pills
"r_sm": "6px", # inline row actions, tags
"r_md": "8px", # buttons, inputs, combo boxes
"r_lg": "12px", # cards, filter bars, panels
"r_xl": "16px", # the shell surfaces the cards sit on
"pad_control": "12px", # horizontal padding inside default controls "pad_control": "12px", # horizontal padding inside default controls
"pad_compact": "9px", "pad_compact": "9px",
} }
_QSS_TOKENS = {**COLORS, **TYPE, **METRICS} _QSS_TOKENS = {**COLORS, **TYPE, **WEIGHTS, **METRICS}
def crisp_pixmap(width: int, height: int | None = None) -> QPixmap: def crisp_pixmap(width: int, height: int | None = None) -> QPixmap:
@@ -144,8 +160,8 @@ GLOBAL_QSS = Template(
QWidget { QWidget {
color: $text; color: $text;
background-color: transparent; background-color: transparent;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: $fs_body; font-size: $fs_body;
font-weight: $fw_body;
} }
QMainWindow, QDialog, QWidget#LoginCanvas { QMainWindow, QDialog, QWidget#LoginCanvas {
@@ -170,7 +186,7 @@ QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
QDialog[businessDialog="true"] QLabel[dialogRole="title"] { QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
color: $text; color: $text;
font-size: $fs_section; font-size: $fs_section;
font-weight: 700; font-weight: $fw_control;
} }
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] { QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
color: $muted; color: $muted;
@@ -214,48 +230,43 @@ QInputDialog QLabel {
font-size: $fs_strong; font-size: $fs_strong;
} }
QWidget#AppCanvas { QWidget#AppCanvas {
background-color: qlineargradient( background-color: $canvas;
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 $canvas,
stop: 0.58 $canvas_mid,
stop: 1 $canvas_glow
);
border-radius: 18px; border-radius: 18px;
} }
QWidget#ShellWorkspace, QStackedWidget#ShellPageStack { QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
background-color: #FCFDFE; background-color: $canvas_mid;
} }
QLabel[role="muted"] { color: $muted; } QLabel[role="muted"] { color: $muted; }
QLabel[role="danger"] { color: $danger; } QLabel[role="danger"] { color: $danger; }
QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; } QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; }
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: $fs_strong; } QLabel[role="breadcrumbSeparator"] { color: #8E8F90; font-size: $fs_strong; }
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: 600; } QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: $fw_control; }
QLabel[role="eyebrow"] { QLabel[role="eyebrow"] {
color: $indigo_hover; color: $indigo_hover;
font-size: $fs_caption; font-size: $fs_caption;
font-weight: 700; font-weight: $fw_control;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
QLabel[role="pageTitle"] { QLabel[role="pageTitle"] {
color: $text; color: $text;
font-size: $fs_title; font-size: $fs_title;
font-weight: 700; font-weight: $fw_heading;
} }
QLabel[role="sectionTitle"] { QLabel[role="sectionTitle"] {
color: $text; color: $text;
font-size: $fs_section; font-size: $fs_section;
font-weight: 700; font-weight: $fw_control;
} }
QLabel[role="display"] { QLabel[role="display"] {
color: $text; color: $text;
font-size: $fs_display; font-size: $fs_display;
font-weight: 700; font-weight: $fw_heading;
} }
QLabel[role="metric"] { QLabel[role="metric"] {
color: $text; color: $text;
font-size: $fs_title; font-size: $fs_title;
font-weight: 700; font-weight: $fw_heading;
} }
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel, QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
@@ -277,19 +288,19 @@ QFrame#MetricCard {
} }
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; } QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; } QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: $fs_title; font-weight: 700; } QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: $fs_title; font-weight: $fw_heading; }
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; } QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; }
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; } QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
QFrame#ReceptionAiCard { QFrame#ReceptionAiCard {
min-height: 132px; min-height: 132px;
background-color: #F8FAFF; background-color: #FFFFFF;
border: 1px solid $line_soft; border: 1px solid $line_soft;
border-radius: 13px; border-radius: 13px;
} }
QLabel#ReceptionAiTitle { QLabel#ReceptionAiTitle {
color: $indigo_pressed; color: $indigo_pressed;
font-size: $fs_strong; font-size: $fs_strong;
font-weight: 700; font-weight: $fw_control;
} }
QFrame#ReceptionAiCard QPushButton[variant="secondary"] { QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
min-height: $h_compact; min-height: $h_compact;
@@ -302,7 +313,7 @@ QLabel#MetricGlyph {
border: 1px solid $line_soft; border: 1px solid $line_soft;
border-radius: 11px; border-radius: 11px;
font-size: $fs_section; font-size: $fs_section;
font-weight: 700; font-weight: $fw_heading;
} }
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; } QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; } QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; }
@@ -318,7 +329,7 @@ QGroupBox {
border: 1px solid $line; border: 1px solid $line;
border-radius: 12px; border-radius: 12px;
background-color: $glass; background-color: $glass;
font-weight: 600; font-weight: $fw_control;
} }
QGroupBox::title { QGroupBox::title {
subcontrol-origin: margin; subcontrol-origin: margin;
@@ -334,7 +345,7 @@ QPushButton {
border-radius: $r_md; border-radius: $r_md;
background-color: $surface; background-color: $surface;
color: $text; color: $text;
font-weight: 600; font-weight: $fw_control;
} }
QPushButton:hover { QPushButton:hover {
background-color: $raised; background-color: $raised;
@@ -345,7 +356,8 @@ QPushButton:pressed {
border-color: $indigo_pressed; border-color: $indigo_pressed;
} }
QPushButton:focus { QPushButton:focus {
border: 2px solid $focus; border: 1px solid $indigo;
background-color: $indigo_pale;
} }
QPushButton:checked { QPushButton:checked {
color: #FFFFFF; color: #FFFFFF;
@@ -361,10 +373,7 @@ QPushButton:disabled {
QPushButton[variant="primary"] { QPushButton[variant="primary"] {
color: #FFFFFF; color: #FFFFFF;
background-color: qlineargradient( background-color: $indigo;
x1: 0, y1: 0, x2: 1, y2: 0,
stop: 0 $indigo, stop: 1 #7769F7
);
border-color: $indigo; border-color: $indigo;
} }
QPushButton[variant="primary"]:hover, QPushButton[variant="primary"]:hover,
@@ -377,7 +386,10 @@ QPushButton[variant="primary"]:checked {
background-color: $indigo_pressed; background-color: $indigo_pressed;
border-color: $indigo_pressed; border-color: $indigo_pressed;
} }
QPushButton[variant="primary"]:focus { border: 2px solid $focus; } QPushButton[variant="primary"]:focus {
border: 1px solid $indigo_pressed;
background-color: $indigo_hover;
}
QPushButton[variant="primary"]:disabled { QPushButton[variant="primary"]:disabled {
color: $disabled_text; color: $disabled_text;
background-color: $surface_alt; background-color: $surface_alt;
@@ -410,7 +422,7 @@ QWidget#RowActions QPushButton[rowAction="true"] {
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $r_sm; border-radius: $r_sm;
font-weight: 600; font-weight: $fw_control;
} }
QWidget#RowActions QPushButton[rowAction="true"]:hover { QWidget#RowActions QPushButton[rowAction="true"]:hover {
color: $indigo_pressed; color: $indigo_pressed;
@@ -436,7 +448,7 @@ QToolButton#RowActionsMore {
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $r_sm; border-radius: $r_sm;
font-weight: 600; font-weight: $fw_control;
} }
QToolButton#RowActionsMore:hover { QToolButton#RowActionsMore:hover {
color: $text; color: $text;
@@ -457,7 +469,7 @@ QPushButton#NoteAttachmentPreview {
background-color: $surface_alt; background-color: $surface_alt;
border: 1px solid $line; border: 1px solid $line;
border-radius: 8px; border-radius: 8px;
font-weight: 500; font-weight: $fw_control;
} }
QPushButton#NoteAttachmentPreview:hover { QPushButton#NoteAttachmentPreview:hover {
background-color: $indigo_pale; background-color: $indigo_pale;
@@ -503,15 +515,15 @@ QPushButton[variant="success"] {
background-color: $success; background-color: $success;
border-color: $success; border-color: $success;
} }
QPushButton[variant="success"]:hover { background-color: #65D4B7; border-color: #65D4B7; } QPushButton[variant="success"]:hover { background-color: #216A56; border-color: #216A56; }
QPushButton[variant="success"]:pressed { background-color: #319F84; border-color: #319F84; } QPushButton[variant="success"]:pressed { background-color: #1B5746; border-color: #1B5746; }
QPushButton[variant="warning"] { QPushButton[variant="warning"] {
color: #FFFFFF; color: #FFFFFF;
background-color: $warning; background-color: $warning;
border-color: $warning; border-color: $warning;
} }
QPushButton[variant="warning"]:hover { background-color: #F0C97C; border-color: #F0C97C; } QPushButton[variant="warning"]:hover { background-color: #925B19; border-color: #925B19; }
QPushButton[variant="warning"]:pressed { background-color: #B99045; border-color: #B99045; } QPushButton[variant="warning"]:pressed { background-color: #794B14; border-color: #794B14; }
QPushButton[variant="ghost"] { QPushButton[variant="ghost"] {
color: $text_soft; color: $text_soft;
@@ -536,7 +548,7 @@ QPushButton[variant="link"] {
background-color: transparent; background-color: transparent;
border-color: transparent; border-color: transparent;
} }
QPushButton[variant="link"]:hover { color: $focus; background-color: $indigo_pale; } QPushButton[variant="link"]:hover { color: $indigo_hover; background-color: $indigo_pale; }
QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; } QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
QPushButton[variant="chip"] { QPushButton[variant="chip"] {
min-height: $h_compact; min-height: $h_compact;
@@ -564,7 +576,7 @@ QPushButton[variant="nav"] {
background-color: transparent; background-color: transparent;
color: $muted; color: $muted;
text-align: left; text-align: left;
font-weight: 600; font-weight: $fw_control;
} }
QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; } QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; }
QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; } QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; }
@@ -580,7 +592,7 @@ QToolButton {
} }
QToolButton:hover { color: $text; background-color: $surface_alt; border-color: $line; } QToolButton:hover { color: $text; background-color: $surface_alt; border-color: $line; }
QToolButton:pressed { background-color: $indigo_pale; border-color: $indigo_pressed; } QToolButton:pressed { background-color: $indigo_pale; border-color: $indigo_pressed; }
QToolButton:focus { border: 2px solid $focus; } QToolButton:focus { border: 1px solid $indigo; background-color: $indigo_pale; }
QToolButton:checked { color: #FFFFFF; background-color: $indigo_pressed; border-color: $indigo_hover; } QToolButton:checked { color: #FFFFFF; background-color: $indigo_pressed; border-color: $indigo_hover; }
QToolButton:disabled { color: $disabled_text; background-color: transparent; border-color: transparent; } QToolButton:disabled { color: $disabled_text; background-color: transparent; border-color: transparent; }
QToolButton[diagnosisChip="true"] { QToolButton[diagnosisChip="true"] {
@@ -616,8 +628,8 @@ QDoubleSpinBox:hover, QKeySequenceEdit:hover { border-color: $indigo_hover; }
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus,
QDateEdit:focus, QDateTimeEdit:focus, QTimeEdit:focus, QSpinBox:focus, QDateEdit:focus, QDateTimeEdit:focus, QTimeEdit:focus, QSpinBox:focus,
QDoubleSpinBox:focus, QKeySequenceEdit:focus { QDoubleSpinBox:focus, QKeySequenceEdit:focus {
border: 2px solid $focus; border: 1px solid $indigo;
background-color: $surface_alt; background-color: $surface;
} }
QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only { QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only {
color: $muted; color: $muted;
@@ -676,7 +688,7 @@ QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView { QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView {
color: $text; color: $text;
background-color: $surface; background-color: $surface;
alternate-background-color: #FBFCFF; alternate-background-color: $surface_alt;
border: 0; border: 0;
border-radius: 12px; border-radius: 12px;
gridline-color: $line; gridline-color: $line;
@@ -684,7 +696,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
selection-color: $text; selection-color: $text;
outline: 0; outline: 0;
} }
QAbstractItemView:focus { border: 1px solid $indigo_hover; } QAbstractItemView:focus { border: 0; }
QTableWidget::item, QTableView::item { QTableWidget::item, QTableView::item {
padding: 6px 8px; padding: 6px 8px;
border-bottom: 1px solid $line; border-bottom: 1px solid $line;
@@ -695,14 +707,14 @@ QTableWidget::item:selected, QTableView::item:selected {
background-color: $selection; background-color: $selection;
} }
QHeaderView::section { QHeaderView::section {
background-color: #F7F9FE; background-color: $raised;
color: $muted; color: $muted;
border: 0; border: 0;
border-right: 1px solid $line; border-right: 0;
border-bottom: 1px solid $line; border-bottom: 1px solid $line;
padding: 7px 8px; padding: 7px 8px;
font-size: $fs_caption; font-size: $fs_caption;
font-weight: 700; font-weight: $fw_control;
} }
QHeaderView::section:hover { color: $text; background-color: $raised; } QHeaderView::section:hover { color: $text; background-color: $raised; }
QTableCornerButton::section { background-color: $surface_alt; border: 0; } QTableCornerButton::section { background-color: $surface_alt; border: 0; }
@@ -736,7 +748,7 @@ QTabBar::tab {
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 9px; border-radius: 9px;
font-weight: 600; font-weight: $fw_control;
} }
QTabBar::tab:hover { color: $text; background-color: $surface_alt; } QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
QTabBar::tab:focus { border-color: $focus; } QTabBar::tab:focus { border-color: $focus; }
@@ -788,8 +800,8 @@ QMenu::item {
border-radius: $r_sm; border-radius: $r_sm;
background-color: transparent; background-color: transparent;
} }
QMenu::item:selected { color: #FFFFFF; background-color: $indigo_pressed; } QMenu::item:selected { color: $indigo_pressed; background-color: $indigo_pale; }
QMenu::item:pressed { background-color: $indigo; } QMenu::item:pressed { color: #FFFFFF; background-color: $indigo; }
QMenu::item:disabled { color: $disabled_text; background-color: transparent; } QMenu::item:disabled { color: $disabled_text; background-color: transparent; }
QMenu::item[danger="true"] { color: $danger; } QMenu::item[danger="true"] { color: $danger; }
QMenu::item[danger="true"]:selected { color: $danger; background-color: $danger_pale; } QMenu::item[danger="true"]:selected { color: $danger; background-color: $danger_pale; }
@@ -841,8 +853,8 @@ QScrollBar::handle:vertical {
min-height: $h_compact; min-height: $h_compact;
border-radius: 4px; border-radius: 4px;
} }
QScrollBar::handle:vertical:hover { background: $indigo_pressed; } QScrollBar::handle:vertical:hover { background: #A7B1C9; }
QScrollBar::handle:vertical:pressed { background: $indigo; } QScrollBar::handle:vertical:pressed { background: #8894B2; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
QScrollBar:horizontal { QScrollBar:horizontal {
@@ -855,8 +867,8 @@ QScrollBar::handle:horizontal {
min-width: 30px; min-width: 30px;
border-radius: 4px; border-radius: 4px;
} }
QScrollBar::handle:horizontal:hover { background: $indigo_pressed; } QScrollBar::handle:horizontal:hover { background: #A7B1C9; }
QScrollBar::handle:horizontal:pressed { background: $indigo; } QScrollBar::handle:horizontal:pressed { background: #8894B2; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; } QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
@@ -885,7 +897,7 @@ QLabel#StatusBadge {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: $r_sm; border-radius: $r_sm;
font-size: $fs_caption; font-size: $fs_caption;
font-weight: 700; font-weight: $fw_control;
} }
QLabel#StatusBadge[kind="neutral"] { color: $muted; background-color: $surface_alt; border-color: $line; } QLabel#StatusBadge[kind="neutral"] { color: $muted; background-color: $surface_alt; border-color: $line; }
QLabel#StatusBadge[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 72); } QLabel#StatusBadge[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 72); }
@@ -902,7 +914,7 @@ QWidget#Pager QLabel#PagerActive {
border: 1px solid $indigo; border: 1px solid $indigo;
border-radius: 8px; border-radius: 8px;
font-size: $fs_caption; font-size: $fs_caption;
font-weight: 700; font-weight: $fw_control;
} }
QWidget#Pager QPushButton { QWidget#Pager QPushButton {
min-height: $h_compact; min-height: $h_compact;
@@ -918,7 +930,7 @@ QLabel#EmptyStateGlyph {
border: 1px solid $line_soft; border: 1px solid $line_soft;
border-radius: 22px; border-radius: 22px;
font-size: $fs_display; font-size: $fs_display;
font-weight: 500; font-weight: $fw_control;
} }
QFrame#MessageBanner { QFrame#MessageBanner {
@@ -927,7 +939,7 @@ QFrame#MessageBanner {
color: $text_soft; color: $text_soft;
} }
QFrame#MessageBanner QLabel { background-color: transparent; } QFrame#MessageBanner QLabel { background-color: transparent; }
QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: 700; } QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: $fw_heading; }
QFrame#MessageBanner[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 82); } QFrame#MessageBanner[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 82); }
QFrame#MessageBanner[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 82); } QFrame#MessageBanner[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 82); }
QFrame#MessageBanner[kind="warning"] { color: $warning; background-color: $warning_pale; border-color: rgba(228, 185, 103, 82); } QFrame#MessageBanner[kind="warning"] { color: $warning; background-color: $warning_pale; border-color: rgba(228, 185, 103, 82); }
@@ -943,7 +955,7 @@ QLabel#Toast {
border: 1px solid $line_soft; border: 1px solid $line_soft;
border-radius: 11px; border-radius: 11px;
padding: 11px 16px; padding: 11px 16px;
font-weight: 600; font-weight: $fw_control;
} }
QLabel#Toast[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 96); } QLabel#Toast[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 96); }
QLabel#Toast[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 96); } QLabel#Toast[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 96); }
@@ -978,7 +990,7 @@ QLabel#UserAvatar {
border: 1px solid $line_soft; border: 1px solid $line_soft;
border-radius: 18px; border-radius: 18px;
font-size: $fs_strong; font-size: $fs_strong;
font-weight: 700; font-weight: $fw_heading;
} }
QWidget#LoginBrandPanel { QWidget#LoginBrandPanel {
background-color: qlineargradient( background-color: qlineargradient(
@@ -997,6 +1009,29 @@ QFrame#LoginCard {
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; } QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
QSplitter::handle:hover { background-color: $indigo_pressed; } QSplitter::handle:hover { background-color: $indigo_pressed; }
QPushButton[variant="secondary"]:disabled,
QPushButton[variant="secondary"]:checked:disabled,
QPushButton[variant="success"]:disabled,
QPushButton[variant="success"]:checked:disabled,
QPushButton[variant="warning"]:disabled,
QPushButton[variant="warning"]:checked:disabled,
QPushButton[variant="danger"]:disabled,
QPushButton[variant="danger"]:checked:disabled,
QPushButton[variant="dangerGhost"]:disabled,
QPushButton[variant="dangerGhost"]:checked:disabled,
QPushButton[variant="ghost"]:disabled,
QPushButton[variant="ghost"]:checked:disabled,
QPushButton[variant="link"]:disabled,
QPushButton[variant="link"]:checked:disabled,
QPushButton[variant="chip"]:disabled,
QPushButton[variant="chip"]:checked:disabled,
QPushButton[variant="nav"]:disabled,
QPushButton[variant="nav"]:checked:disabled {
color: $disabled_text;
background-color: $disabled_surface;
border-color: $line;
}
QToolTip { QToolTip {
color: $text; color: $text;
background-color: $raised; background-color: $raised;
@@ -1024,6 +1059,166 @@ _BLOCK_RHYTHM = {
} }
# --- Surfaces -------------------------------------------------------------
# There is deliberately no drop-shadow system here. One was tried: a painted
# layer behind each page's cards, on the theory that flat outlines were what
# made the workspace look unfinished. It does not work in this palette. The
# cards are #FFFFFF sitting on a #FFFFFF workspace - barely one percent apart -
# so a shadow has no tonal room to read as depth and instead composites into a
# neutral grey rim around every card, which against the blue-tinted ground looks
# like a dirty second border rather than elevation.
#
# Depth in this product comes from the border and the fill, not from shadow.
# --- Control indicator glyphs --------------------------------------------
# Styling ``QCheckBox::indicator`` with a background and border but no image
# tells Qt to stop drawing its own tick, so every checked box in the product
# rendered as a plain indigo square and the partially-checked state was an
# unlabelled grey one. Selection columns on the prescription, patient and
# diagnosis tables all depend on that tick, so the marks are painted here and
# handed back to the stylesheet as cached PNGs.
_INDICATOR_BOX = 15
_INDICATOR_REVISION = "1"
#: The dropdown caret is the one glyph Fusion still drew itself - a solid
#: triangle sitting beside an otherwise entirely stroked icon set. Rendering it
#: here brings every QComboBox, date field and tool-button menu onto the same
#: 24-unit grid as the rest of the product.
_CARET_BOX = 12
def _indicator_pixmap(kind: str, color: str, ratio: int, box_size: int | None = None) -> QPixmap:
box = (box_size or _INDICATOR_BOX) * ratio
pixmap = QPixmap(box, box)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
try:
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
scale = box / 24.0
painter.scale(scale, scale)
pen = QPen(QColor(color), 3.4)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
if kind == "check":
path = QPainterPath(QPointF(5.0, 12.5))
path.lineTo(QPointF(10.0, 17.5))
path.lineTo(QPointF(19.0, 6.5))
painter.drawPath(path)
elif kind == "dash":
painter.drawLine(QPointF(6.0, 12.0), QPointF(18.0, 12.0))
elif kind == "dot":
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(color))
painter.drawEllipse(QPointF(12.0, 12.0), 4.6, 4.6)
elif kind == "caret":
pen.setWidthF(2.6)
painter.setPen(pen)
path = QPainterPath(QPointF(6.0, 9.5))
path.lineTo(QPointF(12.0, 15.5))
path.lineTo(QPointF(18.0, 9.5))
painter.drawPath(path)
finally:
painter.end()
return pixmap
def _indicator_asset_dir() -> Path | None:
try:
from platformdirs import user_cache_dir
from ..config import APP_AUTHOR, APP_NAME
directory = Path(user_cache_dir(APP_NAME, APP_AUTHOR)) / "indicators"
except Exception: # pragma: no cover - falls back to the user home
directory = Path.home() / ".zhenyangdoctor" / "indicators"
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError: # pragma: no cover - read-only deployment
return None
return directory
def _indicator_url(kind: str, color: str, box_size: int | None = None) -> str | None:
"""Return a stylesheet ``url()`` body for one indicator mark, or None."""
directory = _indicator_asset_dir()
if directory is None:
return None
stem = f"{kind}-{color.lstrip('#').lower()}-{box_size or _INDICATOR_BOX}-{_INDICATOR_REVISION}"
base = directory / f"{stem}.png"
# Qt resolves the ``@2x`` companion itself on scaled displays, which keeps
# the mark crisp at the 125%/150% factors clinic workstations run at.
retina = directory / f"{stem}@2x.png"
try:
if not base.exists() and not _indicator_pixmap(kind, color, 1, box_size).save(
str(base), "PNG"
):
return None
if not retina.exists():
_indicator_pixmap(kind, color, 2, box_size).save(str(retina), "PNG")
except OSError: # pragma: no cover - read-only deployment
return None
return base.as_posix()
def _indicator_qss() -> str:
"""Stylesheet fragment that restores the tick, dash and radio dot."""
marks = {
"check": _indicator_url("check", "#FFFFFF"),
"check_disabled": _indicator_url("check", COLORS["disabled_text"]),
"dash": _indicator_url("dash", "#FFFFFF"),
"dot": _indicator_url("dot", "#FFFFFF"),
"dot_disabled": _indicator_url("dot", COLORS["disabled_text"]),
"caret": _indicator_url("caret", COLORS["muted"], _CARET_BOX),
"caret_disabled": _indicator_url("caret", COLORS["disabled_text"], _CARET_BOX),
}
if any(value is None for value in marks.values()):
return ""
return f"""
QCheckBox::indicator:checked {{ image: url({marks["check"]}); }}
QCheckBox::indicator:indeterminate {{
background-color: {COLORS["indigo"]};
border: 1px solid {COLORS["indigo"]};
border-radius: 4px;
image: url({marks["dash"]});
}}
QCheckBox::indicator:checked:disabled {{ image: url({marks["check_disabled"]}); }}
QRadioButton::indicator:checked {{ image: url({marks["dot"]}); }}
QRadioButton::indicator:checked:disabled {{ image: url({marks["dot_disabled"]}); }}
QComboBox::down-arrow, QDateEdit::down-arrow, QDateTimeEdit::down-arrow,
QTimeEdit::down-arrow {{
width: {_CARET_BOX}px;
height: {_CARET_BOX}px;
image: url({marks["caret"]});
}}
/* A tool button's menu indicator defaults to the bottom-right corner, which was
invisible while it was Fusion's 6 px triangle and became obvious once it was a
12 px chevron - the caret dropped below the label instead of sitting beside
it. Anchor it to the right edge; the buttons that show one already reserve
right padding for it, so nothing here changes their metrics. */
QToolButton::menu-indicator {{
width: {_CARET_BOX}px;
height: {_CARET_BOX}px;
image: url({marks["caret"]});
subcontrol-origin: padding;
subcontrol-position: right center;
right: 6px;
}}
/* Re-assert the suppressions that the block above would otherwise override.
These live here rather than in the main sheet because this fragment is
appended after it, and a later rule wins in Qt when specificity ties. */
QToolButton#RowActionsMore::menu-indicator {{ width: 0; height: 0; image: none; }}
QComboBox::down-arrow:disabled, QDateEdit::down-arrow:disabled,
QDateTimeEdit::down-arrow:disabled, QTimeEdit::down-arrow:disabled,
QToolButton::menu-indicator:disabled {{ image: url({marks["caret_disabled"]}); }}
"""
def _block_is_all_bold(block: Any) -> bool: def _block_is_all_bold(block: Any) -> bool:
"""True when every visible run in the block is bold. """True when every visible run in the block is bold.
@@ -1100,7 +1295,7 @@ def apply_reading_rhythm(browser: QTextBrowser, *, role: str) -> None:
char_format = QTextCharFormat() char_format = QTextCharFormat()
char_format.setFont(heading_font) char_format.setFont(heading_font)
if role != "doctor": if role != "doctor":
char_format.setForeground(QColor("#111B3F")) char_format.setForeground(QColor("#1A1C1F"))
cursor.setPosition(block.position()) cursor.setPosition(block.position())
cursor.setPosition( cursor.setPosition(
block.position() + block.length() - 1, block.position() + block.length() - 1,
@@ -1123,14 +1318,21 @@ def _apply_group(
def _register_preferred_cjk_fonts() -> str: def _register_preferred_cjk_fonts() -> str:
"""Make the bundled/offscreen Windows runtime aware of its CJK fonts. """Prefer the shipped outline font, with native CJK faces as a fallback."""
Qt's offscreen platform does not always enumerate the Windows font app = QApplication.instance()
collection. Registering the already-installed YaHei collection only registered = getattr(app, "_doctor_bundled_font_family", None)
when it is missing prevents Chinese text from degrading to tofu boxes in if registered:
packaged captures and headless visual checks. Other platforms continue return registered
to use their native PingFang/Noto fallback. bundled_font = resource_path("fonts", "NotoSansSC-VF.ttf")
""" if bundled_font.is_file():
font_id = QFontDatabase.addApplicationFont(str(bundled_font))
families = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
if families:
family = families[0]
if app is not None:
app._doctor_bundled_font_family = family
return family
platform_families = { platform_families = {
"win32": ("Microsoft YaHei UI", "Microsoft YaHei"), "win32": ("Microsoft YaHei UI", "Microsoft YaHei"),
@@ -1259,12 +1461,27 @@ class _BusinessDialogStyleFilter(QObject):
or dialog.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) or dialog.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
) )
#: The filter is installed on the QApplication, so it is handed every event
#: in the process - roughly 5,000 per list refresh. Only two event types
#: matter, and bailing on the type before touching ``isinstance`` keeps the
#: hot path to a single comparison.
_WATCHED = frozenset({QEvent.Type.Polish, QEvent.Type.Show})
#: Reading surfaces that get eased wheel scrolling when they are polished.
#: Deliberately not every ``QAbstractScrollArea``: animating the scrollbar of
#: an item view that hosts a widget per row means repainting those widgets
#: for the length of the animation, which would trade a jerky scroll for a
#: slow one. The list pages opt their own tables in individually.
_SMOOTH_SCROLL = (QScrollArea, QTextEdit, QTextBrowser, QPlainTextEdit)
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
event_type = event.type() event_type = event.type()
if isinstance(watched, QDialogButtonBox) and event_type in { if event_type not in self._WATCHED:
QEvent.Type.Polish, return False
QEvent.Type.Show, if event_type is QEvent.Type.Polish and isinstance(watched, self._SMOOTH_SCROLL):
}: motion.install_smooth_scroll(watched)
return False
if isinstance(watched, QDialogButtonBox):
_polish_dialog_buttons(watched) _polish_dialog_buttons(watched)
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish: elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
# Window flags can only be changed before the dialog is on screen, # Window flags can only be changed before the dialog is on screen,
@@ -1305,11 +1522,17 @@ def apply_theme(app: QApplication) -> None:
app.setStyle("Fusion") app.setStyle("Fusion")
cjk_family = _register_preferred_cjk_fonts() cjk_family = _register_preferred_cjk_fonts()
# Pin the Windows CJK face explicitly. A comma-separated QSS fallback # Resolve once for both styled widgets and custom-painted table cells.
# list can resolve to Qt's generic sans face in offscreen/native title-bar application_font = QFont(cjk_family)
# captures, which changes glyph width and can even yield tofu boxes. application_font.setPixelSize(int(TYPE["fs_body"].removesuffix("px")))
application_font = QFont(app.font()) application_font.setWeight(QFont.Weight.Normal)
application_font.setFamily(cjk_family) application_font.setStyleStrategy(
QFont.StyleStrategy.PreferAntialias
| QFont.StyleStrategy.PreferOutline
)
# Keep the platform's pixel fitting and subpixel rendering available.
# Forcing grayscale plus vertical-only hinting softens small Windows text.
application_font.setHintingPreference(QFont.HintingPreference.PreferDefaultHinting)
app.setFont(application_font) app.setFont(application_font)
palette = QPalette() palette = QPalette()
active = { active = {
@@ -1324,7 +1547,7 @@ def apply_theme(app: QApplication) -> None:
QPalette.ColorRole.ButtonText: COLORS["text"], QPalette.ColorRole.ButtonText: COLORS["text"],
QPalette.ColorRole.Base: COLORS["surface"], QPalette.ColorRole.Base: COLORS["surface"],
QPalette.ColorRole.Window: COLORS["canvas"], QPalette.ColorRole.Window: COLORS["canvas"],
QPalette.ColorRole.Shadow: "#B7C0D2", QPalette.ColorRole.Shadow: "#C2C2C2",
QPalette.ColorRole.Highlight: COLORS["indigo"], QPalette.ColorRole.Highlight: COLORS["indigo"],
QPalette.ColorRole.HighlightedText: "#FFFFFF", QPalette.ColorRole.HighlightedText: "#FFFFFF",
QPalette.ColorRole.Link: COLORS["info"], QPalette.ColorRole.Link: COLORS["info"],
@@ -1347,7 +1570,7 @@ def apply_theme(app: QApplication) -> None:
QPalette.ColorRole.ButtonText: COLORS["disabled_text"], QPalette.ColorRole.ButtonText: COLORS["disabled_text"],
QPalette.ColorRole.Base: COLORS["disabled_surface"], QPalette.ColorRole.Base: COLORS["disabled_surface"],
QPalette.ColorRole.Window: COLORS["canvas"], QPalette.ColorRole.Window: COLORS["canvas"],
QPalette.ColorRole.Shadow: "#C8CFDC", QPalette.ColorRole.Shadow: "#D2D2D2",
QPalette.ColorRole.Highlight: COLORS["line"], QPalette.ColorRole.Highlight: COLORS["line"],
QPalette.ColorRole.HighlightedText: COLORS["disabled_text"], QPalette.ColorRole.HighlightedText: COLORS["disabled_text"],
QPalette.ColorRole.Link: COLORS["disabled_text"], QPalette.ColorRole.Link: COLORS["disabled_text"],
@@ -1362,7 +1585,7 @@ def apply_theme(app: QApplication) -> None:
_apply_group(palette, QPalette.ColorGroup.Inactive, active) _apply_group(palette, QPalette.ColorGroup.Inactive, active)
_apply_group(palette, QPalette.ColorGroup.Disabled, disabled) _apply_group(palette, QPalette.ColorGroup.Disabled, disabled)
app.setPalette(palette) app.setPalette(palette)
app.setStyleSheet(GLOBAL_QSS) app.setStyleSheet(GLOBAL_QSS + _indicator_qss())
_install_business_dialog_styling(app) _install_business_dialog_styling(app)
@@ -1373,6 +1596,7 @@ __all__ = [
"GLOBAL_QSS", "GLOBAL_QSS",
"METRICS", "METRICS",
"TYPE", "TYPE",
"WEIGHTS",
"apply_theme", "apply_theme",
"crisp_pixmap", "crisp_pixmap",
"mark_business_dialog", "mark_business_dialog",
+85 -11
View File
@@ -10,7 +10,15 @@ from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
from typing import Any from typing import Any
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot from PySide6.QtCore import (
QObject,
QRunnable,
Qt,
QThreadPool,
QTimer,
Signal,
Slot,
)
from PySide6.QtGui import QColor, QPainter, QPaintEvent, QResizeEvent from PySide6.QtGui import QColor, QPainter, QPaintEvent, QResizeEvent
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
@@ -35,6 +43,8 @@ from doctor_workstation.core.errors import (
AuthenticationExpiredError, AuthenticationExpiredError,
) )
from . import icons, motion
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool] AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None _AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
@@ -470,6 +480,8 @@ class PageHeader(QWidget):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("PageHeader") self.setObjectName("PageHeader")
self._compact = False
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8) layout.setSpacing(8)
@@ -514,7 +526,31 @@ class PageHeader(QWidget):
def set_subtitle(self, text: str) -> None: def set_subtitle(self, text: str) -> None:
self.subtitle_label.setText(text) self.subtitle_label.setText(text)
self.subtitle_label.setVisible(bool(text)) self.subtitle_label.setVisible(bool(text) and not self._compact)
self.title_label.setToolTip(text if self._compact else "")
def set_compact(self, compact: bool = True) -> None:
"""Use a single title/action row on list pages with foldable search."""
self._compact = compact
layout = self.layout()
breadcrumb = layout.itemAt(0).layout()
for index in range(breadcrumb.count()):
widget = breadcrumb.itemAt(index).widget()
if widget is not None:
widget.setVisible(not compact)
self.subtitle_label.setVisible(bool(self.subtitle_label.text()) and not compact)
self.title_label.setToolTip(self.subtitle_label.text() if compact else "")
layout.setSpacing(0 if compact else 8)
layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
heading = layout.itemAt(1).layout()
heading.setAlignment(Qt.AlignmentFlag.AlignVCenter)
heading.itemAt(0).layout().setSpacing(0 if compact else 3)
self.actions.setSpacing(8)
if compact:
self.setFixedHeight(44)
else:
self.setMinimumHeight(0)
self.setMaximumHeight(16777215)
class MetricCard(QFrame): class MetricCard(QFrame):
@@ -636,7 +672,9 @@ class MessageBanner(QFrame):
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setContentsMargins(12, 9, 12, 9) layout.setContentsMargins(12, 9, 12, 9)
layout.setSpacing(9) layout.setSpacing(9)
self.icon = QLabel("i", self) # The banner used to letter its own icons - a lowercase "i", a "!", and
# a U+2713 whose shape depended on whichever font happened to cover it.
self.icon = QLabel(self)
self.icon.setObjectName("MessageBannerIcon") self.icon.setObjectName("MessageBannerIcon")
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter) self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.icon.setFixedSize(20, 20) self.icon.setFixedSize(20, 20)
@@ -647,18 +685,33 @@ class MessageBanner(QFrame):
layout.addWidget(self.label, 1) layout.addWidget(self.label, 1)
self.setVisible(bool(text)) self.setVisible(bool(text))
#: Banner kind -> shared glyph and colour role.
_ICONS = {
"info": ("info", "info"),
"success": ("check_circle", "success"),
"warning": ("alert", "warning"),
"danger": ("alert", "danger"),
}
def show_message(self, text: str, kind: str = "info") -> None: def show_message(self, text: str, kind: str = "info") -> None:
glyphs = {"info": "i", "success": "", "warning": "!", "danger": "!"} glyph, role = self._ICONS.get(kind, self._ICONS["info"])
self.label.setText(text) self.label.setText(text)
self.icon.setText(glyphs.get(kind, "i")) self.icon.setPixmap(icons.pixmap(glyph, role, 16))
self.setProperty("kind", kind) self.setProperty("kind", kind)
self.style().unpolish(self) self.style().unpolish(self)
self.style().polish(self) self.style().polish(self)
self.setVisible(bool(text)) if not text:
self.setVisible(False)
return
if self.isVisible():
return
motion.fade_in(self, duration=motion.FAST)
def clear(self) -> None: def clear(self) -> None:
self.setVisible(False) if self.isVisible():
self.label.clear() motion.fade_out(self, duration=motion.FAST, on_finished=self.label.clear)
else:
self.label.clear()
class Toast(QLabel): class Toast(QLabel):
@@ -670,7 +723,10 @@ class Toast(QLabel):
self.hide() self.hide()
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.setSingleShot(True) self._timer.setSingleShot(True)
self._timer.timeout.connect(self.hide) self._timer.timeout.connect(self._dismiss)
def _dismiss(self) -> None:
motion.fade_out(self, duration=motion.BASE)
def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None: def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
self.setText(text) self.setText(text)
@@ -682,7 +738,7 @@ class Toast(QLabel):
if parent is not None: if parent is not None:
self.move(max(16, parent.width() - self.width() - 24), 20) self.move(max(16, parent.width() - self.width() - 24), 20)
self.raise_() self.raise_()
self.show() motion.fade_in(self, duration=motion.FAST)
self._timer.start(duration) self._timer.start(duration)
@@ -730,7 +786,7 @@ class _BusyTrack(QWidget):
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
rect = self.rect() rect = self.rect()
painter.setBrush(QColor("#D8DEEA")) painter.setBrush(QColor("#E4E4E5"))
painter.drawRoundedRect(rect, 3, 3) painter.drawRoundedRect(rect, 3, 3)
chunk_width = max(36, int(rect.width() * 0.32)) chunk_width = max(36, int(rect.width() * 0.32))
span = rect.width() + chunk_width span = rect.width() + chunk_width
@@ -766,6 +822,16 @@ class BusyOverlay(QFrame):
self.raise_() self.raise_()
super().showEvent(event) super().showEvent(event)
def reveal(self) -> None:
"""Fade the guard in, so a fast response never flashes a grey slab."""
if not self.isVisible():
motion.fade_in(self, duration=motion.FAST)
def dismiss(self) -> None:
if self.isVisible():
motion.fade_out(self, duration=motion.FAST)
class OverlayHost(QWidget): class OverlayHost(QWidget):
"""Widget base that automatically sizes a BusyOverlay child.""" """Widget base that automatically sizes a BusyOverlay child."""
@@ -795,11 +861,19 @@ class SortableTable(QTableWidget):
self.setColumnCount(len(self.columns)) self.setColumnCount(len(self.columns))
self.setHorizontalHeaderLabels([column.title for column in self.columns]) self.setHorizontalHeaderLabels([column.title for column in self.columns])
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
# Row separation already comes from the ``::item`` bottom border, so the
# grid only added a column rule that cut across every row - and it kept
# drawing past the last populated column, leaving a stray vertical line
# hanging in the empty part of the table.
self.setShowGrid(False)
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
# Per-pixel mode only smooths dragging; the wheel still jumped three rows
# at a time, which is how most of a list actually gets read.
motion.install_smooth_scroll(self)
self.setMinimumHeight(0) self.setMinimumHeight(0)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.verticalHeader().setVisible(False) self.verticalHeader().setVisible(False)
+34 -1
View File
@@ -85,6 +85,18 @@ def _normalized_key(key: Any) -> str:
return "".join(character for character in str(key).lower() if character.isalnum()) return "".join(character for character in str(key).lower() if character.isalnum())
def _appointment_id(value: Any) -> int:
if value is None:
return 0
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise VideoTicketError("appointment_id must be a nonnegative integer") from exc
if isinstance(value, bool) or parsed < 0 or str(value) != str(parsed):
raise VideoTicketError("appointment_id must be a nonnegative integer")
return parsed
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"} _FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
@@ -138,7 +150,8 @@ def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
if hasattr(ticket, attribute_name) if hasattr(ticket, attribute_name)
} }
if adapted: if adapted:
return adapted # Preserve server policy and identities; model attributes alone omit them.
return {**(dict(raw) if isinstance(raw, Mapping) else {}), **adapted}
raise VideoTicketError("backend ticket must be a mapping or call-ticket object") raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
@@ -199,8 +212,15 @@ class VideoCallRequest:
patient_id: Identifier | None = None patient_id: Identifier | None = None
call_record_id: Identifier | None = None call_record_id: Identifier | None = None
backend_mode: BackendMode = BackendMode.EMBEDDED backend_mode: BackendMode = BackendMode.EMBEDDED
appointment_id: int = 0
appointment_type: Any = None
appointment_type_desc: str = ""
can_video_call: bool = False
can_audio_call: bool = False
call_disabled_reason: str = ""
def __post_init__(self) -> None: def __post_init__(self) -> None:
object.__setattr__(self, "appointment_id", _appointment_id(self.appointment_id))
object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id)) object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id))
object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID")) object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID"))
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig")) object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
@@ -257,6 +277,13 @@ class VideoCallRequest:
"userSig": self.user_sig, "userSig": self.user_sig,
"targetUserId": self.target_user_id, "targetUserId": self.target_user_id,
"diagnosisId": self.diagnosis_id, "diagnosisId": self.diagnosis_id,
"patientId": self.patient_id,
"appointmentId": self.appointment_id,
"appointment_type": self.appointment_type,
"appointment_type_desc": self.appointment_type_desc,
"can_video_call": self.can_video_call is True,
"can_audio_call": self.can_audio_call is True,
"call_disabled_reason": self.call_disabled_reason,
} }
def safe_log_context(self) -> dict[str, Any]: def safe_log_context(self) -> dict[str, Any]:
@@ -340,6 +367,12 @@ def normalize_backend_ticket(
diagnosis_id=normalized_diagnosis, diagnosis_id=normalized_diagnosis,
patient_id=normalized_patient, patient_id=normalized_patient,
call_record_id=payload_call_record, call_record_id=payload_call_record,
appointment_id=_appointment_id(payload.get("appointment_id", 0)),
appointment_type=payload.get("appointment_type"),
appointment_type_desc=str(payload.get("appointment_type_desc") or ""),
can_video_call=payload.get("can_video_call") is True,
can_audio_call=payload.get("can_audio_call") is True,
call_disabled_reason=str(payload.get("call_disabled_reason") or ""),
backend_mode=BackendMode.parse(backend_mode), backend_mode=BackendMode.parse(backend_mode),
) )
+26 -3
View File
@@ -18,7 +18,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, TypeVar from typing import Any, TypeVar
from .launcher import VideoCallRequest from .launcher import VideoCallRequest, normalize_backend_ticket
ResultT = TypeVar("ResultT") ResultT = TypeVar("ResultT")
@@ -288,7 +288,29 @@ class OrderedCallLifecycle:
with self._lock: with self._lock:
return self.bound_room_id or self._claimed_room_id return self.bound_room_id or self._claimed_room_id
def start(self) -> Future[bool]: def refresh_call_policy(self) -> Future[dict[str, Any]]:
"""Fetch policy for this exact immutable consultation on the worker."""
def operation() -> dict[str, Any]:
ticket = self.repository.get_call_ticket(
patient_id=self.request.patient_id,
diagnosis_id=self.request.diagnosis_id,
appointment_id=self.request.appointment_id,
)
refreshed = normalize_backend_ticket(
ticket, patient_id=self.request.patient_id,
diagnosis_id=self.request.diagnosis_id,
)
if (refreshed.appointment_id != self.request.appointment_id
or refreshed.target_user_id != self.request.target_user_id
or refreshed.user_id != self.request.user_id
or refreshed.sdk_app_id != self.request.sdk_app_id):
raise ValueError("当前问诊已变更,请重新打开聊天窗口")
return refreshed.to_web_config()
return self._worker.submit("refresh_policy", operation)
def start(self, *, call_type: int = 2) -> Future[bool]:
if type(call_type) is not int or call_type not in (1, 2):
raise ValueError("call_type must be 1 or 2")
with self._lock: with self._lock:
if self._start_future is not None: if self._start_future is not None:
return self._start_future return self._start_future
@@ -297,7 +319,8 @@ class OrderedCallLifecycle:
raise ValueError("video repository does not implement start_call") raise ValueError("video repository does not implement start_call")
payload: dict[str, Any] = { payload: dict[str, Any] = {
"diagnosis_id": self.request.diagnosis_id, "diagnosis_id": self.request.diagnosis_id,
"call_type": 2, "call_type": call_type,
"appointment_id": self.request.appointment_id,
} }
if self.request.patient_id is not None: if self.request.patient_id is not None:
payload["patient_id"] = self.request.patient_id payload["patient_id"] = self.request.patient_id
+44 -7
View File
@@ -279,6 +279,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
call_ended = Signal(str) # type: ignore[misc] call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc] call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc] _start_completed = Signal(bool) # type: ignore[misc]
_policy_completed = Signal(str, object) # type: ignore[misc]
_room_completed = Signal(str, bool, str) # type: ignore[misc] _room_completed = Signal(str, bool, str) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc] _screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc] _transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
@@ -369,6 +370,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions() self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started) self._start_completed.connect(self._on_lifecycle_started)
self._policy_completed.connect(self._on_policy_completed)
self._room_completed.connect(self._on_room_completed) self._room_completed.connect(self._on_room_completed)
self._screenshot_completed.connect(self._on_screenshot_completed) self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed) self._transcription_completed.connect(self._on_transcription_completed)
@@ -509,7 +511,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
QTimer.singleShot(0, self._open_diagnosis_safely) QTimer.singleShot(0, self._open_diagnosis_safely)
return return
if event == "call-start-request": if event == "call-start-request":
self._start_call_cycle() self._start_call_cycle(call_type=message.get("callType", 2))
return
if event == "call-policy-request":
self._refresh_call_policy(str(message.get("requestId") or ""))
return return
if event == "screenshot": if event == "screenshot":
self._save_screenshot(str(message.get("dataUrl") or "")) self._save_screenshot(str(message.get("dataUrl") or ""))
@@ -934,16 +939,48 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
"本次本地录音未正常结束,文件已保留以便排查。", "本次本地录音未正常结束,文件已保留以便排查。",
) )
def _start_call_cycle(self) -> None: def _prepare_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
if self._call_cycle_closed: if self._call_cycle_closed:
self.lifecycle = self._lifecycle_factory() self.lifecycle = self._lifecycle_factory()
self._lifecycles.append(self.lifecycle) self._lifecycles.append(self.lifecycle)
self._call_cycle_closed = False self._call_cycle_closed = False
def _refresh_call_policy(self, request_id: str) -> None:
if not request_id or self._closing or self._shutdown_requested:
return
self._prepare_call_cycle()
def completed(future: Future[dict[str, Any]]) -> None:
try:
policy = future.result()
except Exception:
policy = {"call_disabled_reason": "无法确认本次挂号通话权限,请重新打开聊天窗口"}
with suppress(RuntimeError):
self._policy_completed.emit(request_id, policy)
try:
self.lifecycle.refresh_call_policy().add_done_callback(completed)
except Exception:
self._on_policy_completed(request_id, {})
def _on_policy_completed(self, request_id: str, policy: dict[str, Any]) -> None:
if self._closing or self._shutdown_requested or self._released:
return
# Send policy only; refreshed credentials never enter logs or notices.
payload = {key: policy.get(key) for key in (
"appointmentId", "appointment_type", "can_video_call", "can_audio_call",
"call_disabled_reason", "appointment_type_desc",
)}
self._page.runJavaScript(
"window.doctorConsultation?.callPolicyResult?.("
+ json.dumps(request_id) + "," + json.dumps(payload, ensure_ascii=True) + ");"
)
def _start_call_cycle(self, *, call_type: int = 2) -> None:
if self._closing or self._shutdown_requested or self._start_requested:
return
self._prepare_call_cycle()
self._start_requested = True self._start_requested = True
try: try:
future = self.lifecycle.start() future = self.lifecycle.start(call_type=call_type)
except Exception: except Exception:
self._start_requested = False self._start_requested = False
self._on_lifecycle_started(False) self._on_lifecycle_started(False)
@@ -1149,8 +1186,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._closing = True self._closing = True
self._media_active = False self._media_active = False
self._shutdown_timer.stop() self._shutdown_timer.stop()
if self._start_requested and not self._call_cycle_closed: # Also retire policy-only workers for IM sessions without a live call.
self.lifecycle.end(self._close_reason) self.lifecycle.end(self._close_reason)
self._abort_local_audio_recording("") self._abort_local_audio_recording("")
self._release_webengine() self._release_webengine()
+10 -5
View File
@@ -1634,7 +1634,11 @@ def test_report_bubbles_report_the_height_they_actually_paint(
host, bubble = _fitted_bubble(reply) host, bubble = _fitted_bubble(reply)
assert bubble.height() > 0 assert bubble.height() > 0
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2 # Wrapping labels can legitimately have a different preferred height at
# their preferred width. Compare against the actual reading-column width.
fitted_height = bubble.heightForWidth(bubble.width())
expected_height = fitted_height if fitted_height >= 0 else bubble.sizeHint().height()
assert abs(expected_height - bubble.height()) <= 2
host.close() host.close()
host.deleteLater() host.deleteLater()
@@ -1646,14 +1650,15 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
assert "#FEF3F2" in risk_card assert "#FEF3F2" in risk_card
assert "#F1B35C" not in risk_card # 旧的橙色描边 assert "#F1B35C" not in risk_card # 旧的橙色描边
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0] marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
assert "#C0392B" in marker assert "#BE4B58" in marker
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None: def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
qss = ai_consult_module.AI_CONSULT_QSS qss = ai_consult_module.AI_CONSULT_QSS
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1) body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
assert len(body) == 2 or "font-size: 13px" in qss assert "color: #1a1c1f" in body.lower()
assert "font-size: 14px" in body
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0] block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
assert "font-size: 13px" in block assert "font-size: 14px" in block
assert "font-size: 11px" not in block assert "font-size: 11px" not in block
+8 -2
View File
@@ -278,8 +278,10 @@ def test_field_order_density_and_conditional_channel_row(
application.processEvents() application.processEvents()
@pytest.mark.parametrize("mode", ["video", "text"])
def test_roster_slot_states_conflict_refresh_and_submit_contract( def test_roster_slot_states_conflict_refresh_and_submit_contract(
application: QApplication, application: QApplication,
mode: str,
) -> None: ) -> None:
repository = _VisualRepository(today_conflict=True) repository = _VisualRepository(today_conflict=True)
host, drawer = _show_drawer( host, drawer = _show_drawer(
@@ -318,6 +320,10 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
assert unavailable.status_label.isVisible() assert unavailable.status_label.isVisible()
assert unavailable.accessibleName() == "10:00-10:30 已约" assert unavailable.accessibleName() == "10:00-10:30 已约"
available.click() available.click()
if mode == "text":
drawer.text_appointment_type_radio.click()
assert drawer.text_appointment_type_radio.isChecked()
assert not drawer.appointment_type_radio.isChecked()
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online")) drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
assert drawer.ok_button.isEnabled() assert drawer.ok_button.isEnabled()
@@ -328,7 +334,7 @@ def test_roster_slot_states_conflict_refresh_and_submit_contract(
"appointment_date": tomorrow, "appointment_date": tomorrow,
"appointment_time": "09:30-10:00", "appointment_time": "09:30-10:00",
"period": "all", "period": "all",
"appointment_type": "video", "appointment_type": mode,
"remark": "", "remark": "",
"channel_source": "online", "channel_source": "online",
"channel_source_detail": "", "channel_source_detail": "",
@@ -559,7 +565,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()
+242
View File
@@ -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
+19 -10
View File
@@ -10,6 +10,8 @@ from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest import pytest
from PySide6.QtCore import QElapsedTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
@@ -398,12 +400,19 @@ def test_appointments_page_default_query_is_today_pending(
page.refresh() page.refresh()
application.processEvents() application.processEvents()
completed = QElapsedTimer()
completed.start()
while page.table.rowCount() == 0 and completed.elapsed() < 2_000:
QTest.qWait(10)
filters = page._query_filters() filters = page._query_filters()
assert filters["status"] == 1 assert filters["status"] == 1
assert filters["include_status_counts"] == 1 assert filters["include_status_counts"] == 1
assert filters["start_date"] == filters["end_date"] assert filters["start_date"] == filters["end_date"]
assert "diag_scope_relax" not in filters assert "diag_scope_relax" not in filters
assert page.table.rowCount() >= 1 assert page.table.rowCount() >= 1
page.close()
application.processEvents()
def test_demo_appointment_status_counts_respect_date_scope() -> None: def test_demo_appointment_status_counts_respect_date_scope() -> None:
@@ -464,8 +473,7 @@ def test_appointment_multiline_cells_receive_enough_row_height(
assert appointment_text.count("\n") == 2 assert appointment_text.count("\n") == 2
assert "2026-08-11 14:30" in appointment_text assert "2026-08-11 14:30" in appointment_text
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10 required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
assert 60 <= page.table.rowHeight(0) <= 66 assert page.table.rowHeight(0) >= required
assert page.table.rowHeight(0) >= min(required, 66)
assert page.table.item(0, 4).toolTip() == appointment_text assert page.table.item(0, 4).toolTip() == appointment_text
page.close() page.close()
@@ -734,7 +742,7 @@ def test_im_entry_does_not_require_a_live_video_hint(
assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled() assert buttons_by_name["与已接通患者进行 IM 问诊"].isEnabled()
waiting = buttons_by_name["与等待患者进行 IM 问诊"] waiting = buttons_by_name["与等待患者进行 IM 问诊"]
assert waiting.isEnabled() assert waiting.isEnabled()
assert waiting.toolTip() == "打开患者 IM,可发送消息并从会话中发起视频" assert waiting.toolTip() == "打开患者 IM,可发送消息;通话能力由本次挂号决定"
page.close() page.close()
application.processEvents() application.processEvents()
@@ -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

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