Compare commits

..
15 Commits
Author SHA1 Message Date
gr 18b92f1767 geng 2026-08-17 09:17:42 +08:00
Your Name 79601ea176 更新 2026-08-17 09:09:20 +08:00
Your Name c1eee2d28a 更新bug 2026-08-17 09:06:49 +08:00
Your Name 2fc8048844 更新 2026-08-14 14:52:42 +08:00
Your Name 18c15d1262 更新 2026-08-14 14:37:30 +08:00
Your Name 21790e35f4 新增 2026-08-13 16:37:37 +08:00
Your Name cbe71905c0 更新 2026-08-13 14:22:19 +08:00
Your Name 72a5300b08 更新 2026-08-13 11:54:00 +08:00
Your Name 07188e82f8 更新 2026-08-13 09:41:55 +08:00
Your Name c3713a8785 更新 2026-08-13 09:39:16 +08:00
Your Name dddf2348d3 更新 2026-08-12 18:30:00 +08:00
Your Name 18ed3c48fa 更新 2026-08-12 18:26:42 +08:00
Your Name 26839c2957 更新 2026-08-12 18:18:33 +08:00
Your Name b2eb18c75f 更新 2026-08-12 18:09:57 +08:00
Your Name a1092c02c3 更新 2026-08-12 17:27:14 +08:00
587 changed files with 37707 additions and 9539 deletions
+2 -2
View File
@@ -155,7 +155,7 @@ export function firstVisitConversionOverview(params: FirstVisitConversionParams)
}
export interface FirstVisitRegistrationStatsParams {
time_type: 'today' | 'week' | 'month'
time_type: 'today' | 'yesterday' | 'week' | 'month'
dept_id?: number
assistant_id?: number
}
@@ -169,7 +169,7 @@ export function firstVisitRegistrationStatsOverview(params: FirstVisitRegistrati
}
export interface FirstVisitDoctorDashboardParams {
time_type: 'today' | 'week' | 'month' | 'custom'
time_type: 'today' | 'yesterday' | 'week' | 'month' | 'custom'
start_date?: string
end_date?: string
dept_id?: number
+40
View File
@@ -780,6 +780,46 @@ export function prescriptionLibraryEditAiReport(params: {
)
}
/** 读取已保存的诊单 AI 报告;不会触发模型生成。 */
export function diagnosisAiReports(params: { id: number }) {
return request.get<PrescriptionAiReportsResponse>(
{
url: '/tcm.diagnosis/aiReports',
params,
timeout: 30000
},
{ ignoreCancelToken: true }
)
}
/** 重新生成诊单双模型 AI 报告;POST 不自动重试。 */
export function diagnosisGenerateAiReports(params: { id: number }) {
return request.post<PrescriptionAiReportsResponse>(
{
url: '/tcm.diagnosis/generateAiReports',
params,
timeout: 210000
},
{ ignoreCancelToken: true, isOpenRetry: false }
)
}
/** 编辑一份已持久化的诊单 AI 报告。 */
export function diagnosisEditAiReport(params: {
id: number
report_id: number
content: string
}) {
return request.post<PrescriptionAiReportEditResponse>(
{
url: '/tcm.diagnosis/editAiReport',
params,
timeout: 30000
},
{ ignoreCancelToken: true, isOpenRetry: false }
)
}
// ========== 诊单待办事项(T5 ==========
/** 待办列表(按 diagnosis_id */
+278 -61
View File
@@ -1,13 +1,32 @@
<template>
<div ref="paperRef" class="rx-paper">
<div class="rx-title">{{ isInternal ? '药房联' : slipTitle }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
</span>
<span class="rx-notice-meta">
<span>日期{{ dateText }}</span>
<span>编号{{ serialText }}</span>
{{
isInternal
? '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
: '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
}}
</span>
<div class="rx-notice-meta">
<div class="rx-meta-item">
<span class="rx-meta-label">日期</span>
<span class="rx-meta-value">{{ dateText }}</span>
</div>
<div class="rx-meta-item">
<span class="rx-meta-label">处方编号</span>
<span class="rx-meta-value">{{ prescriptionSnText }}</span>
</div>
<div class="rx-meta-item">
<span class="rx-meta-label">流转编号挂号</span>
<span class="rx-meta-value">{{ appointmentFlowText }}</span>
</div>
<div v-if="businessOrderNoText" class="rx-meta-item">
<span class="rx-meta-label">业务单号</span>
<span class="rx-meta-value">{{ businessOrderNoText }}</span>
</div>
</div>
</div>
<div class="rx-info">
@@ -44,65 +63,100 @@
</div>
<div class="rx-rp">
<div class="rx-watermark">药房联</div>
<div class="rx-watermark">{{ isInternal ? '药房联' : '处方联' }}</div>
<div class="rx-rp-head">
<div class="rx-rp-label">Rp.</div>
<div class="rx-rp-cols-head">
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
<span>{{ isInternal ? '总量' : '用量' }}</span>
</div>
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
<span>{{ isInternal ? '总量' : '用量' }}</span>
</div>
</div>
</div>
<div class="rx-herbs">
<template v-if="mainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in mainHerbs"
:key="'main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}</span>
</div>
<template v-if="isInternal">
<template v-if="mainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in mainHerbs"
:key="'main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}</span>
</div>
</template>
<template v-if="auxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">
辅方
<span v-if="auxLibraryName" class="rx-herb-library-name">{{ auxLibraryName }}</span>
</div>
<div
v-for="(h, i) in auxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}</span>
</div>
</template>
</template>
<template v-if="auxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in auxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}</span>
</div>
<template v-else>
<template v-if="mainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in mainHerbs"
:key="'user-main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
<template v-if="auxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in auxHerbs"
:key="'user-aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
</template>
</div>
</div>
<div class="rx-text">
<p>主方服法{{ usageText }}</p>
<p v-if="auxUsageText">辅方服法{{ auxUsageText }}</p>
<template v-if="isInternal">
<p v-if="auxUsageText">服法{{ usageText }}</p>
<p v-else>服法{{ usageText }}</p>
<p v-if="auxUsageText">辅服法{{ auxUsageText }}</p>
</template>
<p v-if="adviceText">医嘱{{ adviceText }}</p>
<p v-if="dietaryText">忌口{{ dietaryText }}</p>
<p v-if="remarkText">备注{{ remarkText }}</p>
<p v-if="isInternal && remarkText">备注{{ remarkText }}</p>
<p v-if="pharmacyRemarkText" class="rx-text-warn">
药房备注{{ pharmacyRemarkText }}
</p>
<p v-if="outPelletText" class="rx-text-warn">
<p
v-if="isInternal && outPelletText && data?.prescription_type !== '饮片'"
class="rx-text-warn"
>
出丸{{ outPelletText }}
</p>
</div>
<div class="rx-bottom">
<div class="rx-bot-row rx-bot-row-1">
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
<div class="rx-bot-label">医师</div>
<div class="rx-bot-row rx-bot-row-1" :class="{ 'rx-bot-row-1--user': !isInternal }">
<div class="rx-bot-cell rx-bot-doctor">
<div class="rx-bot-label rx-bot-label--doctor">医师</div>
<div class="rx-bot-doctor-body">
<img
v-if="data?.doctor_signature"
@@ -110,24 +164,28 @@
alt="医师签名"
class="rx-bot-sign-img"
/>
<div v-if="!data?.doctor_signature" class="rx-bot-doctor-name">
<span v-if="!data?.doctor_signature" class="rx-bot-doctor-name">
{{ data?.doctor_name || '' }}
</div>
</span>
</div>
</div>
<div class="rx-bot-cell rx-bot-meta">
<div v-if="isInternal" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">类型:</span>
<span class="rx-bot-meta-val">{{ typeText }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">天数:</span>
<span class="rx-bot-meta-val">{{ data?.dose_count || '—' }}</span>
<span class="rx-bot-meta-val">{{ daysText }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<div v-if="isInternal" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<span class="rx-bot-meta-val">{{ perDoseAmount }}</span>
</div>
</div>
<div class="rx-bot-row rx-hospital-row">
<div class="rx-hospital-line">成都双流甄养堂互联网医院有限公司 联系方式4001667339</div>
<div class="rx-hospital-line">地址四川省成都市双流区黄甲街道黄龙大道二段280号</div>
</div>
</div>
</div>
</template>
@@ -138,15 +196,17 @@ import {
getAuxHerbs,
getMainHerbs,
rxAdviceText,
rxAppointmentFlowText,
rxAuxUsageText,
rxBusinessOrderNoText,
rxDateText,
rxHerbTotal,
rxOutPelletText,
rxPerDoseAmount,
rxPharmacyRemarkText,
rxPrescriptionSnText,
rxRecipientText,
rxRemarkText,
rxSerialText,
rxTypeText,
rxUsageText,
slipAgeText,
@@ -156,15 +216,32 @@ import {
defineOptions({ name: 'PrescriptionSlip' })
const props = defineProps<{
/** 处方详情 / 列表行(字段与 tcm.prescription/detail 对齐) */
data: Record<string, any> | null | undefined
}>()
const props = withDefaults(
defineProps<{
/** 处方详情 / 列表行(字段与 tcm.prescription/detail 对齐) */
data: Record<string, any> | null | undefined
/** 药房联 internal / 处方联 user */
variant?: 'internal' | 'user'
/** 药房联辅方处方库名称 */
auxLibraryName?: string
/** 抬头(处方联) */
title?: string
}>(),
{
variant: 'internal',
auxLibraryName: '',
title: '成都双流甄养堂互联网医院 处方笺'
}
)
const paperRef = ref<HTMLElement | null>(null)
const isInternal = computed(() => props.variant !== 'user')
const slipTitle = computed(() => props.title || '成都双流甄养堂互联网医院 处方笺')
const dateText = computed(() => rxDateText(props.data))
const serialText = computed(() => rxSerialText(props.data))
const prescriptionSnText = computed(() => rxPrescriptionSnText(props.data))
const appointmentFlowText = computed(() => rxAppointmentFlowText(props.data))
const businessOrderNoText = computed(() => rxBusinessOrderNoText(props.data))
const recipientText = computed(() => rxRecipientText(props.data))
const genderLabel = computed(() => slipGenderLabel(props.data?.gender))
const ageText = computed(() => slipAgeText(props.data?.age))
@@ -179,6 +256,17 @@ const pharmacyRemarkText = computed(() => rxPharmacyRemarkText(props.data))
const outPelletText = computed(() => rxOutPelletText(props.data))
const typeText = computed(() => rxTypeText(props.data))
const perDoseAmount = computed(() => rxPerDoseAmount(props.data))
const daysText = computed(() => {
const md = props.data?.medication_days
if (md != null && String(md).trim() !== '') {
return `${md}`
}
const dose = props.data?.dose_count
if (dose != null && String(dose).trim() !== '') {
return `${dose}`
}
return '—'
})
function herbTotal(dosage: number | string) {
return rxHerbTotal(props.data, dosage)
@@ -194,9 +282,8 @@ defineExpose({
<style lang="scss" scoped>
/* ============================================================
* 处方笺(A4 药房联)样式
* 严格按 A4210mm × 297mmpadding 8mm 10mm
* 唯一视觉基准:consumer/prescription/index.vue
* 处方笺(A4 药房联 / 处方联)样式
* 与 Desktop order_list.vue 笺面样式保持一致
* ============================================================ */
.rx-paper {
width: 210mm;
@@ -214,29 +301,98 @@ defineExpose({
overflow: visible;
}
/* === 顶部告知(上下分块,避免左右 flex 把左侧提示挤成竖条)=== */
.rx-notice {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
align-items: stretch;
gap: 10px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
padding: 6px 10px;
padding: 14px 12px;
border-radius: 2px;
font-size: 12px;
color: #1f1f1f;
margin-bottom: 6px;
.rx-notice-text {
flex: 1;
flex: none;
width: 100%;
line-height: 1.55;
word-break: break-word;
}
.rx-notice-meta {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 4px 14px;
width: 100%;
box-sizing: border-box;
margin-top: 4px;
padding: 8px 2px 2px;
border-top: 1px solid #e5e7eb;
font-size: 10px;
line-height: 2.35;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.rx-notice-meta .rx-meta-item {
display: inline-flex;
gap: 24px;
flex: 0 0 auto;
align-items: baseline;
gap: 2px;
min-width: 0;
padding: 0;
margin: 0;
background: transparent;
border: none;
border-radius: 0;
white-space: nowrap;
line-height: 2.35;
height: 20px;
}
.rx-notice-meta .rx-meta-item:not(:first-child) {
border-left: 1px solid #d1d5db;
padding-left: 14px;
margin-left: 2px;
line-height: 2.35;
}
.rx-notice-meta .rx-meta-label {
font-weight: 600;
color: #6b7280;
line-height: 1.35;
flex-shrink: 0;
}
.rx-notice-meta .rx-meta-label::after {
content: '';
font-weight: 500;
color: #9ca3af;
}
.rx-notice-meta .rx-meta-value {
color: #111827;
line-height: 1.35;
font-variant-numeric: tabular-nums;
}
}
.rx-title {
text-align: center;
font-size: 28px;
font-weight: 700;
line-height: 1.2;
margin: 10px 0 35px;
letter-spacing: 0.08em;
color: #1f1f1f;
}
/* === 患者信息表 === */
.rx-info {
border: 1px solid #c8c8c8;
border-bottom: none;
@@ -256,7 +412,7 @@ defineExpose({
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
padding: 16px 10px;
font-size: 13px;
border-right: 1px solid #c8c8c8;
min-height: 32px;
@@ -277,6 +433,7 @@ defineExpose({
}
}
/* === Rp 区域 + 水印 === */
.rx-rp {
position: relative;
border-left: 1px solid #c8c8c8;
@@ -289,6 +446,7 @@ defineExpose({
.rx-watermark {
position: absolute;
/* 不使用 inset 简写:html2canvas 对 inset 解析不稳定 */
top: 0;
right: 0;
bottom: 0;
@@ -318,6 +476,7 @@ defineExpose({
z-index: 1;
width: 100%;
box-sizing: border-box;
padding: 10px;
}
.rx-rp-label {
@@ -325,6 +484,7 @@ defineExpose({
font-weight: 700;
color: #1f1f1f;
line-height: 1;
padding-bottom: 15px;
}
.rx-rp-cols-head {
@@ -336,6 +496,9 @@ defineExpose({
min-width: 0;
}
/* 注意:列内 1fr 64px 与 .rx-herb-cell 完全一致,
* 配合 .rx-herbs 与 .rx-rp-cols-head 同样的左侧 44px 缩进,
* 即可保证「用药 (单剂)」与药材名、「总量」与总量数字精确对齐。 */
.rx-rp-col-head {
display: grid;
grid-template-columns: 1fr 64px;
@@ -343,6 +506,7 @@ defineExpose({
font-size: 13px;
color: #1f1f1f;
min-width: 0;
padding-bottom: 15px;
& > span:last-child {
text-align: right;
@@ -350,6 +514,7 @@ defineExpose({
}
.rx-herbs {
/* 与 .rx-rp-head 中 Rp. 列(36px) + column-gap(8px) 等宽缩进 */
padding-left: 44px;
display: grid;
grid-template-columns: 1fr 1fr;
@@ -374,12 +539,17 @@ defineExpose({
margin-top: 4px;
}
.rx-herb-library-name {
font-weight: 500;
margin-left: 2px;
}
.rx-herb-cell {
display: grid;
grid-template-columns: 1fr 64px;
column-gap: 8px;
font-size: 13px;
line-height: 1.85;
line-height: 2.5;
min-width: 0;
}
@@ -398,6 +568,7 @@ defineExpose({
white-space: nowrap;
}
/* === 服法 / 备注 文本块 === */
.rx-text {
border: 1px solid #c8c8c8;
border-top: none;
@@ -415,6 +586,7 @@ defineExpose({
}
}
/* === 底部表格 === */
.rx-bottom {
border: 1px solid #c8c8c8;
border-top: none;
@@ -430,10 +602,36 @@ defineExpose({
}
.rx-bot-row-1 {
grid-template-columns: 1.4fr 0.6fr 0.6fr 0.6fr 0.6fr 1.7fr 0.95fr 1.25fr;
/* 类型/剂数/单剂量 三个 meta 列适当加宽,
* 避免"浓缩丸-浓缩水丸"被强制折行后把整行挤错位 */
grid-template-columns: 1.6fr 0.6fr 0.6fr 0.6fr 0.6fr 1.7fr 0.95fr 1.25fr;
min-height: 70px;
}
.rx-bot-row-1--user {
grid-template-columns: 1.6fr 0.6fr;
}
.rx-hospital-row {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 2px;
padding: 10px 12px;
min-height: 52px;
text-align: left;
}
.rx-hospital-line {
font-size: 12px;
color: #374151;
line-height: 1.6;
}
/* 默认:单行内容左对齐、上下居中(适配 类型/剂数/单剂量/费用 等格子)。
* 需要"标题置顶 + 签名留白"的格子(医师/审方/配药/复核/发药)请加 .rx-bot-stack。
* 这样无需任何 !important 反向覆盖,html2canvas/原生渲染表现一致。 */
.rx-bot-cell {
border-right: 1px solid #c8c8c8;
padding: 6px 10px;
@@ -445,6 +643,7 @@ defineExpose({
justify-content: flex-start;
min-height: 32px;
box-sizing: border-box;
line-height: 2.5;
&:last-child {
border-right: none;
@@ -463,21 +662,39 @@ defineExpose({
margin-bottom: 4px;
}
.rx-bot-label--doctor {
flex-shrink: 0;
margin-bottom: 0;
margin-right: 6px;
line-height: 1.2;
align-self: center;
}
.rx-bot-doctor {
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
.rx-bot-doctor-body {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
min-height: 36px;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 8px;
min-height: 0;
}
.rx-bot-doctor-name {
font-size: 13px;
color: #1f1f1f;
line-height: 1.2;
white-space: nowrap;
}
.rx-bot-sign-img {
flex-shrink: 0;
max-height: 40px;
max-width: 110px;
object-fit: contain;
@@ -501,7 +718,7 @@ defineExpose({
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
line-height: 1.4;
line-height: 2.5;
}
}
</style>
@@ -173,6 +173,30 @@ export function rxSerialText(data: Record<string, any> | null | undefined): stri
)
}
/** 处方笺「处方编号」:处方 sn,缺省 visit_no */
export function rxPrescriptionSnText(data: Record<string, any> | null | undefined): string {
if (!data) return '—'
const sn = String(data.sn ?? '').trim()
if (sn) return sn
const vn = String(data.visit_no ?? '').trim()
if (vn) return vn
return '—'
}
/** 流转编号(挂号):预约/挂号主键 */
export function rxAppointmentFlowText(data: Record<string, any> | null | undefined): string {
if (!data) return '—'
const aid = Number(data.appointment_id)
if (Number.isFinite(aid) && aid > 0) return String(aid)
return '—'
}
/** 业务订单号(从打开详情的订单行合并,便于笺面核对) */
export function rxBusinessOrderNoText(data: Record<string, any> | null | undefined): string {
if (!data) return ''
return String(data.order_no ?? '').trim()
}
export function rxRecipientText(data: Record<string, any> | null | undefined): string {
if (!data) return '—'
if (data.recipient_text) return data.recipient_text
@@ -2649,7 +2649,42 @@ async function handleDownloadSlip() {
windowWidth: node.scrollWidth,
windowHeight: node.scrollHeight,
width: node.scrollWidth,
height: node.scrollHeight
height: node.scrollHeight,
/** 克隆节点上再兜一层:加高行高,避免药材/底部文字被切边 */
onclone: (_doc, cloned) => {
if (!(cloned instanceof HTMLElement)) return
cloned.querySelectorAll<HTMLElement>('.rx-notice, .rx-notice-text, .rx-notice-meta').forEach((el) => {
el.style.lineHeight = '1.95'
el.style.overflow = 'visible'
})
cloned.querySelectorAll<HTMLElement>('.rx-notice').forEach((el) => {
el.style.padding = '10px 12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-info-cell, .rx-info-cell .rx-key, .rx-info-cell .rx-val').forEach((el) => {
el.style.lineHeight = '1.95'
el.style.overflow = 'visible'
})
cloned.querySelectorAll<HTMLElement>('.rx-info-cell').forEach((el) => {
el.style.padding = '10px 12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-herb-cell').forEach((el) => {
el.style.lineHeight = '2.5'
})
cloned.querySelectorAll<HTMLElement>('.rx-herbs').forEach((el) => {
el.style.rowGap = '12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-text').forEach((el) => {
el.style.lineHeight = '2.1'
})
cloned.querySelectorAll<HTMLElement>('.rx-bot-meta-val, .rx-hospital-line, .rx-bot-cell').forEach((el) => {
el.style.overflow = 'visible'
el.style.lineHeight = '1.85'
})
cloned.querySelectorAll<HTMLElement>('.rx-hospital-row').forEach((el) => {
el.style.paddingTop = '12px'
el.style.paddingBottom = '14px'
})
}
})
const imgData = canvas.toDataURL('image/jpeg', 0.95)
const pdf = new jsPDF({ unit: 'mm', format: 'a4', orientation: 'portrait' })
@@ -1770,6 +1770,17 @@
</div>
</template>
<div v-loading="prescriptionViewLoading" class="rx-wrap">
<!-- 药房联 / 处方联切换 -->
<el-tabs
v-if="prescriptionViewData"
v-model="prescriptionTabType"
class="mx-4 mt-2"
style="margin-bottom: 0; z-index: 1; position: relative"
>
<el-tab-pane label="药房联" name="internal" />
<el-tab-pane label="处方联" name="user" />
</el-tabs>
<!-- 状态条不进入打印/导出范围 -->
<div class="rx-statusbar" v-if="prescriptionViewData">
<el-tag
@@ -1822,6 +1833,9 @@
v-if="prescriptionViewData"
ref="rxSlipRef"
:data="prescriptionViewData"
:variant="prescriptionTabType"
:aux-library-name="slipAuxLibraryName"
:title="SLIP_TITLE"
/>
<!-- 审核痕迹不进入导出 -->
@@ -4466,6 +4480,8 @@ const prescriptionViewLoading = ref(false)
const prescriptionViewData = ref<any>(null)
/** 药房联辅方标题:处方库中该辅方模板的 prescription_name(导入或药材匹配解析) */
const slipAuxLibraryName = ref('')
/** 查看处方联次:药房联 / 处方联 */
const prescriptionTabType = ref<'internal' | 'user'>('internal')
const rxSlipRef = ref<InstanceType<typeof PrescriptionSlip> | null>(null)
const prescriptionSlipExporting = ref(false)
@@ -4864,6 +4880,7 @@ async function openPrescriptionView(row: any) {
prescriptionViewLoading.value = true
prescriptionViewData.value = null
slipAuxLibraryName.value = ''
prescriptionTabType.value = 'internal'
try {
const res: any = await prescriptionDetail({ id: row.prescription_id })
@@ -4961,7 +4978,42 @@ async function capturePrescriptionSlipCanvas(): Promise<HTMLCanvasElement> {
width: el.scrollWidth,
height: el.scrollHeight,
ignoreElements: (node: Element) =>
node instanceof HTMLElement && node.classList.contains('cf-slip-no-export')
node instanceof HTMLElement && node.classList.contains('cf-slip-no-export'),
/** 克隆节点上再兜一层:加高行高,避免药材/底部文字被切边 */
onclone: (_doc, cloned) => {
if (!(cloned instanceof HTMLElement)) return
cloned.querySelectorAll<HTMLElement>('.rx-notice, .rx-notice-text, .rx-notice-meta').forEach((node) => {
node.style.lineHeight = '1.95'
node.style.overflow = 'visible'
})
cloned.querySelectorAll<HTMLElement>('.rx-notice').forEach((node) => {
node.style.padding = '10px 12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-info-cell, .rx-info-cell .rx-key, .rx-info-cell .rx-val').forEach((node) => {
node.style.lineHeight = '1.95'
node.style.overflow = 'visible'
})
cloned.querySelectorAll<HTMLElement>('.rx-info-cell').forEach((node) => {
node.style.padding = '10px 12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-herb-cell').forEach((node) => {
node.style.lineHeight = '2.5'
})
cloned.querySelectorAll<HTMLElement>('.rx-herbs').forEach((node) => {
node.style.rowGap = '12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-text').forEach((node) => {
node.style.lineHeight = '2.1'
})
cloned.querySelectorAll<HTMLElement>('.rx-bot-meta-val, .rx-hospital-line, .rx-bot-cell').forEach((node) => {
node.style.overflow = 'visible'
node.style.lineHeight = '1.85'
})
cloned.querySelectorAll<HTMLElement>('.rx-hospital-row').forEach((node) => {
node.style.paddingTop = '12px'
node.style.paddingBottom = '14px'
})
}
})
}
@@ -2342,6 +2342,17 @@
</div>
</template>
<div v-loading="prescriptionViewLoading" class="rx-wrap">
<!-- 药房联 / 处方联切换 -->
<el-tabs
v-if="prescriptionViewData"
v-model="prescriptionTabType"
class="mx-4 mt-2"
style="margin-bottom: 0; z-index: 1; position: relative"
>
<el-tab-pane label="药房联" name="internal" />
<el-tab-pane label="处方联" name="user" />
</el-tabs>
<!-- 状态条不进入打印/导出范围 -->
<div class="rx-statusbar" v-if="prescriptionViewData">
<el-tag
@@ -2394,6 +2405,8 @@
v-if="prescriptionViewData"
ref="rxSlipRef"
:data="prescriptionViewData"
:variant="prescriptionTabType"
:title="SLIP_TITLE"
/>
<!-- 审核痕迹不进入导出 -->
@@ -5296,6 +5309,8 @@ const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄
const prescriptionViewVisible = ref(false)
const prescriptionViewLoading = ref(false)
const prescriptionViewData = ref<any>(null)
/** 查看处方联次:药房联 / 处方联 */
const prescriptionTabType = ref<'internal' | 'user'>('internal')
const rxSlipRef = ref<InstanceType<typeof PrescriptionSlip> | null>(null)
const prescriptionSlipExporting = ref(false)
@@ -5563,7 +5578,8 @@ async function openPrescriptionView(row: any) {
prescriptionViewVisible.value = true
prescriptionViewLoading.value = true
prescriptionViewData.value = null
prescriptionTabType.value = 'internal'
try {
const res: any = await prescriptionDetail({ id: row.prescription_id })
const base = res?.data ?? res ?? null
@@ -5657,7 +5673,42 @@ async function capturePrescriptionSlipCanvas(): Promise<HTMLCanvasElement> {
width: el.scrollWidth,
height: el.scrollHeight,
ignoreElements: (node: Element) =>
node instanceof HTMLElement && node.classList.contains('cf-slip-no-export')
node instanceof HTMLElement && node.classList.contains('cf-slip-no-export'),
/** 克隆节点上再兜一层:加高行高,避免药材/底部文字被切边 */
onclone: (_doc, cloned) => {
if (!(cloned instanceof HTMLElement)) return
cloned.querySelectorAll<HTMLElement>('.rx-notice, .rx-notice-text, .rx-notice-meta').forEach((node) => {
node.style.lineHeight = '1.95'
node.style.overflow = 'visible'
})
cloned.querySelectorAll<HTMLElement>('.rx-notice').forEach((node) => {
node.style.padding = '10px 12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-info-cell, .rx-info-cell .rx-key, .rx-info-cell .rx-val').forEach((node) => {
node.style.lineHeight = '1.95'
node.style.overflow = 'visible'
})
cloned.querySelectorAll<HTMLElement>('.rx-info-cell').forEach((node) => {
node.style.padding = '10px 12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-herb-cell').forEach((node) => {
node.style.lineHeight = '2.5'
})
cloned.querySelectorAll<HTMLElement>('.rx-herbs').forEach((node) => {
node.style.rowGap = '12px'
})
cloned.querySelectorAll<HTMLElement>('.rx-text').forEach((node) => {
node.style.lineHeight = '2.1'
})
cloned.querySelectorAll<HTMLElement>('.rx-bot-meta-val, .rx-hospital-line, .rx-bot-cell').forEach((node) => {
node.style.overflow = 'visible'
node.style.lineHeight = '1.85'
})
cloned.querySelectorAll<HTMLElement>('.rx-hospital-row').forEach((node) => {
node.style.paddingTop = '12px'
node.style.paddingBottom = '14px'
})
}
})
}
+107 -81
View File
@@ -103,40 +103,40 @@
</article>
</section>
<section class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门订单量占比</h2>
<p>订单创建人归属排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="item in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div>
<strong>{{ formatNumber(item.value) }} </strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
<section v-if="showRankings" class="ranking-grid">
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}订单量占比排名</h2>
<p>{{ rankingSubject }}归属统计排除取消拒收及退款</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.orders.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, totalOrderValue) }" /></div>
<strong><span>{{ formatNumber(item.value) }} </span><small>{{ formatShare(item.value, totalOrderValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
</article>
<article class="panel ranking-panel">
<div class="panel-heading">
<div>
<h2>部门金额占比</h2>
<p>仅统计未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="item in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name">{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div>
<strong>{{ formatMoney(item.value) }}</strong>
</div>
</div>
<div class="panel-heading">
<div>
<h2>{{ rankingSubject }}金额占比排名</h2>
<p>{{ rankingSubject }}归属统计仅含未取消未拒收且未退款的有效金额</p>
</div>
<span>单位</span>
</div>
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
<span class="bar-name" :title="item.name"><b>{{ index + 1 }}</b>{{ item.name }}</span>
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, totalAmountValue) }" /></div>
<strong><span>{{ formatMoney(item.value) }}</span><small>{{ formatShare(item.value, totalAmountValue) }}</small></strong>
</div>
</div>
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
</article>
</section>
@@ -145,7 +145,7 @@
<div class="panel-heading panel-heading--table">
<div>
<h2>明细数据列表</h2>
<p>展开部门可查看人员明细加粉=总进线=区间有效加粉按员工+客户去重须会话同意剔除已删客户与继承客户挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
<p>展开部门可查看人员明细加粉=总进线=区间有效加粉按员工+客户去重须会话同意剔除已删客户继承客户扫一扫/搜手机号/名片分享添加及区间前已加过的重加挂号=已支付且实收低于 10 元的订单预约=有效预约记录开口率=开口/加粉挂号率=挂号/加粉面诊率=面诊/挂号看挂号后流失预约率=面诊/预约看预约后未面诊面诊接诊率=接诊诊单/面诊接诊率=接诊诊单/总进线</p>
</div>
<span>{{ dashboard.rows.length }} 个顶层节点</span>
</div>
@@ -156,7 +156,7 @@
default-expand-all
class="detail-table"
>
<el-table-column prop="name" label="部门 / 人员" min-width="280" fixed="left">
<el-table-column prop="name" label="部门 / 人员" min-width="220" fixed="left">
<template #default="{ row }">
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
{{ row.name }}
@@ -178,34 +178,34 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
<el-table-column label="接诊金额" min-width="120" align="right">
<el-table-column prop="add_fans_count" label="加粉" min-width="72" align="right" />
<el-table-column prop="total_open_count" label="开口" min-width="72" align="right" />
<el-table-column prop="paid_appointment_count" label="挂号" min-width="72" align="right" />
<el-table-column prop="appointment_total_count" label="预约" min-width="72" align="right" />
<el-table-column prop="interview_count" label="面诊" min-width="72" align="right" />
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="88" align="right" />
<el-table-column label="接诊金额" min-width="104" align="right">
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
</el-table-column>
<el-table-column label="开口率" min-width="96" align="right">
<el-table-column label="开口率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
</el-table-column>
<el-table-column label="挂号率" min-width="96" align="right">
<el-table-column label="挂号率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
</el-table-column>
<el-table-column label="面诊率" min-width="96" align="right">
<el-table-column label="面诊率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
</el-table-column>
<el-table-column label="预约率" min-width="96" align="right">
<el-table-column label="预约率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
</el-table-column>
<el-table-column label="面诊接诊率" min-width="116" align="right">
<el-table-column label="面诊接诊率" min-width="100" align="right">
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
</el-table-column>
<el-table-column label="接诊率" min-width="96" align="right">
<el-table-column label="接诊率" min-width="82" align="right">
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
</el-table-column>
<el-table-column label="ROI" min-width="86" align="right">
<el-table-column label="ROI" min-width="72" align="right">
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
</el-table-column>
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
@@ -291,9 +291,9 @@ type MediaChannelOption = {
const emptyDashboard = () => ({
meta: {
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden'
},
filters: {
departments: [] as any[],
@@ -330,7 +330,7 @@ const timeOptions = [
{ label: '自定义', value: 'custom' }
]
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: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
@@ -364,8 +364,13 @@ const mediaChannelGroups = computed(() => {
}
return Array.from(groups.values())
})
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
const rankingKind = computed(() => dashboard.meta.ranking_kind || (
Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group'
))
const showRankings = computed(() => rankingKind.value !== 'hidden')
const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组')
const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0))
const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0))
const targetChartOption = computed(() => ({
animationDuration: 450,
color: ['#0f9185', '#2f78df'],
@@ -447,9 +452,14 @@ function formatMoney(value: any) {
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatPercent(value: any) {
return `${Number(value || 0).toFixed(1)}%`
}
function formatPercent(value: any) {
return `${Number(value || 0).toFixed(1)}%`
}
function formatShare(value: any, total: number) {
if (total <= 0) return '0.0%'
return `${(Number(value || 0) / total * 100).toFixed(1)}%`
}
function nullablePercent(value: any) {
return value === null || value === undefined ? '未设置' : formatPercent(value)
@@ -464,10 +474,11 @@ function compactNumber(value: number) {
return String(Math.round(value))
}
function barWidth(value: any, maximum: number) {
if (maximum <= 0) return '0%'
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%`
}
function barWidth(value: any, total: number) {
const numericValue = Number(value || 0)
if (total <= 0 || numericValue <= 0) return '0%'
return `${Math.max(4, Math.min(100, numericValue / total * 100))}%`
}
function progressValue(value: any) {
return Math.max(0, Math.min(100, Number(value || 0)))
@@ -477,22 +488,29 @@ onMounted(loadDashboard)
</script>
<style scoped lang="scss">
.conversion-page {
display: grid;
gap: 14px;
min-height: 640px;
padding: 16px;
color: #172033;
background: #f4f6f8;
}
.conversion-page {
display: grid;
gap: 14px;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 640px;
padding: 16px;
overflow-x: hidden;
box-sizing: border-box;
color: #172033;
background: #f4f6f8;
}
.page-heading,
.filter-strip,
.panel,
.metric-card {
border: 1px solid #dfe5ec;
background: #fff;
}
.metric-card {
min-width: 0;
box-sizing: border-box;
border: 1px solid #dfe5ec;
background: #fff;
}
.page-heading {
display: flex;
@@ -566,7 +584,7 @@ onMounted(loadDashboard)
}
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.panel { padding: 16px; border-radius: 10px; }
.panel { min-width: 0; max-width: 100%; padding: 16px; border-radius: 10px; box-sizing: border-box; }
.panel-heading {
display: flex;
@@ -580,18 +598,26 @@ onMounted(loadDashboard)
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
}
.bar-list { display: grid; gap: 13px; }
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-list { display: grid; gap: 13px; max-height: 340px; overflow-y: auto; padding-right: 4px; }
.bar-row { display: grid; grid-template-columns: 130px minmax(80px, 1fr) 116px; align-items: center; gap: 10px; }
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.bar-name b { display: inline-block; width: 20px; margin-right: 7px; color: #98a2b3; font-size: 11px; font-weight: 650; text-align: center; }
.bar-row > strong { display: flex; align-items: baseline; justify-content: flex-end; gap: 7px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.bar-row > strong small { color: #8a95a5; font-size: 10px; font-weight: 500; }
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
.bar-track i.is-teal { background: #15998d; }
.bar-track i.is-blue { background: #307bdf; }
.detail-panel { padding-bottom: 10px; }
.detail-table {
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
.detail-panel { padding-bottom: 10px; overflow: hidden; }
.detail-table {
width: 100%;
max-width: 100%;
min-width: 0;
:deep(.el-table__inner-wrapper),
:deep(.el-scrollbar) { max-width: 100%; }
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
strong.is-parent { color: #172033; font-weight: 700; }
@@ -632,7 +658,7 @@ onMounted(loadDashboard)
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
.employee-select, .dept-select, .channel-select { width: 100%; }
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
.bar-row { grid-template-columns: 100px minmax(70px, 1fr) 96px; }
.target-summary { grid-template-columns: 1fr; }
}
</style>
@@ -321,6 +321,7 @@ const query = reactive<FirstVisitDoctorDashboardParams>({
const customDateRange = ref<string[]>([])
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '昨天', value: 'yesterday' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' },
{ label: '自定义', value: 'custom' }
@@ -1,92 +1,100 @@
<template>
<section class="embedded-panel" v-loading="loading">
<div class="panel-toolbar">
<div>
<h2>医生排班</h2>
<p>查看近 7 日出诊医生及可约时段与医生排班管理同口径</p>
</div>
<div class="toolbar-actions">
<el-button
:icon="Refresh"
:loading="refreshing"
:disabled="!selectedDoctorId || !form.date"
@click="handleRefreshSlots"
>
刷新时段
</el-button>
</div>
</div>
<el-form :model="form" label-width="88px" class="paiban-form">
<el-form-item label="选择医生">
<div class="doctor-list">
<el-radio-group v-model="selectedDoctorId" @change="handleDoctorChange">
<el-radio
v-for="doctor in doctorList"
:key="doctor.id"
:value="doctor.id"
class="doctor-radio"
>
{{ doctor.name }}
</el-radio>
</el-radio-group>
<el-empty
v-if="!loading && doctorList.length === 0"
description="暂无可用医生"
:image-size="64"
/>
<div class="paiban-container" v-loading="loading">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>医生排班管理</span>
<el-button
text
type="primary"
size="small"
:loading="refreshing"
:disabled="!selectedDoctorId || !form.date"
@click="handleRefreshSlots"
>
<template #icon>
<Refresh />
</template>
刷新
</el-button>
</div>
</el-form-item>
</template>
<el-form-item label="排班时间">
<el-empty v-if="!selectedDoctorId" description="请先选择医生" :image-size="72" />
<el-empty
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
description="该医生暂无排班"
:image-size="72"
/>
<div v-else class="paiban-time-container">
<div class="date-selector">
<el-button
v-for="dateOption in dateOptions"
:key="dateOption.date"
:type="form.date === dateOption.date ? 'primary' : ''"
class="date-button"
@click="selectDate(dateOption.date)"
>
{{ dateOption.label }}
</el-button>
</div>
<div v-if="form.date" class="time-slots-container">
<div class="time-slots-grid">
<div
v-for="slot in filteredTimeSlots"
:key="slot.time"
class="time-slot-item"
:class="{
available: slot.available,
unavailable: !slot.available,
selected: form.selectedTime === slot.time
}"
@click="selectTimeSlot(slot)"
<el-form :model="form" label-width="100px" class="paiban-form">
<!-- 选择医生 -->
<el-form-item label="选择医生:">
<div class="doctor-list">
<el-radio-group v-model="selectedDoctorId" @change="handleDoctorChange">
<el-radio
v-for="doctor in doctorList"
:key="doctor.id"
:value="doctor.id"
class="doctor-radio"
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
{{ doctor.name }}
</el-radio>
</el-radio-group>
</div>
</el-form-item>
<!-- 排班时间 -->
<el-form-item label="排班时间:">
<!-- 未选择医生提示 -->
<el-empty
v-if="!selectedDoctorId"
description="请先选择医生"
:image-size="80"
/>
<!-- 医生无排班提示 -->
<el-empty
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
description="该医生暂无排班"
:image-size="80"
/>
<!-- 有排班时显示日期和时间段 -->
<div v-else class="paiban-time-container">
<!-- 日期选择 -->
<div class="date-selector">
<el-button
v-for="dateOption in dateOptions"
:key="dateOption.date"
:type="form.date === dateOption.date ? 'primary' : ''"
class="date-button"
@click="selectDate(dateOption.date)"
>
{{ dateOption.label }}
</el-button>
</div>
<!-- 时间段区域 -->
<div v-if="form.date" class="time-slots-container">
<!-- 时间段网格 -->
<div class="time-slots-grid">
<div
v-for="slot in filteredTimeSlots"
:key="slot.time"
class="time-slot-item"
:class="{
available: slot.available,
unavailable: !slot.available,
selected: form.selectedTime === slot.time
}"
@click="selectTimeSlot(slot)"
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
</div>
</div>
</div>
</div>
<el-empty
v-if="filteredTimeSlots.length === 0"
description="当前日期暂无可展示时段"
:image-size="64"
/>
</div>
</div>
</el-form-item>
</el-form>
</section>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
@@ -164,17 +172,21 @@ const filteredTimeSlots = computed(() => {
const slotDateTime = dayjs(`${form.date} ${slot.time}`)
const isPast = slotDateTime.isBefore(now) || slotDateTime.isSame(now, 'minute')
if (isPast) {
return { ...slot, available: false }
return {
...slot,
available: false
}
}
return slot
})
})
async function loadDoctors() {
const loadDoctors = async () => {
try {
loading.value = true
const res = await getDoctors()
doctorList.value = res || []
if (doctorList.value.length > 0 && !selectedDoctorId.value) {
selectedDoctorId.value = doctorList.value[0].id
await handleDoctorChange()
@@ -187,25 +199,28 @@ async function loadDoctors() {
}
}
async function handleDoctorChange() {
const handleDoctorChange = async () => {
form.selectedTime = ''
form.date = ''
timeSlots.value = []
doctorRosterDates.value = []
if (selectedDoctorId.value) {
await loadDoctorRoster()
}
}
async function loadDoctorRoster() {
const loadDoctorRoster = async () => {
if (!selectedDoctorId.value) {
return
}
try {
loading.value = true
const startDate = dayjs().format('YYYY-MM-DD')
const endDate = dayjs().add(6, 'day').format('YYYY-MM-DD')
const res = await rosterLists({
doctor_id: selectedDoctorId.value,
start_date: startDate,
@@ -216,6 +231,7 @@ async function loadDoctorRoster() {
if (res?.lists && res.lists.length > 0) {
doctorRosterDates.value = [...new Set(res.lists.map((item: any) => item.date))] as string[]
doctorRosterDates.value.sort()
await nextTick()
if (doctorRosterDates.value.length > 0) {
const today = dayjs().format('YYYY-MM-DD')
@@ -237,7 +253,7 @@ async function loadDoctorRoster() {
}
}
function selectDate(date: string) {
const selectDate = (date: string) => {
form.date = date
form.selectedTime = ''
if (selectedDoctorId.value) {
@@ -245,16 +261,19 @@ function selectDate(date: string) {
}
}
async function loadTimeSlots(silent = false) {
const loadTimeSlots = async (silent = false) => {
if (!selectedDoctorId.value || !form.date) {
return
}
if (isLoadingSlots) {
console.log('正在加载中,跳过本次请求')
return
}
try {
isLoadingSlots = true
if (!silent) {
loading.value = true
}
@@ -285,7 +304,7 @@ async function loadTimeSlots(silent = false) {
}
}
function selectTimeSlot(slot: TimeSlot) {
const selectTimeSlot = (slot: TimeSlot) => {
if (!slot.available) {
feedback.msgWarning('该时间段不可预约')
return
@@ -293,10 +312,11 @@ function selectTimeSlot(slot: TimeSlot) {
form.selectedTime = slot.time
}
async function handleRefreshSlots() {
const handleRefreshSlots = async () => {
if (!selectedDoctorId.value || !form.date) {
return
}
if (isLoadingSlots) {
feedback.msgWarning('正在刷新中,请稍候')
return
@@ -306,13 +326,16 @@ async function handleRefreshSlots() {
refreshing.value = true
const previousSelection = form.selectedTime
form.selectedTime = ''
await loadTimeSlots()
if (previousSelection) {
const slot = timeSlots.value.find((s) => s.time === previousSelection)
if (slot?.available) {
if (slot && slot.available) {
form.selectedTime = previousSelection
}
}
feedback.msgSuccess('刷新成功')
} catch (error) {
console.error('刷新失败:', error)
@@ -322,8 +345,9 @@ async function handleRefreshSlots() {
}
}
function startAutoRefresh() {
const startAutoRefresh = () => {
stopAutoRefresh()
autoRefreshTimer = window.setInterval(() => {
if (selectedDoctorId.value && form.date) {
loadTimeSlots(true)
@@ -331,7 +355,7 @@ function startAutoRefresh() {
}, 5000)
}
function stopAutoRefresh() {
const stopAutoRefresh = () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
@@ -362,36 +386,16 @@ onUnmounted(() => {
</script>
<style scoped lang="scss">
.embedded-panel {
padding-top: 4px;
.paiban-container {
padding: 0;
}
.panel-toolbar {
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1f2a37;
}
p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
}
.paiban-form {
@@ -404,10 +408,14 @@ onUnmounted(() => {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
.doctor-radio {
margin-right: 0;
:deep(.el-radio__label) {
display: flex;
align-items: center;
}
}
}
@@ -419,7 +427,7 @@ onUnmounted(() => {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
margin-bottom: 20px;
.date-button {
min-width: 130px;
@@ -441,6 +449,19 @@ onUnmounted(() => {
padding: 16px;
}
.time-slots-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.time-slots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
@@ -467,7 +488,7 @@ onUnmounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 8px;
padding: 0px 8px;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
@@ -485,7 +506,7 @@ onUnmounted(() => {
.slot-status {
font-size: 12px;
color: #909399;
padding: 0 8px;
padding: 0px 8px;
border-radius: 4px;
background-color: #f4f4f5;
@@ -350,7 +350,7 @@ const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPa
const PaibanPanel = defineAsyncComponent(() => import('./components/PaibanPanel.vue'))
type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
type DateType = 'all' | 'yesterday' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
const router = useRouter()
const userStore = useUserStore()
@@ -401,6 +401,7 @@ const statusOptions: Array<{ label: string; value: StatusFilter }> = [
const dateOptions: Array<{ label: string; value: DateType }> = [
{ label: '全部时间', value: 'all' },
{ label: '昨天', value: 'yesterday' },
{ label: '今日预约', value: 'today' },
{ label: '明日', value: 'tomorrow' },
{ label: '后天', value: 'day_after' },
@@ -466,6 +467,9 @@ function selectDateType(type: DateType) {
if (type === 'all') {
formData.start_date = ''
formData.end_date = ''
} else if (type === 'yesterday') {
formData.start_date = today.subtract(1, 'day').format('YYYY-MM-DD')
formData.end_date = formData.start_date
} else if (type === 'today') {
formData.start_date = today.format('YYYY-MM-DD')
formData.end_date = formData.start_date
@@ -325,6 +325,7 @@ const dashboard = reactive(emptyDashboard())
const query = reactive<FirstVisitRegistrationStatsParams>({ time_type: 'today' })
const timeOptions = [
{ label: '今日', value: 'today' },
{ label: '昨天', value: 'yesterday' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' }
]
@@ -98,7 +98,7 @@
</div>
</article>
<article class="metric-card">
<article v-if="dashboard.scope.kind !== 'assistant'" class="metric-card">
<div class="metric-label-row">
<span>本人本月业绩</span>
<el-tooltip :content="dashboard.meta.commission_note" placement="top">
@@ -151,11 +151,11 @@
<div class="panel-header">
<div>
<h2>{{ dashboard.rankings.appointments.title }}</h2>
<p>{{ appointmentRankingSubtitle }}</p>
<p v-if="appointmentRankingSubtitle">{{ appointmentRankingSubtitle }}</p>
</div>
<div class="panel-header-actions">
<el-tree-select
v-if="dashboard.rankings.appointments.kind !== 'doctor'"
v-if="showRankingDeptSelect"
v-model="rankingDeptId"
:data="dashboard.filters.ranking_departments"
:props="departmentTreeProps"
@@ -164,7 +164,7 @@
clearable
filterable
default-expand-all
placeholder="全部可见部门"
placeholder="全部部门"
class="ranking-dept-select"
@change="handleRankingDeptChange"
/>
@@ -176,12 +176,16 @@
v-for="(item, index) in dashboard.rankings.appointments.items"
:key="`appointment-${item.id}-${index}`"
class="ranking-row"
:class="{ 'is-self': item.is_self }"
>
<span class="ranking-index" :class="{ 'is-top': index < 2 }">{{ index + 1 }}</span>
<span class="ranking-index" :class="{ 'is-top': index < 2, 'is-self': item.is_self }">{{ index + 1 }}</span>
<div class="ranking-main">
<div class="ranking-copy">
<strong>{{ item.name || '未命名成员' }}</strong>
<span>{{ formatNumber(item.count) }} 挂号</span>
<strong>
{{ item.name || '未命名成员' }}
<i v-if="item.is_self" class="ranking-self-tag"></i>
</strong>
<span>{{ formatNumber(item.count) }} {{ rankingCountUnit }}</span>
</div>
<div class="ranking-meter" aria-hidden="true">
<span :style="{ width: rankingWidth(item.count, appointmentRankingMax) }" />
@@ -190,14 +194,16 @@
<b class="ranking-value">{{ formatNumber(item.count) }}</b>
</div>
</div>
<el-empty v-else :image-size="72" description="当前数据范围内暂无挂号排行" />
<el-empty v-else :image-size="72" :description="`当前数据范围内暂无${rankingCountUnit}排行`" />
</article>
<article class="dashboard-panel ranking-panel">
<div class="panel-header">
<div>
<h2>{{ dashboard.rankings.performance.title }}</h2>
<p>{{ dashboard.rankings.performance.scope_label }}内今日计入业绩的业务订单</p>
<p>
{{ performanceRankingSubtitle }}
</p>
</div>
<span class="panel-meta">今日</span>
</div>
@@ -206,11 +212,15 @@
v-for="(item, index) in dashboard.rankings.performance.items"
:key="`performance-${item.id}-${index}`"
class="ranking-row"
:class="{ 'is-self': item.is_self }"
>
<span class="ranking-index" :class="{ 'is-top': index < 2 }">{{ index + 1 }}</span>
<span class="ranking-index" :class="{ 'is-top': index < 2, 'is-self': item.is_self }">{{ index + 1 }}</span>
<div class="ranking-main">
<div class="ranking-copy">
<strong>{{ item.name || '未命名部门' }}</strong>
<strong>
{{ item.name || (dashboard.rankings.performance.kind === 'person' ? '未命名成员' : '未命名部门') }}
<i v-if="item.is_self" class="ranking-self-tag"></i>
</strong>
<span>{{ formatNumber(item.count) }} 个诊单</span>
</div>
<div class="ranking-meter" aria-hidden="true">
@@ -335,6 +345,7 @@ interface RankingItem {
name: string
count: number
amount: number
is_self?: boolean
}
type ComparisonDirection = 'up' | 'down' | 'flat'
@@ -355,10 +366,12 @@ const emptyComparison = (): MetricComparison => ({
const createInitialDashboard = () => ({
scope: {
key: '',
kind: '',
scope_value: 4,
label: '',
is_limited: true,
viewer_name: '',
viewer_id: 0,
role_ids: [] as number[],
role_names: [] as string[],
department_names: [] as string[],
@@ -401,11 +414,13 @@ const createInitialDashboard = () => ({
appointments: {
title: '实时挂号排行',
kind: 'assistant',
metric: 'registration',
scope_label: '',
items: [] as RankingItem[],
},
performance: {
title: '今日部门业绩排行',
kind: 'dept',
scope_label: '',
items: [] as RankingItem[],
},
@@ -413,6 +428,7 @@ const createInitialDashboard = () => ({
filters: {
ranking_departments: [] as any[],
ranking_dept_id: 0,
ranking_selectable: true,
},
trend: {
date_range: [] as string[],
@@ -533,9 +549,34 @@ const currentDate = computed(() => new Intl.DateTimeFormat('zh-CN', {
weekday: 'short',
}).format(now.value))
const showRankingDeptSelect = computed(() => (
dashboard.filters.ranking_selectable !== false
&& dashboard.scope.kind !== 'group_leader'
&& dashboard.rankings.appointments.kind !== 'doctor'
&& dashboard.filters.ranking_departments.length > 0
))
const rankingCountUnit = computed(() => (
dashboard.rankings.appointments.metric === 'appointment' ? '预约' : '挂号'
))
const appointmentRankingSubtitle = computed(() => {
if (dashboard.rankings.appointments.metric === 'appointment') {
return ''
}
const actor = dashboard.rankings.appointments.kind === 'doctor' ? '医生' : '成员'
return `${dashboard.rankings.appointments.scope_label}${actor}已支付且实收低于 10 元的订单数`
const scopeLabel = dashboard.rankings.appointments.scope_label || '当前部门'
return `${scopeLabel}${actor}已支付且实收低于 10 元的订单数`
})
const performanceRankingSubtitle = computed(() => {
const scopeLabel = dashboard.rankings.performance.scope_label || '当前部门'
if (dashboard.rankings.performance.kind === 'person') {
return dashboard.scope.kind === 'group_leader'
? '本小组内今日计入业绩的个人订单'
: `${scopeLabel}内今日计入业绩的个人订单`
}
return `${scopeLabel}内今日计入业绩的业务订单`
})
const appointmentRankingMax = computed(() => Math.max(
@@ -656,7 +697,7 @@ const loadDashboard = async () => {
errorMessage.value = ''
try {
const res: any = await performanceDashboardOverview({
ranking_dept_id: rankingDeptId.value,
ranking_dept_id: loaded.value ? (rankingDeptId.value || 0) : undefined,
// 驾驶舱是实时数据,避免浏览器或反向代理复用旧的 GET 响应。
_t: Date.now(),
})
@@ -998,6 +1039,11 @@ onBeforeUnmount(() => {
padding: 7px 9px;
border-radius: 11px;
background: var(--dash-surface-muted);
&.is-self {
background: color-mix(in srgb, var(--dash-accent) 16%, var(--dash-surface));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dash-accent) 42%, transparent);
}
}
.ranking-index {
@@ -1017,6 +1063,24 @@ onBeforeUnmount(() => {
color: #fff;
background: var(--dash-accent);
}
&.is-self:not(.is-top) {
color: var(--dash-accent);
background: color-mix(in srgb, var(--dash-accent) 18%, var(--el-fill-color-light));
font-weight: 750;
}
}
.ranking-self-tag {
flex-shrink: 0;
padding: 0 5px;
border-radius: 6px;
color: #fff;
font-size: 10px;
font-style: normal;
font-weight: 700;
line-height: 18px;
background: var(--dash-accent);
}
.ranking-main {
@@ -1032,6 +1096,9 @@ onBeforeUnmount(() => {
margin-bottom: 5px;
strong {
display: inline-flex;
align-items: center;
gap: 6px;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+408
View File
@@ -0,0 +1,408 @@
# APP 接诊台队列第二行 Python `dict` 泄漏审计
审计日期:2026-08-14
审计范围:`server` 列表 API → Python API client / repository / model normalize → `reception.py` 队列卡片
操作边界:只读诊断;未修改任何业务代码或测试代码,仅新增本报告。
## 0. Trellis 指令检查
仓库根目录 `D:\web\zyt` 下不存在 `.trellis/`,因此没有可读取的 `.trellis/workflow.md``.trellis/spec/` 或任务上下文。本审计已按根目录 `AGENTS.md` 的现有约束执行。
## 1. 结论
根因已经确定,不是 Qt 的渲染问题,也不是 JSON 解码问题,而是一个“对象字段被误当成文本别名”的类型边界错误:
1. 当前服务端 `doctor.appointment/lists` 使用 `->with('diagnosis')`,所以每条挂号记录的 `diagnosis` 字段实际是一个完整的关联诊单对象(JSON object / Python `dict`),缺失关联时则可能为 `null`;它不是诊断名称字符串。
2. `RemoteDoctorRepository.list_appointments()` 通过 `PageResult.from_payload(..., Appointment.from_dict)` 做 normalize。`Appointment.from_dict()` 没有诊断摘要字段,只把完整原始行保存在 `Appointment.raw`
3. `get_value()` 对 dataclass 上不存在的字段会回退到 `raw`,因此 `first_value(record, ..., "diagnosis")` 会取出那个 `dict`
4. `QueueRow` 把该值放进 `str(part).strip()`Python 按字典 `repr` 生成 `"{'id': ..., ...}"`,随后直接传给 `QLabel`。这正是用户看到的第二行文本。
触发点位于当前工作区尚未提交的接诊台视觉改造:旧版队列第二行只展示预约时间,不读取 `diagnosis`;当前改造在 `QueueRow` 中新增了 `"diagnosis"` 这个兜底别名,从而首次暴露服务端一直存在的关联对象。
**直接修复不能只是换成 `display_text()`。** `display_text()` 对容器同样执行 `str(value)`,仍会显示 Python 字典。也不应把整个嵌套 `diagnosis` 合并进 appointment,因为两层都有 `id``patient_id``status` 等不同语义字段,会污染挂号状态和三个 ID 的权威口径。
## 2. 完整数据链路
### 2.1 服务端列表实际返回嵌套对象
入口和响应封装:
| 文件 / 函数 | 当前行号 | 事实 |
|---|---:|---|
| `server/app/adminapi/controller/doctor/AppointmentController.php::lists()` | 62-65 | `doctor.appointment/lists` 交给 `AppointmentLists`。 |
| `server/app/common/service/JsonService.php::dataLists()` | 120-147 | HTTP envelope 的 `data``{lists, count, page_no, page_size, extend}`。 |
| `server/app/adminapi/lists/doctor/AppointmentLists.php::lists()` | 162-369 | 构造并序列化每一条挂号记录。 |
决定 `diagnosis` 类型的代码:
- `AppointmentLists.php:213-218`:查询从 `Appointment::alias('a')->with('diagnosis')` 开始;同时 join `tcm_diagnosis u`,只把患者、医生、医助、`diagnosis_id` 等少数字段平铺到顶层。
- `AppointmentLists.php:261-267`:模型 `select()->toArray()`;未限制字段的关联模型随主记录一起转成数组。
- `server/app/common/model/doctor/Appointment.php:99-102``diagnosis()``belongsTo(Diagnosis::class, 'patient_id', 'id')`。因此 appointment 表里的 `patient_id` 实际指向诊单 ID,而不是诊单对象中的真实患者 ID。
- `server/app/adminapi/logic/doctor/AppointmentLogic.php:623-642` 也明确记录:`appointment.patient_id == tcm_diagnosis.id`
- `server/app/common/model/tcm/Diagnosis.php:26-39`:关联对象对应 `tcm_diagnosis` 模型。
- `server/sql/tcm_diagnosis.sql:2-25`:基础 schema 中诊单至少包含 `id`、真实 `patient_id``patient_name``diagnosis_type``syndrome_type``symptoms``remark` 等字段。不同部署的后续列可能更多,但容器类型不变。
按照当前代码生成的响应形态如下(字段删减,仅表达类型和 ID 语义):
```json
{
"code": 1,
"data": {
"lists": [
{
"id": 101,
"patient_id": 501,
"diagnosis_id": 501,
"patient_name": "张三",
"appointment_time": "09:00",
"status": 1,
"diagnosis": {
"id": 501,
"patient_id": 301,
"patient_name": "张三",
"diagnosis_type": "follow_up",
"syndrome_type": "...",
"symptoms": "口干"
}
}
],
"count": 1,
"page_no": 1,
"page_size": 15,
"extend": {}
}
}
```
这里的关键合同是:
- 顶层 `diagnosis_id`:诊单 ID
- 顶层 `patient_id`:历史命名,当前也存诊单 ID
- `diagnosis.id`:诊单 ID
- `diagnosis.patient_id`:真实患者 ID
- `diagnosis`object 或 null,不应作为字符串渲染。
本次没有调用线上接口或读取生产数据库;“实际字段形态”依据当前 server 查询、关系定义、模型序列化和 schema 静态确认。容器类型由 `with('diagnosis')` 确定,不依赖具体数据内容。
### 2.2 API client 解 envelope,但不改变行字段
- `app/src/doctor_workstation/services/api_client.py::ApiClient._unwrap()`344-382:校验 envelope,在 `code == 1` 时直接返回 `envelope['data']`
- 所以 repository 收到的是 `{lists, count, ...}`,列表行里的嵌套 `diagnosis` 仍为 Python `dict`
### 2.3 Repository / model normalize 保留嵌套对象到 `raw`
- `app/src/doctor_workstation/services/repository.py::RemoteDoctorRepository.list_appointments()`879-909:请求 `doctor.appointment/lists`,再调用 `PageResult.from_payload(payload, Appointment.from_dict, ...)`
- `app/src/doctor_workstation/core/models.py::PageResult.from_payload()`804-865:在 840 行逐条调用 parser。
- `app/src/doctor_workstation/core/models.py::Appointment.from_dict()`213-257:只 normalize 顶层基本字段;没有 `clinical_diagnosis``diagnosis_name``disease_name``disease_course` dataclass 字段;256 行执行 `raw=dict(source)`,完整保留嵌套 relation。
- `app/src/doctor_workstation/ui/widgets.py::get_value()`,53-71:对象属性不存在时,66-67 行回退到对象的 `raw`
- `app/src/doctor_workstation/ui/widgets.py::first_value()`74-81:只排除 `None` 和空字符串,不排除 Mapping、Sequence 或其他不可展示容器。
因此 normalize 后的真实 Python 形态是:
```python
Appointment(
id=101,
patient_id=501,
diagnosis_id=501,
# 没有 canonical diagnosis summary 字段
raw={
# ...
"diagnosis": {"id": 501, "patient_id": 301, "symptoms": "口干"}
},
)
```
### 2.4 `QueueRow` 把 Mapping 转成 Python 文本
- `app/src/doctor_workstation/ui/pages/reception.py::ReceptionPage._apply_queue()`1473-1519`page_items()` 取出 `Appointment`,并为每条记录创建 `QueueRow(record)`
- `app/src/doctor_workstation/ui/pages/reception.py::QueueRow.__init__()`567-574:按 `clinical_diagnosis → diagnosis_name → disease_name → diagnosis` 取第一个非空值。
- 前三个字段在当前 server 顶层没有,`diagnosis` 则通过 `get_value()``raw` 回退命中关联 `dict`
- 同函数 586-590`str(part).strip()` 对该 dict 生成 Python repr。
- 591 行:repr 被送入 `QLabel`,没有任何类型检查。
- `app/src/doctor_workstation/ui/widgets.py::display_text()`,84-91:即使改用此函数,91 行仍是 `str(value)`,所以不是修复。
相邻的 `ReceptionPage._render_identity()``reception.py:1783-1809` 也有“候选值 → `str(part)`”模式。它当前处理的是详情响应中的 diagnosis mapping 内部字段,不会必然触发本问题,但建议复用同一个 scalar-only helper,避免未来某个详情别名变成 object/list 时再次泄漏容器 repr。
## 3. 可重复证据
使用项目现有虚拟环境、`-B` 禁止生成 bytecode,执行了一个无网络、无文件写入的最小复现:
```python
row = Appointment.from_dict({
"id": 1,
"patient_name": "张三",
"appointment_time": "09:00",
"diagnosis": {"id": 8, "patient_name": "张三", "symptoms": "口干"},
})
widget = QueueRow(row)
```
当前代码输出:
```text
normalized_type= Appointment raw_diagnosis_type= dict
fallback_value= {'id': 8, 'patient_name': '张三', 'symptoms': '口干'}
rendered_subline= {'id': 8, 'patient_name': '张三', 'symptoms': '口干'}
```
这同时证明:
- API row 到 `Appointment` 的 normalize 已发生;
- dict 并未来自 Qt
- 卡片最终文本和 Python dict repr 完全相同。
## 4. 为什么现有测试没有发现
1. `app/tests/test_reception_parity_ui.py::test_queue_status_badge_is_not_clipped_in_narrow_panel()`,103-128,只断言状态徽标尺寸和位置;fixture 不含 `diagnosis`,也没有读取 `ReceptionQueueSubline`
2. 同文件队列分页/筛选 fixtures247-386)只给 `id/patient_name/status` 等平铺字段,未模拟 server 的 `diagnosis: {...}` relation。
3. `app/tests/test_mock_repository.py::test_tolerant_page_parsing_accepts_aliases_and_bad_rows()`,298-324,只覆盖简单别名和坏行;没有嵌套关系字段。
4. `app/tests/test_repository_parity.py::test_page_result_preserves_outer_and_nested_extend()`155-174,覆盖的是分页 envelope 嵌套,不是 row 内 relation 嵌套。
5. `app/tests/test_repository_parity.py::test_remote_reception_is_forcibly_scoped_to_today()`227-244,只断言请求 endpoint/参数,不断言返回 DTO 字段类型。
6. Demo appointments 在 `app/src/doctor_workstation/services/mock_repository.py:3153-3283` 不包含 `diagnosis` relation,因此视觉截图只会走时间 fallback,无法暴露生产响应问题。
## 5. 兼容旧 / 新响应的稳健提取规则
### 5.1 必须先区分“容器”和“可展示标量”
建议定义一个只接受 JSON scalar 的 helper
- 接受:非空 `str`;必要时接受 `int/float` 并转换成字符串;
- 拒绝:`Mapping`、list/tuple/set、bool、`None`、空白字符串;
- 绝不对未知容器调用 `str()`
- 如果产品以后明确支持多选诊断,应单独定义“纯字符串列表 join”合同,不能把任意 list/dict 通用字符串化。
### 5.2 诊断摘要优先级
兼容三类已知/合理响应:
1. **新/平铺 canonical**:顶层 `clinical_diagnosis`
2. **平铺历史别名**:顶层 `diagnosis_name``disease_name`
3. **当前 server relation**:若顶层 `diagnosis` 是 Mapping,只从其内部的 `clinical_diagnosis``diagnosis_name``disease_name`、标量 `diagnosis` 中选;
4. **更老的标量别名**:只有当顶层 `diagnosis` 本身是 scalar 时,才把它作为最后兜底;
5. 都没有可展示文本时,返回空字符串,让 UI 回退到预约时间。
推荐顺序可写成:
```text
top.clinical_diagnosis
→ top.diagnosis_name
→ top.disease_name
→ diagnosis_object.clinical_diagnosis
→ diagnosis_object.diagnosis_name
→ diagnosis_object.disease_name
→ diagnosis_object.diagnosis(仅 scalar
→ top.diagnosis(仅 scalar
→ ""
```
不要把 `diagnosis_type` 直接当临床诊断:它在 server 中是初诊/复诊等类型 code;也不要直接显示未经翻译的 `syndrome_type` code。若产品明确希望第二行显示证型,应由 server 提供 `syndrome_type_text` 或由客户端字典翻译后作为另一个明确字段,不能把整个 relation 当成兜底。
### 5.3 病程摘要优先级
同样对顶层和 relation 内部执行 scalar-only 查找:
```text
top.disease_course_text
→ top.disease_course
→ top.course_text
→ top.course
→ diagnosis_object.disease_course_text
→ diagnosis_object.disease_course
→ diagnosis_object.course_text
→ diagnosis_object.course
→ ""
```
### 5.4 最终渲染规则
- `diagnosis``course` 都有文本:`诊断 · 病程`
- 只有一个:只显示该项;
- 两者都没有:显示预约时间;
- 时间也没有:显示“时间待确认”;
- 无论输入如何,最终字符串都不得包含由容器 repr 产生的 `{...}` / `[...]`
## 6. 建议补丁
### 6.1 首选:model 边界 canonicalize + UI 最后一道类型保护
#### A. `core/models.py`
在基础 helper 附近(当前 21-81 行)增加 scalar-only 提取器:
```python
def _first_scalar_text(*values: object) -> str:
for value in values:
if isinstance(value, str):
text = value.strip()
if text:
return text
elif isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return ""
```
`Appointment`(当前 179-211 行)增加 canonical 字段:
```python
clinical_diagnosis: str = ""
disease_course: str = ""
```
`Appointment.from_dict()` 当前 217 行之后只选择性读取 relation**不要 merge 整个 nested mapping**
```python
source = _mapping(data)
diagnosis_value = source.get("diagnosis")
diagnosis = _mapping(diagnosis_value)
legacy_diagnosis = None if isinstance(diagnosis_value, Mapping) else diagnosis_value
clinical_diagnosis = _first_scalar_text(
source.get("clinical_diagnosis"),
source.get("diagnosis_name"),
source.get("disease_name"),
diagnosis.get("clinical_diagnosis"),
diagnosis.get("diagnosis_name"),
diagnosis.get("disease_name"),
diagnosis.get("diagnosis"),
legacy_diagnosis,
)
disease_course = _first_scalar_text(
source.get("disease_course_text"),
source.get("disease_course"),
source.get("course_text"),
source.get("course"),
diagnosis.get("disease_course_text"),
diagnosis.get("disease_course"),
diagnosis.get("course_text"),
diagnosis.get("course"),
)
```
随后赋给 dataclass 字段。可顺带在顶层 `diagnosis_id` 缺失时安全回退 `diagnosis.id`,但必须保持 appointment 的 `id/status/patient_id` 仍以顶层为权威。
#### B. `ui/pages/reception.py`
即使 model 已 canonicalize`QueueRow` 仍可能被测试仓库或其他 repository 直接传入 dict,因此 UI 应保留 scalar-only guard。最小安全改法不是简单删除 `"diagnosis"`,而是:
```python
def _display_scalar(value: object) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return ""
```
然后在 `QueueRow.__init__()` 当前 567-591 行:
```python
diagnosis = _display_scalar(
first_value(
record,
"clinical_diagnosis",
"diagnosis_name",
"disease_name",
default=None,
)
) or _display_scalar(get_value(record, "diagnosis", None))
course = _display_scalar(
first_value(
record,
"disease_course_text",
"disease_course",
"course_text",
"course",
default=None,
)
)
subline_parts = [part for part in (diagnosis, course) if part]
subline = QLabel(" · ".join(subline_parts) or display_text(time or "时间待确认"))
```
如果希望 `QueueRow` 本身也兼容未经 `Appointment.from_dict()` 的嵌套 raw dict,则把 5.2/5.3 的 relation 内部候选一起放进一个纯函数(例如 `_queue_summary_fields(record)`),并由 model/UI 共用或分别调用同一优先级。重点是 relation 容器永远不能进入 `QLabel`
建议同样把 `_render_identity()` 当前 1802-1805 行的 `str(part)` 改为这个 scalar-only helper,作为邻接防御。
### 6.2 不建议的修复
- **只改 `display_text(diagnosis)`**:仍会 `str(dict)`
- **只删掉 `"diagnosis"` 别名**:能止住当前服务端,但会丢掉历史 scalar `diagnosis` 兼容,也无法读取未来/其他部署的嵌套 canonical 文本。
- **`json.dumps(diagnosis)`**:只是把 Python repr 换成 JSON,仍然把内部对象和潜在隐私信息显示给用户。
- **把 relation 整体 merge 到 appointment**:会让 diagnosis 的 `id/patient_id/status` 覆盖挂号字段,破坏视频、完成接诊和选中一致性。
- **立即删除 server 的 `with('diagnosis')`**:可能影响已有管理端消费者;在没有完整 server contract 回归前不应作为 APP 热修。
### 6.3 可选的服务端长期收敛
长期可以让 `AppointmentLists` 明确返回队列所需的 scalar summary,例如 `clinical_diagnosis` / `disease_course_text` / 已翻译的 `syndrome_type_text`,并限制或移除列表里的完整 relation,以减少 payload 和 PII 面。但当前 schema 各部署并不完全一致,直接在 SQL field 中引用未必存在的列会造成查询失败,因此这应作为单独的 API contract 变更,不是本次桌面 APP 热修的前置条件。
## 7. 必需测试
### 7.1 Model / repository normalize 测试
建议放在 `app/tests/test_repository_parity.py`,直接通过 `PageResult.from_payload(..., Appointment.from_dict)` 覆盖真实路径:
1. relation object 内有 `clinical_diagnosis``disease_course`normalize 后得到 canonical 字符串,同时 `raw['diagnosis']` 仍保留原 dict。
2. 顶层 flattened canonical 字段优先于嵌套字段。
3. 顶层 legacy scalar `diagnosis` 可兼容。
4. `diagnosis` 只有无关 mapping 字段时,canonical 诊断为空,不出现 dict repr。
5. 顶层 `status=1/id=101/patient_id=501` 与 nested `status=0/id=501/patient_id=301` 同时存在时,appointment 权威字段不得被 nested 覆盖。
6. `diagnosis=null`、空 dict、字段为空白、错误的 list/dict 类型均不抛异常。
建议核心断言示例:
```python
assert appointment.clinical_diagnosis == "消渴"
assert appointment.disease_course == "2 年"
assert isinstance(appointment.raw["diagnosis"], dict)
assert appointment.id == 101
assert appointment.status == 1
assert appointment.patient_id == 501
```
### 7.2 QueueRow 渲染测试
建议放在 `app/tests/test_reception_parity_ui.py`,扩展当前 103 行附近的 `QueueRow` 测试;通过 `findChild(QLabel, 'ReceptionQueueSubline')` 直接断言:
| 输入 | 期望第二行 |
|---|---|
| flat `clinical_diagnosis='消渴'`, `disease_course_text='2 年'` | `消渴 · 2 年` |
| legacy scalar `diagnosis='消渴'`, `course='2 年'` | `消渴 · 2 年` |
| nested relation 内含 canonical 文本(经 `Appointment.from_dict` | `消渴 · 2 年` |
| `diagnosis={'id': 8, 'symptoms': '口干'}`,无摘要 | 回退预约时间 |
| `clinical_diagnosis={...}` / `course=[...]` | 回退预约时间,且不抛异常 |
| 所有字段缺失 | `时间待确认` |
每个 case 还应有通用安全断言:
```python
assert "{" not in subline.text()
assert "}" not in subline.text()
assert "[" not in subline.text()
assert "]" not in subline.text()
```
如果正常业务文本本身允许这些符号,则更精确地断言“不等于 `repr(payload['diagnosis'])`”并断言预期 fallback;不要只做脆弱的字符黑名单。
### 7.3 Server contract 测试(若修改 server
若后续调整 `AppointmentLists`PHP 侧应加入 endpoint/列表类 contract
- `diagnosis` 明确为 array|null,禁止在 contract 中宣称 string
- 新增的 queue summary 必须是 string|null
- count / scope / 日期过滤不受影响;
- relation 字段收窄或移除前,先盘点 admin 其他页面消费者。
## 8. 修复验收标准
1. 线上/current server 的 nested `diagnosis` response 不再把 `{...}` 显示在队列第二行。
2. 平铺 canonical、历史 scalar 和 nested canonical 三种形态均有确定输出。
3. 没有可展示诊断/病程时稳定回退预约时间,而不是空白或容器 repr。
4. `Appointment` 的挂号 `id/status/patient_id` 不被 nested diagnosis 覆盖。
5. 新增 model 与 QueueRow 测试通过;现有 same-day、分页、切换患者和状态徽标测试保持通过。
6. 不需要以修改 server 或数据库 schema 作为 APP 修复前提。
## 9. 最终判定
这是一个确定性的 P1 展示与数据边界缺陷:不会直接修改数据,但会把完整关联对象(其中可能包含手机号、身份证、病史等字段,取决于部署 schema)暴露在医生端 UI,并破坏卡片可读性。推荐用“repository/model selective normalize + UI scalar-only guard”双层修复;不要序列化对象,也不要 merge relation。
+203
View File
@@ -0,0 +1,203 @@
# 诊疗 / 病例 / 订单 / AI 报告子窗口蓝白参考审计
审计日期:2026-08-13
审计性质:只读;未修改业务源码、测试、脚本或既有 PNG。
审计边界:诊疗详情/编辑抽屉、独立病例只读页、诊断上下文中的业务订单详情、诊断 AI 报告。未审计或改动 `theme.py``widgets.py`、处方编辑/患者页/挂号页;AI 部分只看诊断报告模式,不扩展到处方业务。
## 1. 结论先行
1. **用户所指“蓝白参考”最接近的现有实图是 `artifacts/pixel_exact_v3/consultations.png`**1710×9202026-08-13 19:56)。它是当前最新、最完整的蓝白医生工作站视觉:浅蓝壳层、近白内容底、白色卡片、靛蓝主操作、冷灰蓝文字与边框。子窗口应从它取色,不应从旧 Diagnosis 深色图取色。
2. **子窗口结构不能统一成同一种 modal。** 现有正确结构分别是:诊疗编辑/viewOnly 为右侧 60% Drawer;独立病例为无 Tabs 的纵向滚动详情页;订单详情为右侧 80% readonly DrawerAI 报告为 920×760 的居中 Dialog。用户要求的“蓝白”应是视觉统一,不是破坏这些已经有测试与研究规格支撑的容器合同。
3. **`artifacts/diagnosis_visual/*.png` 的主诊疗图仍是深色旧产物,只能借布局,不能借色。** 例如 `diagnosis_edit_1440x900.png``diagnosis_viewonly_1440x900.png``diagnosis_readonly_1440x900.png``diagnosis_order_detail_drawer_1440x900.png` 均以 `#080B14/#101626/#151D31` 为主。它们与 19:56 的蓝白主壳不属于同一视觉版本。
4. **最接近当前浅色订单结构的实图是 `.pytest-tmp-ui-redesign-full/test_drawer_and_inline_player_0/order.png`**1100×7202026-08-13 17:53)。它准确显示了 20% 遮罩 + 80% 白色 Drawer、固定 Header/Body/Footer 和卡片层级,但色谱仍是上一轮灰白+青色(`#F5F7FB/#D8DEEA/#CFFAFE/#A5F3FC`),因此仅作订单布局参考。
5. **AI Dialog 已有一张新生成的 920×760 蓝白实图:`artifacts/subwindow_exact/prescription_ai_report_920x760.png`。** 它直接证明当前 AI 组件的 Header、snapshot、医疗警示、双模型 Tabs、两列报告、滚动区和 Footer 能按蓝白色板渲染;但画面是“AI 处方解释”模式,不是诊断 `DIAGNOSIS_AI_KIND` 的“AI 报告/完整病历”模式。诊断 AI 使用同一个 Dialog/QSS,故该图可作为外观强参考,仍不能替代诊断模式本身的验收图。`dialogs/prescription_ai.py` 当前在工作树中仍是未跟踪文件,故它是“当前工作树实现”,不是已有发布基线。
6. **当前工作树正在变化。** 审计期间 `dialogs/diagnosis.py` 的订单 QSS 已从灰白版本进一步改为蓝白版本(例如遮罩 `rgba(30,64,175,.18)`、面板 `#F6F9FE`、边框 `#DDE7FF`);随后 `diagnosis_drawer.py` 又加入 `_DIAGNOSIS_BLUE_REPLACEMENTS`,在不重写成熟选择器的前提下把旧青色/绿灰字面量映射到靛蓝/冷灰蓝。测试中的选中 chip 断言也已从 `#CFFAFE` 更新为 `#F0F2FF`。本报告以下约束以审计结束时的最新工作树为准,同时明确区分旧 PNG。
## 2. 证据优先级与可用方式
| 优先级 | 证据 | 用途 | 不可误用 |
|---|---|---|---|
| 1 | `artifacts/pixel_exact_v3/consultations.png` | 蓝白总色调、主/次文字、边框、内容底、主按钮 | 不是子窗口几何图,不能据此改变 Drawer 比例 |
| 2 | `artifacts/subwindow_exact/prescription_ai_report_920x760.png` | AI Dialog 蓝白实际渲染、阅读密度、滚动与 Footer | 是处方解释模式,不是诊断 AI 报告模式 |
| 3 | `src/doctor_workstation/ui/dialogs/prescription_ai.py``PRESCRIPTION_AI_QSS` | 已落地的蓝白 Dialog 色板、按钮、Tabs、阅读排版 | 文件未跟踪;不能当作已发布基线 |
| 4 | `.pytest-tmp-ui-redesign-full/test_drawer_and_inline_player_0/order.png` | 80% 订单 Drawer 的浅色结构和首屏信息密度 | 青色强调与黑色遮罩不是最终蓝白色值 |
| 5 | `artifacts/diagnosis_visual/diagnosis_edit_*.png``diagnosis_viewonly_*.png` | 60% 诊疗 Drawer、Header/Tabs/Body/Footer、滚动与窄宽布局 | 深色旧主题不能复用 |
| 6 | `artifacts/diagnosis_visual/diagnosis_readonly_*.png` | 独立病例纵向流、4/3 列病例密度、异常值层级 | 深色旧主题不能复用 |
| 7 | `artifacts/diagnosis_visual/diagnosis_order_detail_drawer_1440x900.png` 与两个 1024×640 状态图 | 80% 比例、金额五卡、处方/收款首屏、固定 Footer | 深色旧主题不能复用;文件名 `640x540` 实际为 1024×640 |
| 8 | `research/diagnosis_detail_visual_spec.md``diagnosis_final_visual_gate.md` | 后台结构事实、间距、字段顺序、状态、历史验收 | 旧门禁“PASS”只证明当时深色截图结构完整,不代表符合本轮蓝白参考 |
### 2.1 实图像素色谱
`pixel_exact_v3/consultations.png` 每 2 px 采样得到的主要实色:
| 角色 | 参考实色 | 说明 |
|---|---|---|
| 页面/内容底 | `#FCFDFE` | 最大面积;子窗口滚动内容的首选底色 |
| 主卡片/浮层 | `#FFFFFF` | 表格、表单、Header、Footer、信息卡 |
| 壳层浅蓝 | `#EEF3FD` | 适合 overlay 外的壳层或非常浅的背景层,不宜给所有内卡重复使用 |
| 次级填充 | `#F7F9FE``#F2F6FE` | 输入只读态、筛选块、提示块、轻卡底 |
| 主边框 | `#E2E7F4` | 参考图中卡片与分隔线的高频精确色 |
| 主色 | `#5265F6` | 参考图主按钮/选中态的高频精确色 |
| 主标题 | `#15224A` | 参考图高频深靛文字 |
| 正文 | `#3F4E75` | 正文/表格主内容 |
| 次文字 | `#7481A3` | 标签、说明、占位与元信息 |
| 危险 | `#F15B67`(实图) | 取消/危险语义;业务详情可继续用更稳的 `#C43E55` 文本 |
AI 报告现有色板与参考图极近:背景 `#F7F9FE`,主色 `#5761F4`hover `#6871F6`pressed/链接 `#4D57D8`,浅主色 `#F0F2FF`,边框 `#DCE3F2`,标题 `#17203F`,正文 `#37415E`,次文字 `#78849D`。两套主色只差 5 个 RGB 量级;**若追求实图像素统一,统一到 `#5265F6`;若追求最小改动,可把 AI 色板整套作为子窗口局部 token,但不得继续混入青色 `#0891B2/#0E7490/#CFFAFE/#A5F3FC`。**
## 3. 全部子窗口共同约束
### 3.1 色与表面
- 外层/滚动区:`#FCFDFE` 或需要轻微分层时 `#F7F9FE`
- Header、Footer、卡片、表格主体:`#FFFFFF`
- 一般边框/分隔:`1px #E2E7F4`;强调边框可用 `#DCE3F2``#DDE7FF`,但同一控件不要混用三种。
- 主操作、选中 Tab、focus`#5265F6`hover 可用 `#6871F6`pressed/深色链接 `#4D57D8`;浅背景 `#F0F2FF`,浅边框 `#D8DCFF`
- 标题/数据主值:`#15224A`(现有 AI 的 `#17203F` 可作为近似);正文 `#3F4E75`label/meta `#7481A3`
- 成功/提醒/危险仍保留业务语义色,不要全部染成蓝:成功 `#16876C``#16A34A`;提醒 `#9A6813`;危险 `#C43E55` 或异常指标 `#DC2626`
- 遮罩只负责层级,不变成黑墙:推荐订单当前工作树的 `rgba(30,64,175,.18)`;诊疗 Drawer 可略深但保持蓝灰透明。旧 `rgba(8,11,20,.78)` 明显不符合参考。
### 3.2 字体、圆角、密度
- 字体栈:`Microsoft YaHei UI`, `PingFang SC`, `Noto Sans CJK SC`, sans-serif。不要为普通正文引入另一套拉丁字体;病例编号/时间可使用等宽数字。
- 正文/控件 13 px;字段 label、提示与 meta 1112 px;卡片标题 1415 pxDrawer 标题 1819 pxAI 报告标题 20 px。
- 4 px 间距基线;常用 8/12/16/20/24 px。不要产生 5、13、17、21 等无依据的主布局间距。
- 控件/普通按钮高 34 px;诊疗 Footer 主按钮高 40 px;紧凑 close 为 32×32;诊疗 Tab 高 42 pxAI Tab 高 36 px。
- 内控件/按钮圆角 7–8 px;字段卡/分区 910 pxHero 12 px;独立只读大卡 14 px。999 px 只用于真正的状态 pill/选择 chip,不要给所有按钮胶囊化。
- 表格状态必须使用小型 Tag/pill,不得整格铺色。表头宜 `#F7F9FE/#F8FAFF`,主体白底,行/列分隔 `#E2E7F4`;金额右对齐,状态与操作位置保持现有合同。
- hover、pressed、disabled、focus 必须可辨;focus 使用主色边界/外环,不能因改蓝白而删除键盘焦点。
## 4. 诊疗编辑 / viewOnly Drawer
### 4.1 必须保持的几何结构
- `DiagnosisDialog` 外层仍覆盖 owner,右侧 panel RTL 贴边;桌面宽度为 owner 的 **60%**1024×640 时 614 px1440×900 时 864 px。
- 窗口宽 `<=768` 时 panel 全宽;当前 Dialog 最小 760×520、默认 1024×640。不要改成固定居中 880×680 modal。
- 三段分离:Header、可横向滚动 Tabs + 独立滚动 Body、固定 Footer。Footer 不得放到 ScrollArea 尾部。
- Header 内边距 `16px 13px`,横向 gap 10;标题行内部 gap 10,副标题与标题垂直 gap 4close 32×32。
- Tabs:单 Tab 最小高 42,水平 padding 14;横向 overflow 用 4 px 细滚动条,隐藏原生盒状左右箭头;激活条使用主色,建议 2–3 px。
- Basic Body`20px 16px 20px 20px`(左/上/右/下)内边距;分区纵向 gap 12;一行两字段时 gap 16;窄模式纵向 gap 12。
- Footer`20px 14px 20px 18px` 内边距,按钮 gap 12,白底、上边 `#E2E7F4`,主按钮 40 px 高。
### 4.2 应改成的视觉
- Panel/Header/Footer/Body 从旧深靛或当前青绿色调统一到 §3 色板:白色 Header/Footer、近白蓝 Body,主色 `#5265F6`
- `diagnosis_drawer.py` 的基础 QSS 字面量仍大量是青色 `#0891B2/#0E7490/#22D3EE/#CFFAFE/#A5F3FC` 与绿灰文字 `#134E4A/#2A6B64/#5B7A76`,但当前工作树已通过 `_DIAGNOSIS_BLUE_REPLACEMENTS` 在运行时成组映射为靛蓝/冷灰蓝。这个方向正确;最终检查重点应变为:映射是否覆盖所有 scoped QSS、QPainter 和 inline style,且没有选择器优先级让旧色漏出。
- 推荐映射:
| 当前 Diagnosis 色 | 蓝白目标 |
|---|---|
| `#0891B2`, `#0E7490` | `#5265F6` / pressed `#4D57D8` |
| `#22D3EE` | `#6871F6` 或 focus `#5265F6` |
| `#CFFAFE` | `#F0F2FF` |
| `#A5F3FC` | `#D8DCFF` |
| `#F5F8F7`, `#F6F6F6` | `#FCFDFE` / `#F7F9FE` |
| `#D5E5E2`, `#D9DEDA`, `#E2EBE8` | `#E2E7F4` / `#DCE3F2` |
| `#134E4A`, `#2A6B64` | `#15224A` / `#3F4E75` |
| `#5B7A76`, `#66736D` | `#7481A3` |
- mode badge、锁定 warning、成功/失败保存状态保留语义色;不要把 warning 也涂成主蓝。
- 当前表单代码为中宽两列、字段 label 固定 100 px`content_w < 520` 才堆叠。研究规格中的后台 label 160 px 与当前 614 px 两列桌面实现存在冲突;**本轮蓝白适配不应贸然把 100 改成 160**,否则 1024×640 会失去已测试的两列无裁切合同。若未来要追后台 160 px,需单独重做栅格,不属于纯视觉换肤。
## 5. 独立病例 / 病历只读页
本节“病例”按当前 `DiagnosisDialog` 的 standalone readonly + `CaseGrid` 理解;不进入处方历史详情实现。
### 5.1 必须保持的布局
- 独立只读页是纵向 ScrollArea,**没有 Tabs**;页面内容四边 16 px、卡片间 gap 16。
- 顶部 Hero 左右可换行;宽度 `<900` 时右侧患者摘要落到下一行。Hero 内边距 `16px 12px`,内部 gap 12,圆角 12。
- 患者信息大卡内边距 18、纵向 gap 14;内部患者 Hero 内边距 `18px 16px`、纵向 gap 3。
- 病例卡 `CaseGrid` 内边距 18、纵向 gap 14;分组之间 dashed 分隔;网格横向 16、纵向 8。
- 宽屏病例分组保持既有列数:基本信息/生命体征/主诉 4 列,现病史与其他病史 3 列,既往史与补充意见整行。异常高压/低压/血糖继续用红色、700 字重和上箭头。
- 病例 label/value 的视觉尺度保持 1212.5 pxlabel `#7481A3`value `#15224A/#1F2937`,空值使用更浅的灰蓝。
### 5.2 蓝白外观
- 页面底 `#FCFDFE`;Hero 可使用研究规格已有的浅蓝渐变 `#F5F8FF → #EEF3FF`,边框 `#DDE7FF`;不要使用深色整页。
- 通用只读卡白底、`1px #E6EBF2`、14 px 圆角;卡片标题 15/700,左侧 3×16 px 主蓝标记。
- 当前工作树在患者信息卡标题行新增了“AI 报告”按钮。位置应固定在标题行右侧;使用 secondary 样式(字 `#4D57D8`、底 `#F0F2FF`、边 `#D8DCFF`、34 px 高、7 px 圆角),避免与“保存/生成”级主动作争抢。
- `CaseGrid` 已有少量蓝灰 inline 色(subtitle `#7886AA`、divider `#D8DEEE`),方向正确,但应收敛到统一的 `#7481A3/#E2E7F4`,避免同一页出现多套近似边框。
## 6. 业务订单详情 Drawer
### 6.1 必须保持的结构与尺寸
- `OrderDetailDrawer` 覆盖 owner,右侧 panel 固定 **80%**1024 owner 为约 819 px1440 owner 为 1152 px;左侧 20% 为 scrim。
- Header/Body/Footer 三段独立;Body 单独纵向滚动,Footer 始终可见。
- Header 当前内边距 `20px 15px 18px 15px`gap 12;标题栈 gap 4;标题 19 pxmeta 12 px;右侧依次是只读 badge、状态 Tag、关闭。
- Body 当前内边距 `18px 16px 18px 22px`,区块 gap 14。区块内边距 `15px 14px 15px 16px`,gap 11;字段/金额卡网格 gap 8。
- 金额概览首行 5 等分卡;值 18/700。信息字段为 3 列,物流元信息 2 列。收款表最小高 145,按行数增长但最大 280。
- 内容顺序必须保持:金额概览 → 处方详情 → 收款记录 → 履约与收货 → 物流轨迹 → 操作日志。readonly 隐藏收款方式等敏感/可编辑内容,不能为了“清爽”删掉已规定的只读信息层级。
- Footer 当前内边距 `18px 10px`;左侧数据来源说明,右侧关闭。
### 6.2 应匹配的蓝白细节
- 当前工作树 `_ORDER_DETAIL_QSS` 已基本走在正确方向:scrim `rgba(30,64,175,.18)`、Drawer/Scroll `#F6F9FE`、Header/Footer 白、强边 `#DDE7FF`、section `#FFFFFF/#E6EBF2`、字段底 `#F8FAFF`、空态 dashed `#C9D8F2`。这套可保留。
- 仍需确保从 `DIAGNOSIS_QSS` 继承的通用按钮/Tag/表格不会把订单局部重新染成青色。订单局部关闭按钮使用 secondary 蓝白;状态 Tag 保留绿/黄/红业务语义。
- 时间轴左线 `#93B4F4` 是合理的浅主蓝;标题 `#1F2937`、meta `#64748B` 可保留,若做全局像素统一再收敛到 `#15224A/#7481A3`
- 旧订单 PNG 中黑色 20% scrim 和深色主画面不能作为目标;浅色测试图的 80% 几何和卡片层级才是目标。
## 7. AI 报告 Dialog
### 7.1 当前实现已接近目标
- 居中 Dialog,默认 920×760,最小 720×560;不要改成右侧 Drawer,除非产品另行决定窗口范式。
- Root 内边距 `22px 18px`,纵向 gap 12。
- 标题 20/700,副标题 13;右侧可有状态 badge。
- 完整病历 snapshot:白底、`1px #DCE3F2`、10 px 圆角,内边距 `14px 12px`gap 12;左 label 固定 72 px。
- 医疗提示使用淡黄语义卡 `#FFF9EE/#F3DFB5`、9 px 圆角;不要为“全蓝白”抹掉警示语义。
- 两模型 Tabs 高 36、水平 padding 18selected 字 `#4D57D8`、底 `#F0F2FF`、2 px 主色下划线;Tab 内容白底、10 px 圆角。
- 报告 ScrollArea 白底;host 内边距 `4px 4px 12px 8px`gap 12。
- 核心判断 summary`#F0F2FF`、左 3 px `#5761F4`,内容内边距 `18px 16px`;结构化报告四格为 2 列,列 gap 28;编辑框最小高 280。
- Footer 右对齐;按钮 34 px 高、水平 padding 16、7 px 圆角;生成/保存为 primary,关闭/取消为普通或 secondary。
### 7.2 已有实图与仍缺的证据
- `artifacts/subwindow_exact/prescription_ai_report_920x760.png` 已显示一张质量足够的蓝白 AI Dialog:标题区、药材 snapshot、淡黄医疗警示、两模型状态 Tabs、核心判断浅蓝强调、2×2 报告栅格、垂直滚动和底部“关闭/重新生成”均完整,无深色或青色残留。它能证明共享 Dialog 外观已成立。
- 该图标题是“AI 处方解释”、snapshot 为“药材组合”,并非诊断模式的“AI 报告/完整病历”。诊断模式虽然复用同一个类和 QSS,仍需自己的实图验证文案高度、完整病历摘要换行和按钮标签。
- `tests/test_prescription_ai_ui.py` 只验证权限、文字、双模型数据、生成与编辑,没有截图、尺寸、主色、滚动和窄窗断言。
- 当前 `scripts/render_subwindow_exact.py` 的审计结束版本不再包含 AI render 分支,现有 AI PNG 的可重复生成链路不清晰;因此不能仅凭文件存在判可持续门禁。
- 后续视觉门禁至少需要:920×760 有报告态、720×560 空态/加载态、编辑态(含 280 px editor)、生成失败/旧报告回退态各一张;同时验证 Tabs、snapshot、warning、Footer 和纵向滚动无裁切。
## 8. 现有 tests / scripts / research 能保护什么
### 8.1 已有强结构约束
- `tests/test_diagnosis_drawer_visual.py`
- 诊疗 Drawer 60%、右贴边、全高、Footer 固定;1024/1440 两档。
- 独立 readonly 无 Tabs、内容 gap 16、无横向滚动。
- Tabs 横向滚动条 4 px,原生工具按钮不可见。
- 订单 Tab、病例/Notes/Daily 等真实组件可达,窗口 resize/reopen 与 owner 同步。
- `tests/test_diagnosis_order_video_visual.py`
- 订单 Drawer 为 80%,覆盖 ownerreadonly 属性与各信息区存在。
- 空字段必须显示明确空态,不能伪造 0;操作日志受权限保护。
- 生成的 order image 只要求 1100×720 且文件大于 10 KB。
- `research/diagnosis_detail_visual_spec.md`
- 明确三种诊疗视图的结构、4 px 间距基线、Header/Tabs/Footer、病例 4/3 列、订单 80% Drawer 和业务字段顺序。
- `research/diagnosis_final_visual_gate.md`
- 证明旧版 60%/80% 几何、fixed Footer、窄窗滚动和所有详情区曾完整入镜。
### 8.2 当前视觉门禁缺口
1. Diagnosis tests 原先明确断言选中 chip 为青色 `#CFFAFE`;审计结束时当前工作树已把三处断言同步为 `#F0F2FF`。此项已在改动层关闭,但仍需最终测试运行证明未回归。
2. 订单的截图测试只看尺寸和文件体积,不校验 80% 边界位置、Header/Footer 色、主色、scrim 或卡片色;可能在视觉回退时继续通过。
3. AI 报告完全没有 PNG/像素门禁。
4. `scripts/render_diagnosis_detail_visual.py``render_diagnosis_order_video_visual.py` 能重建诊疗与订单实图;当前工作树还新增了未跟踪的 `scripts/render_subwindow_exact.py`,聚焦生成诊疗 Drawer、订单 Drawer 和日常记录编辑器。`artifacts/subwindow_exact/prescription_ai_report_920x760.png` 虽已存在,但当前脚本版本不再覆盖它,且该图是处方模式;诊断 AI 的可重复 render 门禁仍缺失。`artifacts/diagnosis_visual` 仍是深色旧产物,蓝白实现完成后必须以新图验收,不可用旧图宣布通过。
5. 深色 replacement 表仍留在 `diagnosis_drawer.py` 作为未来暗色材料。当前注释说明默认 light 不执行它;后续实现不应删除未来暗色能力,也不能误把该 replacement 再无条件应用到默认模式。
## 9. 建议的实现优先级(仅供父任务使用)
1. **先完成并验证 Diagnosis scoped QSS 色板统一**:当前 replacement 表已经落地,下一步应验证青色/绿灰全部映射为蓝白 token,同时保持 60%/80%/窗口结构不动。
2. **再清理 inline style 漏点**:病例 subtitle/divider、banner 文本、订单 toolbar/meta 等应使用 objectName + 同一色板,避免局部仍冒出旧深色或青色。
3. **保留订单当前工作树的蓝白局部 QSS**,检查它与通用 `DIAGNOSIS_QSS` 的选择器优先级,避免按钮和表格被旧青色覆盖。
4. **以 AI 报告色板为一致性校准**,必要时把其主色从 `#5761F4` 微调到参考精确色 `#5265F6`;医疗 warning、成功、错误保留语义色。
5. **最后重渲并逐窗比较**:必须同时看 1024×640 与 1440×900 的诊疗/病例/订单,AI 看 920×760 与 720×560。旧深色 PNG 不再作为通过证据。
## 10. 最终可验收口径
- 诊疗:60% 右 Drawer,白 Header/Footer、近白蓝 Body、靛蓝 active/focus/primary,无青色残留;Tabs 与 Footer 不裁切。
- 病例:无 Tabs 的纵向白卡流,浅蓝 Hero,4/3 列病例仍可读;AI 报告 secondary 按钮位于患者信息卡标题行右侧。
- 订单:20% 淡蓝 scrim + 80% 白/浅蓝 Drawer;五金额卡、处方、收款、履约、物流、日志顺序完整;Footer 固定。
- AI 报告:920×760/720×560 都能完整显示标题、snapshot、医疗警示、双模型 Tabs、滚动报告与 Footer;主色与 `#5265F6` 同色系,无青色。
- 四窗共同:`#FCFDFE/#FFFFFF/#E2E7F4/#5265F6/#15224A/#7481A3` 形成稳定层级;34/40 px 控件节奏、7/10/12/14 px 圆角层级和 4 px 间距基线一致;语义色不被“全蓝化”。
@@ -12,20 +12,65 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.core.permissions import PermissionSet
from doctor_workstation.ui import apply_theme
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
from doctor_workstation.ui.dialogs.prescription import (
PrescriptionDetailDialog,
PrescriptionEditorDialog,
)
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
class VisualRepository:
def list_medicines(self, **_filters: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
report = {
"summary": "益气健脾为主,兼顾养阴安神,适合脾气不足、气阴两虚方向的复核参考。",
"possible_symptoms": ["乏力气短", "食少便溏", "睡眠不安"],
"main_indications": "气阴两虚、脾气不足所见的倦怠与纳差。",
"efficacy": ["益气健脾", "养阴生津", "宁心安神"],
"suitable_people": ["辨证属气阴两虚者", "需由医师结合四诊确认"],
"compatibility_analysis": "黄芪、党参、白术与茯苓协同补气健脾,麦冬、五味子兼顾养阴敛津,酸枣仁与远志用于宁心安神。",
"cautions": ["仍需核对过敏史与现用药", "症状变化时及时复诊"],
"disclaimer": "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。",
}
return {
"prescription_id": template_id,
"can_refresh": True,
"can_edit": True,
"reports": [
{
"report_id": 21,
"model_key": "qwen",
"model_label": "千问",
"model_name": "qwen3.6-35b",
"generated_at": "2026-08-13 10:32:00",
"report": report,
"is_stale": False,
"is_edited": True,
},
{
"report_id": 22,
"model_key": "openai",
"model_label": "OpenAI",
"model_name": "gpt-5.6-sol",
"generated_at": "2026-08-13 10:32:00",
"report": report,
"is_stale": False,
"is_edited": False,
},
],
}
def _seed() -> dict[str, Any]:
return {
"diagnosis_id": 501,
"id": 2501,
"appointment_id": 2501,
"prescription_no": "RX202608130021",
"patient_name": "林晓岚",
"phone": "13800138000",
"gender": 0,
@@ -57,6 +102,8 @@ def _seed() -> dict[str, Any]:
"prescription_name": "宁心辅方",
},
"doctor_name": "陈医生",
"audit_status": 1,
"create_time": "2026-08-13 10:26:00",
"herbs": [
{"medicine_id": 11, "name": "黄芪", "dosage": 15, "formula_type": "主方"},
{"medicine_id": 12, "name": "党参", "dosage": 12, "formula_type": "主方"},
@@ -78,11 +125,6 @@ def _make_editor(host: QWidget) -> PrescriptionEditorDialog:
current_user=SimpleNamespace(id=1, name="陈医生"),
parent=host,
)
# Diagnosis-detail callers apply their scoped sheet after construction. The
# prescription surface owns its own scoped rules, so this also verifies that
# real entry path rather than a renderer-only appearance.
editor.setObjectName("DiagnosisPrescriptionEditor")
editor.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS)
return editor
@@ -101,43 +143,59 @@ def render() -> list[Path]:
if families:
app.setFont(QFont(families[0], 9))
output = Path(__file__).resolve().parents[1] / "artifacts" / "diagnosis_visual"
output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
output.mkdir(parents=True, exist_ok=True)
rendered: list[Path] = []
for host_width, height in ((1440, 900), (1024, 768), (920, 780)):
host = QWidget()
host.resize(host_width, height)
host.show()
editor = _make_editor(host)
editor.show()
_settle(app)
editor.body_scroll.verticalScrollBar().setValue(0)
_settle(app, 3)
width = min(PrescriptionEditorDialog.DRAWER_WIDTH, host_width)
path = output / f"diagnosis_state_prescription_editor_{width}x{height}.png"
if not editor.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
editor.close()
host.close()
_settle(app, 2)
host = QWidget()
host.resize(1440, 900)
host.show()
editor = _make_editor(host)
editor.show()
_settle(app)
for index, state in ((1, "herbs"), (2, "usage"), (3, "signature")):
editor.tabs.setCurrentIndex(index)
_settle(app, 4)
path = output / f"diagnosis_state_prescription_editor_{state}_1200x900.png"
if not editor.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
editor.body_scroll.verticalScrollBar().setValue(0)
_settle(app, 4)
path = output / "prescription_editor_860x900.png"
if not editor.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
editor.close()
host.close()
detail = PrescriptionDetailDialog(
_seed(),
can_open_diagnosis=True,
can_open_orders=True,
)
detail.show()
_settle(app, 10)
path = output / "prescription_detail_920x780.png"
if not detail.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
detail.close()
_settle(app, 2)
ai_dialog = PrescriptionAiReportDialog(
VisualRepository(),
PermissionSet(["*", "tcm.prescriptionLibrary/editAiReport"]),
)
ai_dialog.open_for(
{
"id": 2501,
"prescription_name": "益气养阴安神方",
"formula_type": "主方",
"herbs": _seed()["herbs"],
}
)
ai_dialog.show()
_settle(app, 16)
path = output / "prescription_ai_report_920x760.png"
if not ai_dialog.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
ai_dialog.close()
_settle(app, 2)
return rendered
+78
View File
@@ -0,0 +1,78 @@
"""Render the 1710×920 reception AI acceptance artifact offscreen."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QPoint, QThreadPool
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
def main() -> int:
app = QApplication.instance() or QApplication([])
apply_theme(app)
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
if font_path.is_file():
QFontDatabase.addApplicationFont(str(font_path))
repository = DemoDoctorRepository()
session = repository.login("doctor", "doctor123")
window = ShellWindow(repository, session)
window.resize(1710, 920)
window.show()
if not window.navigate("reception"):
raise RuntimeError("reception navigation is unavailable")
for _index in range(4):
app.processEvents()
QThreadPool.globalInstance().waitForDone(10_000)
app.processEvents()
output = Path(__file__).resolve().parents[1] / "artifacts" / "reception_ai_exact" / "reception.png"
output.parent.mkdir(parents=True, exist_ok=True)
pixmap = window.grab()
if pixmap.size().width() != 1710 or pixmap.size().height() != 920:
raise RuntimeError(f"unexpected render size: {pixmap.size().width()}x{pixmap.size().height()}")
if not pixmap.save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
page = window.pages.get("reception")
if page is not None:
left = page.ai_analysis_card.geometry()
right = page.ai_assistant_card.geometry()
left_global = page.ai_analysis_card.mapTo(window, QPoint(0, 0))
right_global = page.ai_assistant_card.mapTo(window, QPoint(0, 0))
print(
"AI_CARDS",
left.x(),
left.y(),
left.width(),
left.height(),
right.x(),
right.y(),
right.width(),
right.height(),
page._ai_analysis_state,
)
print(
"AI_CARDS_GLOBAL",
left_global.x(),
left_global.y(),
right_global.x(),
right_global.y(),
)
print(output)
window.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,93 @@
"""Render the structured reception medication/case card for visual review."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool
from PySide6.QtWidgets import QApplication
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.ui.shell import ShellWindow
from doctor_workstation.ui.theme import apply_theme
def main() -> int:
app = QApplication.instance() or QApplication([])
apply_theme(app)
repository = DemoDoctorRepository()
session = repository.login("doctor", "doctor123")
window = ShellWindow(repository, session)
window.resize(1710, 920)
window.show()
if not window.navigate("reception"):
raise RuntimeError("reception navigation is unavailable")
for _index in range(4):
app.processEvents()
QThreadPool.globalInstance().waitForDone(10_000)
page = window.pages.get("reception")
if page is None:
raise RuntimeError("reception page was not created")
page.detail_stack.setCurrentIndex(1)
medication_index = next(
index
for index in range(page.detail_tabs.count())
if page.detail_tabs.tabText(index) == "用药记录"
)
page.detail_tabs.setCurrentIndex(medication_index)
page._render_case(
{"has_prescription": True},
{"age": 56, "gender": 1},
{
"diagnosis_date": "2026-08-14",
"diagnosis_type_text": "复诊",
"systolic": 148,
"diastolic": 92,
"blood_pressure_status": "偏高",
"fasting_blood_sugar": "6.8",
"fasting_blood_sugar_status": "偏高",
"clinical_diagnosis": "2 型糖尿病(血糖控制未达标),合并高血压与轻度脂肪肝",
"current_medications": [
"二甲双胍缓释片 0.5 g,早晚餐后各一次",
"格列吡嗪片 5 mg,早餐前一次",
],
"allergy_history_text": "青霉素过敏",
"chief_complaint": "口干口苦、多饮多汗,近期睡眠欠佳",
"present_illness": (
"近两周空腹血糖波动,伴头昏、腰膝酸软及夜间多尿;"
"未发生明确低血糖,服药依从性一般。"
),
"tongue": "舌红,苔黄腻",
"pulse": "脉弦滑",
"treatment_principle": "益气养阴、清热利湿,兼顾健脾化痰",
"diabetes_history_text": "10 年",
"past_history_text": "高血压 6 年、高脂血症 3 年",
"family_history_text": "父亲患 2 型糖尿病",
"sleep_condition_text": "多梦、入睡困难",
"diet_condition_text": "主食量偏多,晚餐较晚",
"remark": "重点核对降糖药剂量,复查肝肾功能,并关注夜间低血糖风险。",
},
)
for _index in range(6):
app.processEvents()
output = (
Path(__file__).resolve().parents[1]
/ "artifacts"
/ "reception_medication_case"
/ "reception_medication_case_1710x920.png"
)
output.parent.mkdir(parents=True, exist_ok=True)
if not window.grab().save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
print(output)
window.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+121
View File
@@ -0,0 +1,121 @@
"""Render focused blue-white diagnosis subwindow acceptance references."""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from PySide6.QtGui import QFont, QFontDatabase # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from render_diagnosis_detail_visual import ( # noqa: E402
ScreenshotRepository,
_run_immediately,
)
from render_diagnosis_order_video_visual import order_detail # noqa: E402
from doctor_workstation.core import PermissionSet # noqa: E402
from doctor_workstation.services import DemoDoctorRepository # noqa: E402
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog # noqa: E402
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module # noqa: E402
from doctor_workstation.ui.dialogs import prescription_ai as ai_module # noqa: E402
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog # noqa: E402
from doctor_workstation.ui.dialogs.prescription_ai import ( # noqa: E402
DIAGNOSIS_AI_KIND,
PrescriptionAiReportDialog,
)
def _save(widget: object, path: Path, app: QApplication) -> None:
widget.show()
for _ in range(8):
app.processEvents()
if not widget.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
def main() -> int:
output = ROOT / "artifacts" / "subwindow_exact"
output.mkdir(parents=True, exist_ok=True)
app = QApplication.instance() or QApplication([])
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))
diagnosis_module.run_async = _run_immediately
ai_module.run_async = _run_immediately
host = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"]))
host.resize(1280, 800)
host.open_for(501, editable=True)
_save(host, output / "diagnosis_edit_drawer_1280x800.png", app)
order = host._build_order_detail_dialog(order_detail(), 801)
_save(order, output / "diagnosis_order_detail_1280x800.png", app)
order.close()
host.close()
app.processEvents()
daily = DailyRecordEditorDialog(
"blood",
{
"id": 6101,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_time": "08:20",
"fasting_blood_sugar": 8.2,
"postprandial_blood_sugar": 12.4,
"systolic_pressure": 146,
"diastolic_pressure": 92,
"western_medicine": "二甲双胍",
"remark": "继续观察餐后波动。",
},
)
daily.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS)
daily.resize(680, 660)
_save(daily, output / "diagnosis_daily_editor_680x660.png", app)
daily.close()
app.processEvents()
ai_report = PrescriptionAiReportDialog(
DemoDoctorRepository(),
permissions=PermissionSet(["*"]),
kind=DIAGNOSIS_AI_KIND,
)
ai_report.open_for(
{
"id": 501,
"diagnosis_id": 501,
"patient_name": "林晓岚",
"diagnosis_date": "2026-08-10",
"diagnosis_type": "复诊",
"syndrome_type": "气阴两虚",
"chief_complaint": "口干乏力,餐后血糖波动",
"present_illness": "近两周睡眠改善,仍需记录空腹与餐后血糖。",
"clinical_diagnosis": "2 型糖尿病",
"treatment_principle": "益气养阴,兼顾饮食与运动管理",
"doctor_advice": "连续记录七日血糖并按时复诊",
}
)
ai_report.resize(920, 760)
_save(ai_report, output / "diagnosis_ai_report_920x760.png", app)
ai_report.close()
app.processEvents()
for path in sorted(output.glob("*.png")):
print(path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+176 -169
View File
@@ -9,7 +9,7 @@ import time
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtWidgets import (
QApplication,
@@ -43,71 +43,73 @@ from doctor_workstation.ui.widgets import (
from doctor_workstation.video import BackendMode, launch_video_call
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
LOGGER = logging.getLogger(__name__)
class _ChineseQtTranslator(QTranslator):
"""Guarantee Chinese labels for common Qt standard buttons.
Qt's packaged ``qtbase_zh_CN`` catalog remains the primary source for
framework text. This small fallback also keeps release builds localized
when a packager omits the optional ``.qm`` files.
"""
_BUTTON_TEXT = {
"OK": "确定",
"Open": "打开",
"Save": "保存",
"Save All": "全部保存",
"Cancel": "取消",
"Close": "关闭",
"Yes": "",
"Yes to All": "全部确认",
"No": "",
"No to All": "全部否定",
"Abort": "中止",
"Retry": "重试",
"Ignore": "忽略",
"Discard": "放弃",
"Help": "帮助",
"Apply": "应用",
"Reset": "重置",
"Restore Defaults": "恢复默认设置",
"Don't Save": "不保存",
}
def translate(
self,
context: str,
source_text: str,
disambiguation: str | None = None,
n: int = -1,
) -> str:
del context, disambiguation, n
return self._BUTTON_TEXT.get(source_text.replace("&", ""), "")
def _install_chinese_translations(application: QApplication) -> None:
"""Install Simplified Chinese Qt catalogs once for the whole process."""
if getattr(application, "_doctor_workstation_chinese_translators", None):
return
QLocale.setDefault(QLocale("zh_CN"))
translators: list[QTranslator] = []
translations_path = QLibraryInfo.path(
QLibraryInfo.LibraryPath.TranslationsPath
)
for catalog in ("qt_zh_CN", "qtbase_zh_CN"):
translator = QTranslator(application)
if translator.load(catalog, translations_path):
application.installTranslator(translator)
translators.append(translator)
fallback = _ChineseQtTranslator(application)
application.installTranslator(fallback)
translators.append(fallback)
application._doctor_workstation_chinese_translators = translators # type: ignore[attr-defined]
LOGGER = logging.getLogger(__name__)
class _ChineseQtTranslator(QTranslator):
"""Guarantee Chinese labels for common Qt standard buttons.
Qt's packaged ``qtbase_zh_CN`` catalog remains the primary source for
framework text. This small fallback also keeps release builds localized
when a packager omits the optional ``.qm`` files.
"""
_BUTTON_TEXT = {
"OK": "确定",
"Open": "打开",
"Save": "保存",
"Save All": "全部保存",
"Cancel": "取消",
"Close": "关闭",
"Yes": "",
"Yes to All": "全部确认",
"No": "",
"No to All": "全部否定",
"Abort": "中止",
"Retry": "重试",
"Ignore": "忽略",
"Discard": "放弃",
"Help": "帮助",
"Apply": "应用",
"Reset": "重置",
"Restore Defaults": "恢复默认设置",
"Don't Save": "不保存",
}
def translate(
self,
context: str,
source_text: str,
disambiguation: str | None = None,
n: int = -1,
) -> str | None:
del context, disambiguation, n
# Returning an empty string tells Qt that an unknown source string has
# a valid, deliberately empty translation. That also erased internal
# values such as QPageSize's "A4", producing zero-sized PDF pages.
# ``None`` delegates unknown text to Qt's installed catalog/source.
return self._BUTTON_TEXT.get(source_text.replace("&", ""))
def _install_chinese_translations(application: QApplication) -> None:
"""Install Simplified Chinese Qt catalogs once for the whole process."""
if getattr(application, "_doctor_workstation_chinese_translators", None):
return
QLocale.setDefault(QLocale("zh_CN"))
translators: list[QTranslator] = []
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
for catalog in ("qt_zh_CN", "qtbase_zh_CN"):
translator = QTranslator(application)
if translator.load(catalog, translations_path):
application.installTranslator(translator)
translators.append(translator)
fallback = _ChineseQtTranslator(application)
application.installTranslator(fallback)
translators.append(fallback)
application._doctor_workstation_chinese_translators = translators # type: ignore[attr-defined]
class _UnconfiguredRepository:
@@ -136,17 +138,20 @@ class DemoVideoDialog(QDialog):
self.setWindowTitle("视频面诊 · 演示模式")
self.setMinimumSize(760, 520)
self.resize(980, 660)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#0B1210;}"
"QLabel{color:#EAF2EE;}"
"QFrame#RemoteStage{background:#14211E;border:1px solid #2C403A;border-radius:18px;}"
"QFrame#LocalStage{background:#20312C;border:1px solid #3C554D;border-radius:14px;}"
"QPushButton{min-width:96px;min-height:42px;border-radius:21px;background:#253A34;"
"color:#F4F8F6;border:1px solid #3C554D;}"
"QPushButton:hover{background:#304A42;}"
"QPushButton#Hangup{background:#B94B44;border-color:#CF625B;}"
)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
root = QVBoxLayout(self)
root.setContentsMargins(22, 18, 22, 22)
@@ -157,7 +162,7 @@ class DemoVideoDialog(QDialog):
header.addWidget(title)
header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#91B9AC;font-size:12px;")
demo.setStyleSheet("color:#7886AA;font-size:12px;")
header.addWidget(demo)
self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;")
@@ -172,8 +177,8 @@ class DemoVideoDialog(QDialog):
avatar = QLabel((patient_name or "")[:1])
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar.setFixedSize(104, 104)
avatar.setStyleSheet(
"background:#DDF1EC;color:#0F6D64;border-radius:52px;font-size:42px;font-weight:700;"
avatar.setStyleSheet(
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
)
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
waiting = QLabel("等待患者接听…")
@@ -182,7 +187,7 @@ class DemoVideoDialog(QDialog):
stage_layout.addWidget(waiting)
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setStyleSheet("color:#80948D;font-size:12px;")
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
stage_layout.addWidget(hint)
stage_layout.addStretch(1)
@@ -192,7 +197,7 @@ class DemoVideoDialog(QDialog):
local_layout = QVBoxLayout(local)
local_label = QLabel("医生画面")
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
local_label.setStyleSheet("color:#A9BDB6;font-weight:600;")
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
local_layout.addWidget(local_label)
root.addWidget(stage, 1)
@@ -264,12 +269,12 @@ class ApplicationController(QObject):
def _show_login(self) -> None:
if self.login_window is None:
self.login_window = LoginWindow(
self._base_repository(),
self.config,
self.demo_repository,
credential_store=self.token_store,
)
self.login_window = LoginWindow(
self._base_repository(),
self.config,
self.demo_repository,
credential_store=self.token_store,
)
self.login_window.login_succeeded.connect(self._on_login_succeeded)
self.login_window.config_changed.connect(self._on_config_changed)
self.login_window.demo_mode_changed.connect(self._on_demo_mode_changed)
@@ -277,9 +282,9 @@ class ApplicationController(QObject):
else:
self.login_window.repository = self._base_repository()
self.login_window.config = self.config
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
self.login_window.restore_remembered_credentials()
if not self.login_window.demo_check.isChecked():
self.login_window.active_repository = self._base_repository()
self.login_window.restore_remembered_credentials()
self.login_window.show()
self.login_window.raise_()
self.login_window.activateWindow()
@@ -564,40 +569,40 @@ class ApplicationController(QObject):
if parent is None or self.current_repository is None:
return
patient_id = payload.get("patient_id")
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
open_im = str(payload.get("mode") or "video").lower() == "im"
diagnosis_id = payload.get("diagnosis_id")
patient_name = str(payload.get("patient_name") or "患者")
open_im = str(payload.get("mode") or "video").lower() == "im"
if patient_id in (None, "") or diagnosis_id in (None, ""):
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
return
call_key = str(diagnosis_id)
existing_call = self.video_calls.get(call_key)
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
qt_window = getattr(existing_call, "qt_window", None)
if qt_window is not None:
qt_window.show()
qt_window.raise_()
qt_window.activateWindow()
show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200)
return
if (
call_key in self.video_pending
or existing_call is not None
or call_key in self.demo_video_dialogs
):
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
return
closed_previous_im = False
if open_im:
for key, call in tuple(self.video_calls.items()):
if key == call_key or not getattr(call, "open_im", False):
continue
closed_previous_im = True
self.video_calls.pop(key, None)
with suppress(Exception):
call.close()
call_key = str(diagnosis_id)
existing_call = self.video_calls.get(call_key)
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
qt_window = getattr(existing_call, "qt_window", None)
if qt_window is not None:
qt_window.show()
qt_window.raise_()
qt_window.activateWindow()
show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200)
return
if (
call_key in self.video_pending
or existing_call is not None
or call_key in self.demo_video_dialogs
):
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
return
closed_previous_im = False
if open_im:
for key, call in tuple(self.video_calls.items()):
if key == call_key or not getattr(call, "open_im", False):
continue
closed_previous_im = True
self.video_calls.pop(key, None)
with suppress(Exception):
call.close()
if self.current_demo_mode:
dialog = DemoVideoDialog(patient_name, parent)
@@ -611,11 +616,11 @@ class ApplicationController(QObject):
dialog.show()
return
show_toast(
parent,
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
"info",
)
show_toast(
parent,
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
"info",
)
repository = self.current_repository
marker = object()
self.video_pending[call_key] = marker
@@ -626,35 +631,35 @@ class ApplicationController(QObject):
diagnosis_id=int(diagnosis_id),
)
def request_ticket() -> None:
if self.video_pending.get(call_key) is not marker:
return
run_async(
get_ticket,
on_success=lambda ticket: self._launch_video(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
open_im=open_im,
patient_name=patient_name,
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
parent,
error,
),
)
# Tencent IM may take a brief moment to release the previous browser
# connection. The admin version also has only one ChatDialog instance.
if closed_previous_im:
QTimer.singleShot(400, request_ticket)
else:
request_ticket()
def request_ticket() -> None:
if self.video_pending.get(call_key) is not marker:
return
run_async(
get_ticket,
on_success=lambda ticket: self._launch_video(
ticket,
diagnosis_id=diagnosis_id,
patient_id=patient_id,
repository=repository,
call_key=call_key,
marker=marker,
open_im=open_im,
patient_name=patient_name,
),
on_error=lambda error: self._video_ticket_error(
call_key,
marker,
parent,
error,
),
)
# Tencent IM may take a brief moment to release the previous browser
# connection. The admin version also has only one ChatDialog instance.
if closed_previous_im:
QTimer.singleShot(400, request_ticket)
else:
request_ticket()
def _video_ticket_error(
self,
@@ -681,11 +686,11 @@ class ApplicationController(QObject):
diagnosis_id: Any,
patient_id: Any,
repository: Any,
call_key: str,
marker: object,
open_im: bool = False,
patient_name: str = "患者",
) -> None:
call_key: str,
marker: object,
open_im: bool = False,
patient_name: str = "患者",
) -> None:
if self.video_pending.get(call_key) is not marker:
return
self.video_pending.pop(call_key, None)
@@ -708,11 +713,11 @@ class ApplicationController(QObject):
patient_id=patient_id,
backend_mode=mode,
local_dist=video_dist_path(),
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
open_im=open_im,
patient_name=patient_name,
)
remote_url=self.config.video_web_url or None,
logger=logging.getLogger("doctor_workstation.video"),
open_im=open_im,
patient_name=patient_name,
)
except Exception as error:
LOGGER.exception("video call could not be launched")
show_toast(
@@ -788,10 +793,12 @@ def _create_application(argv: list[str]) -> QApplication:
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
application = QApplication(argv)
_install_chinese_translations(application)
application.setApplicationName("甄养堂医生工作站")
application.setApplicationDisplayName("甄养堂医生工作站")
with suppress(AttributeError):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True)
application = QApplication(argv)
_install_chinese_translations(application)
application.setApplicationName("甄养堂医生工作站")
application.setApplicationDisplayName("甄养堂医生工作站")
application.setOrganizationName("ZhenYangTang")
application.setOrganizationDomain("zhenyangtang.com")
application.setQuitOnLastWindowClosed(True)
@@ -4,6 +4,7 @@ from .api_client import ApiClient
from .factory import build_repository
from .mock_repository import DEMO_PERMISSIONS, DemoDoctorRepository
from .repository import (
DIAGNOSIS_AI_PERMISSIONS,
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
AuditAction,
@@ -17,6 +18,7 @@ __all__ = [
"AuditAction",
"DEMO_PERMISSIONS",
"DemoDoctorRepository",
"DIAGNOSIS_AI_PERMISSIONS",
"DoctorRepository",
"KeyringLike",
"PRESCRIPTION_LIBRARY_PERMISSIONS",
@@ -111,10 +111,13 @@ class ApiClient:
params: Mapping[str, Any] | None = None,
*,
headers: Mapping[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
) -> Any:
"""Issue a GET request and return the unwrapped envelope data."""
return self.request("GET", endpoint, params=params, headers=headers)
return self.request(
"GET", endpoint, params=params, headers=headers, timeout=timeout
)
def post(
self,
@@ -123,13 +126,16 @@ class ApiClient:
*,
json: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
) -> Any:
"""Issue a non-retried JSON POST and return the unwrapped data."""
if payload is not None and json is not None:
raise ValueError("pass either payload or json, not both")
body = json if json is not None else payload
return self.request("POST", endpoint, json=body or {}, headers=headers)
return self.request(
"POST", endpoint, json=body or {}, headers=headers, timeout=timeout
)
def post_multipart(
self,
@@ -226,6 +232,7 @@ class ApiClient:
data: Mapping[str, Any] | None = None,
files: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | httpx.Timeout | None = None,
) -> Any:
"""Issue one API request with structured transport/envelope errors."""
@@ -245,6 +252,7 @@ class ApiClient:
)
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
request_timeout = self.timeout if timeout is None else timeout
for attempt in range(attempts):
try:
response = self._client.request(
@@ -255,7 +263,7 @@ class ApiClient:
data=dict(data) if verb == "POST" and data is not None else None,
files=dict(files) if files is not None else None,
headers=request_headers,
timeout=self.timeout,
timeout=request_timeout,
)
break
except httpx.TimeoutException as exc:
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
"""Compatibility module exporting the production doctor repository."""
from .repository import (
DIAGNOSIS_AI_PERMISSIONS,
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
AuditAction,
@@ -11,6 +12,7 @@ from .repository import (
__all__ = [
"AuditAction",
"DoctorRepository",
"DIAGNOSIS_AI_PERMISSIONS",
"PRESCRIPTION_LIBRARY_PERMISSIONS",
"PRESCRIPTION_PERMISSIONS",
"RemoteDoctorRepository",
+450 -176
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping, Sequence
from collections.abc import Mapping, Sequence
from contextlib import suppress
from datetime import date
from io import BytesIO
@@ -41,9 +41,21 @@ PRESCRIPTION_LIBRARY_PERMISSIONS: Final[dict[str, str]] = {
"read": "wcf.prescription/read",
"update": "wcf.prescription/edit",
"delete": "wcf.prescription/delete",
"ai_reports": "tcm.prescriptionLibrary/aiReports",
"generate_ai_reports": "tcm.prescriptionLibrary/generateAiReports",
"edit_ai_report": "tcm.prescriptionLibrary/editAiReport",
}
"""Canonical permissions used by the routed prescription-library view."""
DIAGNOSIS_AI_PERMISSIONS: Final[dict[str, str]] = {
"ai_reports": "tcm.diagnosis/aiReports",
"generate_ai_reports": "tcm.diagnosis/generateAiReports",
"edit_ai_report": "tcm.diagnosis/editAiReport",
"analysis": "tcm.diagnosis/aiAnalysis",
"assistant": "tcm.diagnosis/aiAssistant",
}
"""Canonical permissions used by reception and patient-profile AI reports."""
PRESCRIPTION_PERMISSIONS: Final[dict[str, str]] = {
"create": "cf.prescription/add",
"read": "cf.prescription/read",
@@ -140,6 +152,64 @@ class DoctorRepository(Protocol):
def delete_prescription_template(self, template_id: int) -> Any:
"""Delete a prescription-library record."""
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Return saved AI interpretation reports for one library template."""
def generate_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Regenerate every model report for one library template."""
def edit_prescription_template_ai_report(
self,
template_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Save a manually edited AI report for one library template."""
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Return saved AI reports for one diagnosis / patient profile."""
def generate_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Regenerate every model report for one diagnosis."""
def edit_diagnosis_ai_report(
self,
diagnosis_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Save a manually edited AI report for one diagnosis."""
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
) -> dict[str, Any]:
"""Ask the first-party diagnosis assistant; the server selects the model."""
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
*,
model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]:
"""Generate one model's structured reception analysis for a diagnosis."""
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Return every persisted AI diagnosis snapshot for one patient."""
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: Literal["qwen", "openai"],
) -> dict[str, Any]:
"""Append one model-specific AI diagnosis snapshot for one patient."""
def get_prescription(self, prescription_id: int) -> Prescription:
"""Return one issued prescription."""
@@ -619,38 +689,38 @@ class DoctorRepository(Protocol):
def end_call(self, diagnosis_id: int) -> Any:
"""End the active diagnosis call."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Start a transcript stored on one exact call record."""
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Idempotently persist completed transcript segments."""
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize and materialize the transcript text on a call record."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind a TRTC room to the active call."""
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Start a transcript stored on one exact call record."""
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Idempotently persist completed transcript segments."""
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize and materialize the transcript text on a call record."""
def my_self(self) -> Session:
"""Compatibility alias for :meth:`get_session`."""
@@ -1165,7 +1235,191 @@ class RemoteDoctorRepository:
return self.client.post("tcm.prescriptionLibrary/delete", {"id": template_id})
def list_medicines(
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Read persisted AI reports without triggering model generation."""
payload = _client_request(
self.client,
"get",
"tcm.prescriptionLibrary/aiReports",
{"id": template_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/aiReports"))
def generate_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
"""Regenerate the multi-model diagnosis report; not retried by the client."""
payload = _client_request(
self.client,
"post",
"tcm.prescriptionLibrary/generateAiReports",
{"id": template_id},
timeout=210.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/generateAiReports"))
def edit_prescription_template_ai_report(
self,
template_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Persist a manual edit of one saved model report."""
payload = _client_request(
self.client,
"post",
"tcm.prescriptionLibrary/editAiReport",
{"id": template_id, "report_id": report_id, "content": content},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.prescriptionLibrary/editAiReport"))
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Read persisted diagnosis AI reports without triggering model generation."""
payload = _client_request(
self.client,
"get",
"tcm.diagnosis/aiReports",
{"id": diagnosis_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiReports"))
def generate_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
"""Regenerate the multi-model diagnosis report; not retried by the client."""
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/generateAiReports",
{"id": diagnosis_id},
timeout=210.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/generateAiReports"))
def edit_diagnosis_ai_report(
self,
diagnosis_id: int,
*,
report_id: int,
content: str,
) -> dict[str, Any]:
"""Persist a manual edit of one saved diagnosis report."""
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/editAiReport",
{"id": diagnosis_id, "report_id": report_id, "content": content},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/editAiReport"))
def analyze_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
) -> dict[str, Any]:
"""Submit a diagnosis question to the first-party server assistant."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
clean_task = task.strip().lower() or "custom"
if clean_task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/aiAssistant",
{"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task},
# The upstream is allowed 90 seconds by server configuration. Keep a
# small transport buffer so the desktop can receive the server's own
# timeout response instead of racing it.
timeout=105.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiAssistant"))
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
*,
model: Literal["qwen", "openai"] = "qwen",
) -> dict[str, Any]:
"""Generate one model's structured analysis via the first-party API."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/aiAnalysis",
{"id": diagnosis_id, "model": clean_model},
timeout=105.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiAnalysis"))
def list_patient_ai_reports(self, patient_id: int) -> dict[str, Any]:
"""Read patient-level snapshots without triggering model generation."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
payload = _client_request(
self.client,
"get",
"tcm.diagnosis/patientAiReports",
{"patient_id": patient_id},
timeout=30.0,
)
return dict(_require_mapping(payload, "tcm.diagnosis/patientAiReports"))
def generate_patient_ai_report(
self,
patient_id: int,
*,
model: Literal["qwen", "openai"],
) -> dict[str, Any]:
"""Append one patient snapshot; the desktop never sends provider secrets."""
if patient_id <= 0:
raise ValueError("patient_id must be positive")
clean_model = str(model).strip().lower()
if clean_model not in {"qwen", "openai"}:
raise ValueError("model must be qwen or openai")
payload = _client_request(
self.client,
"post",
"tcm.diagnosis/generatePatientAiReport",
{"patient_id": patient_id, "model": clean_model},
timeout=105.0,
)
return dict(
_require_mapping(payload, "tcm.diagnosis/generatePatientAiReport")
)
def list_medicines(
self,
*,
name: str = "",
@@ -2201,157 +2455,157 @@ class RemoteDoctorRepository:
ticket.diagnosis_id = diagnosis_id
return ticket
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create the server-side call record before ringing participants."""
payload = self.client.post(
"tcm.diagnosis/startCall",
{
"diagnosis_id": diagnosis_id,
"patient_id": patient_id,
"call_type": call_type,
},
)
if not isinstance(payload, Mapping):
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id object", data=payload
)
raw_id = next(
(
payload[key]
for key in ("call_record_id", "callRecordId", "id")
if key in payload
),
None,
)
try:
if isinstance(raw_id, bool):
raise ValueError
call_record_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
) from exc
if call_record_id <= 0:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
)
return {"call_record_id": call_record_id}
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
"""Create the server-side call record before ringing participants."""
payload = self.client.post(
"tcm.diagnosis/startCall",
{
"diagnosis_id": diagnosis_id,
"patient_id": patient_id,
"call_type": call_type,
},
)
if not isinstance(payload, Mapping):
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id object", data=payload
)
raw_id = next(
(
payload[key]
for key in ("call_record_id", "callRecordId", "id")
if key in payload
),
None,
)
try:
if isinstance(raw_id, bool):
raise ValueError
call_record_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
) from exc
if call_record_id <= 0:
raise ApiProtocolError(
"tcm.diagnosis/startCall returned no valid call_record_id",
data=dict(payload),
)
return {"call_record_id": call_record_id}
def end_call(self, diagnosis_id: int) -> Any:
"""End the active call/recording associated with a diagnosis."""
return self.client.post("tcm.diagnosis/endCall", {"diagnosis_id": diagnosis_id})
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
"""Bind the actual TRTC room to the active call record."""
if not room_id.strip():
raise ValueError("room_id is required")
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _transcription_identity(
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
) -> dict[str, Any]:
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id is required")
clean_session = transcription_session_id.strip()
if not clean_session or len(clean_session) > 128:
raise ValueError("transcription_session_id must contain 1 to 128 characters")
return {
"diagnosis_id": diagnosis_id,
"call_record_id": call_record_id,
"transcription_session_id": clean_session,
}
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Create the server transcript session for one call record."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
body["language"] = language.strip()[:32] or "zh-CN"
return self.client.post("tcm.diagnosis/startCallTranscription", body)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Upsert a bounded batch using each segment_id as the idempotency key."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
normalized: list[dict[str, Any]] = []
if len(segments) > 50:
raise ValueError("at most 50 transcript segments may be submitted at once")
for segment in segments:
segment_id = str(segment.get("segment_id") or "").strip()
text = str(segment.get("text") or "").strip()
if not segment_id or len(segment_id) > 160:
raise ValueError("transcript segment_id is invalid")
if not text or len(text) > 4_000:
raise ValueError("transcript text is invalid")
normalized.append(
{
"segment_id": segment_id,
"speaker_user_id": str(segment.get("speaker_user_id") or "")[:160],
"speaker_role": str(segment.get("speaker_role") or "unknown")[:20],
"timestamp": max(int(segment.get("timestamp") or 0), 0),
"text": text,
}
)
if not normalized:
raise ValueError("at least one transcript segment is required")
body["segments"] = normalized
return self.client.post("tcm.diagnosis/upsertCallTranscriptSegments", body)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize a transcript; repeated requests use the same session identity."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
clean_status = status.strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
if expected_segment_count < 0:
raise ValueError("expected_segment_count must not be negative")
body.update(
{
"expected_segment_count": expected_segment_count,
"status": clean_status,
}
)
return self.client.post("tcm.diagnosis/finishCallTranscription", body)
return self.client.post(
"tcm.diagnosis/bindCallRoom",
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
)
@staticmethod
def _transcription_identity(
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
) -> dict[str, Any]:
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
raise ValueError("call_record_id is required")
clean_session = transcription_session_id.strip()
if not clean_session or len(clean_session) > 128:
raise ValueError("transcription_session_id must contain 1 to 128 characters")
return {
"diagnosis_id": diagnosis_id,
"call_record_id": call_record_id,
"transcription_session_id": clean_session,
}
def start_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
*,
language: str = "zh-CN",
) -> Any:
"""Create the server transcript session for one call record."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
body["language"] = language.strip()[:32] or "zh-CN"
return self.client.post("tcm.diagnosis/startCallTranscription", body)
def upsert_call_transcript_segments(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
segments: Sequence[Mapping[str, Any]],
) -> Any:
"""Upsert a bounded batch using each segment_id as the idempotency key."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
normalized: list[dict[str, Any]] = []
if len(segments) > 50:
raise ValueError("at most 50 transcript segments may be submitted at once")
for segment in segments:
segment_id = str(segment.get("segment_id") or "").strip()
text = str(segment.get("text") or "").strip()
if not segment_id or len(segment_id) > 160:
raise ValueError("transcript segment_id is invalid")
if not text or len(text) > 4_000:
raise ValueError("transcript text is invalid")
normalized.append(
{
"segment_id": segment_id,
"speaker_user_id": str(segment.get("speaker_user_id") or "")[:160],
"speaker_role": str(segment.get("speaker_role") or "unknown")[:20],
"timestamp": max(int(segment.get("timestamp") or 0), 0),
"text": text,
}
)
if not normalized:
raise ValueError("at least one transcript segment is required")
body["segments"] = normalized
return self.client.post("tcm.diagnosis/upsertCallTranscriptSegments", body)
def finish_call_transcription(
self,
diagnosis_id: int,
call_record_id: int | str,
transcription_session_id: str,
expected_segment_count: int,
*,
status: str = "completed",
) -> Any:
"""Finalize a transcript; repeated requests use the same session identity."""
body = self._transcription_identity(
diagnosis_id, call_record_id, transcription_session_id
)
clean_status = status.strip().lower()
if clean_status not in {"completed", "partial", "failed"}:
raise ValueError("transcription status is invalid")
if expected_segment_count < 0:
raise ValueError("expected_segment_count must not be negative")
body.update(
{
"expected_segment_count": expected_segment_count,
"status": clean_status,
}
)
return self.client.post("tcm.diagnosis/finishCallTranscription", body)
# Compatibility aliases keep UI naming independent from endpoint history.
def my_self(self) -> Session:
@@ -2402,6 +2656,26 @@ def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
return value
def _client_request(
client: Any,
method: str,
endpoint: str,
payload: Mapping[str, Any] | None = None,
*,
timeout: float | None = None,
) -> Any:
"""Call get/post, ignoring timeout kwargs that test doubles do not accept."""
fn = getattr(client, method)
params = dict(payload or {})
try:
if timeout is None:
return fn(endpoint, params)
return fn(endpoint, params, timeout=timeout)
except TypeError:
return fn(endpoint, params)
def _material_kind(
material_type: str,
) -> Literal["image", "video", "file"]:
@@ -53,28 +53,28 @@ from .widgets import (
)
APPOINTMENT_DRAWER_QSS = r"""
QDialog#AppointmentDrawerOverlay {
background-color: transparent;
color: #134E4A;
font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 14px;
}
QDialog#AppointmentDrawerOverlay {
background-color: transparent;
color: #111F46;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
background-color: #FFFFFF;
border-left: 1px solid #DCDFE6;
border-left: 1px solid #E6EAF5;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
background-color: #FFFFFF;
border: 0;
border-bottom: 1px solid #E2EBE8;
border-bottom: 1px solid #E6EAF5;
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
color: #134E4A;
font-size: 18px;
font-weight: 600;
color: #111F46;
font-size: 18px;
font-weight: 700;
}
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
@@ -86,14 +86,14 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
border: 0;
border-radius: 4px;
background-color: transparent;
color: #5B7A76;
color: #7886AA;
font-size: 22px;
font-weight: 400;
}
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
color: #0891B2;
background-color: #ECFEFF;
color: #4451E2;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
@@ -109,13 +109,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
}
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
color: #5B7A76;
color: #3F4E75;
font-size: 14px;
font-weight: 500;
}
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
color: #5B7A76;
color: #7886AA;
}
QDialog#AppointmentDrawerOverlay QComboBox,
@@ -123,12 +123,12 @@ QDialog#AppointmentDrawerOverlay QLineEdit,
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
min-height: 30px;
padding: 0 11px;
border: 1px solid #DCDFE6;
border-radius: 4px;
border: 1px solid #E6EAF5;
border-radius: 9px;
background-color: #FFFFFF;
color: #134E4A;
selection-background-color: #A0CFFF;
selection-color: #134E4A;
color: #111F46;
selection-background-color: #5761F4;
selection-color: #FFFFFF;
}
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
@@ -138,13 +138,13 @@ QDialog#AppointmentDrawerOverlay QPlainTextEdit {
QDialog#AppointmentDrawerOverlay QComboBox:hover,
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
border-color: #C0C4CC;
border-color: #5761F4;
}
QDialog#AppointmentDrawerOverlay QComboBox:focus,
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
border: 2px solid #79BBFF;
border: 2px solid #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
@@ -154,77 +154,79 @@ QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
background-color: #FFFFFF;
color: #134E4A;
border: 1px solid #D5E5E2;
selection-background-color: #ECFEFF;
selection-color: #0891B2;
color: #111F46;
border: 1px solid #E6EAF5;
selection-background-color: #5761F4;
selection-color: #FFFFFF;
outline: 0;
}
QDialog#AppointmentDrawerOverlay QRadioButton {
min-height: 24px;
spacing: 8px;
color: #134E4A;
color: #3F4E75;
}
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
width: 12px;
height: 12px;
border-radius: 7px;
border: 1px solid #DCDFE6;
border: 1px solid #E6EAF5;
background-color: #FFFFFF;
}
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
border-color: #0891B2;
border-color: #5761F4;
}
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
width: 4px;
height: 4px;
border: 5px solid #0891B2;
border: 5px solid #5761F4;
border-radius: 7px;
background-color: #FFFFFF;
}
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
color: #0891B2;
color: #4451E2;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
padding: 0;
border: 1px solid #DCDFE6;
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
min-height: 38px;
max-height: 38px;
padding: 0;
border: 1px solid #E6EAF5;
border-radius: 8px;
background-color: #FFFFFF;
color: #5B7A76;
color: #3F4E75;
font-size: 14px;
font-weight: 500;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
color: #0891B2;
border-color: #0891B2;
background-color: #ECFEFF;
color: #4451E2;
border-color: #5761F4;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
border-color: #79BBFF;
border-color: #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
color: #FFFFFF;
border-color: #0891B2;
background-color: #0891B2;
border-color: #5761F4;
background-color: #5761F4;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
background-color: #F8F9FA;
background-color: #F7F9FE;
border: 0;
border-radius: 8px;
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
color: #134E4A;
color: #111F46;
font-size: 15px;
font-weight: 600;
}
@@ -236,27 +238,27 @@ QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
border: 0;
border-radius: 4px;
background-color: transparent;
color: #0891B2;
color: #4451E2;
font-size: 13px;
font-weight: 500;
}
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
background-color: #ECFEFF;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
min-width: 110px;
min-height: 70px;
padding: 0 8px;
border: 2px solid #D5E5E2;
border: 2px solid #E6EAF5;
border-radius: 8px;
background-color: #FFFFFF;
color: #134E4A;
color: #111F46;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
color: #134E4A;
color: #111F46;
font-size: 15px;
font-weight: 600;
}
@@ -265,33 +267,33 @@ QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#Appo
padding: 0 8px;
border-radius: 4px;
background-color: #F4F4F5;
color: #5B7A76;
color: #7886AA;
font-size: 12px;
font-weight: 400;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
color: #67C23A;
background-color: #F0F9FF;
color: #17A77D;
background-color: #EAF9F3;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
color: #0891B2;
border-color: #0891B2;
background-color: #ECFEFF;
color: #4451E2;
border-color: #5761F4;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
border-color: #79BBFF;
border-color: #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
color: #FFFFFF;
border-color: #0891B2;
background: qlineargradient(
x1:0, y1:0, x2:1, y2:1,
stop:0 #0891B2,
stop:1 #66B1FF
border-color: #5761F4;
background: qlineargradient(
x1:0, y1:0, x2:1, y2:1,
stop:0 #5761F4,
stop:1 #7769F7
);
}
@@ -305,18 +307,18 @@ QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLa
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
color: #C0C4CC;
border-color: #D5E5E2;
background-color: #F5F7FA;
color: #A4ADC3;
border-color: #E6EAF5;
background-color: #F0F2F8;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotTime,
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
color: #C0C4CC;
color: #A4ADC3;
}
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
background-color: #F5F7FA;
background-color: #F0F2F8;
}
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
@@ -324,94 +326,94 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
color: #5B7A76;
color: #7886AA;
font-size: 14px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
background-color: #ECFEFF;
border: 1px solid #D9ECFF;
border-radius: 4px;
background-color: #F0F4FF;
border: 1px solid #DDE5FF;
border-radius: 9px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] {
background-color: #FDF6EC;
border: 1px solid #FAECD8;
border-radius: 4px;
background-color: #FFF5E6;
border: 1px solid #F6E3C4;
border-radius: 9px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] {
background-color: #FEF0F0;
border: 1px solid #FDE2E2;
border-radius: 4px;
background-color: #FFF1F3;
border: 1px solid #F7D7DC;
border-radius: 9px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
background-color: #F0F9EB;
border: 1px solid #E1F3D8;
border-radius: 4px;
background-color: #EAF9F3;
border: 1px solid #D4F0E5;
border-radius: 9px;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
color: #0891B2;
color: #4D69ED;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
color: #E6A23C;
color: #D38625;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
color: #F56C6C;
color: #F15B67;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
color: #67C23A;
color: #17A77D;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
background-color: #FFFFFF;
border: 0;
border-top: 1px solid #E2EBE8;
border-top: 1px solid #E6EAF5;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
min-height: 30px;
max-height: 30px;
padding: 0 15px;
border: 1px solid #DCDFE6;
border-radius: 4px;
border: 1px solid #E6EAF5;
border-radius: 9px;
background-color: #FFFFFF;
color: #5B7A76;
font-size: 14px;
font-weight: 500;
color: #3F4E75;
font-size: 13px;
font-weight: 600;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
color: #0891B2;
border-color: #A5F3FC;
background-color: #ECFEFF;
color: #4451E2;
border-color: #5761F4;
background-color: #F0F2FF;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
border-color: #79BBFF;
border-color: #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
color: #FFFFFF;
border-color: #0891B2;
background-color: #0891B2;
border-color: #5761F4;
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5761F4, stop:1 #7769F7);
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
color: #FFFFFF;
border-color: #66B1FF;
background-color: #66B1FF;
border-color: #4C57E9;
background-color: #4C57E9;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
color: #FFFFFF;
border-color: #A0CFFF;
background-color: #A0CFFF;
border-color: #E6EAF5;
background-color: #A4ADC3;
}
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
@@ -420,7 +422,7 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
}
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
color: #5B7A76;
color: #7886AA;
font-size: 14px;
}
@@ -434,11 +436,11 @@ QDialog#AppointmentDrawerOverlay QScrollBar:vertical {
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
min-height: 30px;
border-radius: 3px;
background-color: #DCDFE6;
background-color: #E6EAF5;
}
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
background-color: #C0C4CC;
background-color: #8D9BFF;
}
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
+204 -154
View File
@@ -1,9 +1,9 @@
"""Scoped visual primitives for diagnosis readonly pages and edit drawers.
Diagnosis chrome uses the workstation clinical teal palette (not Element blue)
so the drawer feels native to the desktop app while remaining visually isolated
from other pages via object-name selectors.
"""
"""Scoped visual primitives for diagnosis readonly pages and edit drawers.
The diagnosis workspace uses the same cool white, indigo and blue-gray visual
language as the desktop shell. Every rule remains isolated behind diagnosis
object names and dynamic properties so adjacent pages keep their own styling.
"""
from __future__ import annotations
@@ -40,7 +40,6 @@ from PySide6.QtWidgets import (
QLineEdit,
QPlainTextEdit,
QPushButton,
QRadioButton,
QScrollBar,
QSizePolicy,
QTableWidget,
@@ -384,9 +383,11 @@ QLabel#DiagnosisUnitLabel {
}
QLineEdit[diagnosisField="true"],
QComboBox[diagnosisField="true"],
QPlainTextEdit[diagnosisField="true"],
QDateEdit[diagnosisField="true"],
QDoubleSpinBox[diagnosisField="true"] {
QPlainTextEdit[diagnosisField="true"],
QDateEdit[diagnosisField="true"],
QDoubleSpinBox[diagnosisField="true"],
QSpinBox[diagnosisField="true"],
QTimeEdit[diagnosisField="true"] {
color: #134E4A;
background-color: #FFFFFF;
border: 1px solid #D5E5E2;
@@ -405,9 +406,11 @@ QPlainTextEdit[diagnosisField="true"] {
}
QLineEdit[diagnosisField="true"]:focus,
QComboBox[diagnosisField="true"]:focus,
QPlainTextEdit[diagnosisField="true"]:focus,
QDateEdit[diagnosisField="true"]:focus,
QDoubleSpinBox[diagnosisField="true"]:focus {
QPlainTextEdit[diagnosisField="true"]:focus,
QDateEdit[diagnosisField="true"]:focus,
QDoubleSpinBox[diagnosisField="true"]:focus,
QSpinBox[diagnosisField="true"]:focus,
QTimeEdit[diagnosisField="true"]:focus {
border: 1px solid #0891B2;
background-color: #F0FDFA;
}
@@ -421,9 +424,11 @@ QLineEdit[diagnosisField="true"]:read-only,
QPlainTextEdit[diagnosisField="true"]:read-only,
QLineEdit[diagnosisField="true"]:disabled,
QComboBox[diagnosisField="true"]:disabled,
QPlainTextEdit[diagnosisField="true"]:disabled,
QDateEdit[diagnosisField="true"]:disabled,
QDoubleSpinBox[diagnosisField="true"]:disabled {
QPlainTextEdit[diagnosisField="true"]:disabled,
QDateEdit[diagnosisField="true"]:disabled,
QDoubleSpinBox[diagnosisField="true"]:disabled,
QSpinBox[diagnosisField="true"]:disabled,
QTimeEdit[diagnosisField="true"]:disabled {
color: #5B7A76;
background-color: #F0F2EF;
border-color: #D9DEDA;
@@ -460,11 +465,40 @@ QLabel#DiagnosisReadonlyTitle {
font-size: 16px;
font-weight: 700;
}
QLabel#DiagnosisReadonlyPatientName {
color: #0F172A;
font-size: 18px;
font-weight: 700;
}
QLabel#DiagnosisReadonlyPatientName {
color: #0F172A;
font-size: 18px;
font-weight: 700;
}
QLabel#DiagnosisReadonlyHeroMeta,
QLabel#DiagnosisPrivacyText,
QLabel#DiagnosisOrderOffsetPreview,
QLabel#DiagnosisOrdersSummary {
color: #64748B;
font-size: 12px;
}
QLabel#DiagnosisOrderOffsetLabel {
color: #1F2937;
font-size: 13px;
font-weight: 600;
}
QPushButton#DiagnosisOrderOffsetHelp {
min-width: 22px;
max-width: 22px;
min-height: 22px;
max-height: 22px;
padding: 0;
color: #0E7490;
background-color: #CFFAFE;
border: 1px solid #A5F3FC;
border-radius: 11px;
font-weight: 700;
}
QPushButton#DiagnosisOrderOffsetHelp:hover,
QPushButton#DiagnosisOrderOffsetHelp:focus {
background-color: #A5F3FC;
border-color: #22D3EE;
}
QLabel#DiagnosisReadonlyStatus[severity="neutral"] {
color: #9AA39E;
background-color: rgba(255, 255, 255, 190);
@@ -775,11 +809,35 @@ QLabel#DiagnosisUnsupportedState {
padding: 22px;
font-size: 13px;
}
QDialog#DiagnosisDailyEditor,
QDialog#DiagnosisOrderDetailDialog,
QDialog#DiagnosisRecordingPlayer {
background-color: #FFFFFF;
}
QDialog#DiagnosisDailyEditor,
QDialog#DiagnosisOrderDetailDialog,
QDialog#DiagnosisRecordingPlayer {
background-color: #FFFFFF;
}
QDialog#DiagnosisDailyEditor {
background-color: #F7F9FE;
}
QFrame#DiagnosisEditorHeader,
QFrame#DiagnosisEditorFooter {
background-color: #FFFFFF;
border: 0;
}
QFrame#DiagnosisEditorHeader {
border-bottom: 1px solid #E6EAF5;
}
QFrame#DiagnosisEditorFooter {
border-top: 1px solid #E6EAF5;
}
QWidget#DiagnosisEditorContent {
background-color: #FFFFFF;
border: 1px solid #E6EAF5;
border-radius: 12px;
}
QWidget#DiagnosisEditorContent QLabel {
color: #3F4E75;
font-size: 12px;
font-weight: 600;
}
QLabel#DiagnosisDialogHeading {
color: #0F172A;
font-size: 18px;
@@ -797,12 +855,17 @@ QLabel#DiagnosisEditorError {
border-radius: 7px;
padding: 8px 10px;
}
QScrollArea#DiagnosisEditorScroll,
QScrollArea#DiagnosisOrderDetailScroll {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 10px;
}
QScrollArea#DiagnosisEditorScroll,
QScrollArea#DiagnosisOrderDetailScroll {
background-color: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 10px;
}
QScrollArea#DiagnosisEditorScroll,
QScrollArea#DiagnosisEditorScroll > QWidget > QWidget {
background-color: #F7F9FE;
border: 0;
}
QFrame#DiagnosisOrderOffsetBar,
QFrame#DiagnosisOrderDetailHero {
background-color: #F8FAFC;
@@ -875,89 +938,76 @@ QDialog#DiagnosisPrescriptionEditor QDoubleSpinBox:focus {
}
"""
# Keep the component-scoped rules authoritative while applying one restrained
# dark indigo visual system across every nested editor, table, state and card.
_DIAGNOSIS_DARK_REPLACEMENTS = (
("color: #FFFFFF", "color: #EEF2FF"),
("color:#FFFFFF", "color:#EEF2FF"),
("selection-color: #134E4A", "selection-color: #EEF2FF"),
("selection-background-color: #CFFAFE", "selection-background-color: #29334F"),
("selection-background-color: #A5F3FC", "selection-background-color: #6675F5"),
("background-color: #0F172A", "background-color: #080B14"),
("color: #CBD5E1", "color: #9AA7C0"),
("rgba(15, 23, 42, 112)", "rgba(8, 11, 20, 196)"),
("rgba(255, 255, 255, 218)", "rgba(8, 11, 20, 224)"),
("rgba(255, 255, 255, 210)", "rgba(8, 11, 20, 216)"),
("rgba(255, 255, 255, 190)", "rgba(21, 29, 49, 230)"),
("#FFFFFF", "#101626"),
("#F5F8F7", "#080B14"),
("#F6F6F6", "#080B14"),
("#F8FAFC", "#151D31"),
("#F1F5F9", "#151D31"),
("#F0F2EF", "#151D31"),
("#F0F5F3", "#151D31"),
("#F3F4F6", "#151D31"),
("#FAFAFA", "#151D31"),
("#ECFEFF", "#151D31"),
("#CFFAFE", "#151D31"),
("#FFFBEB", "#151D31"),
("#F0FDF4", "#151D31"),
("#FEF2F2", "#151D31"),
("#FEE2E2", "#1B2440"),
("#ECFDF5", "#151D31"),
("#FFF7F7", "#151D31"),
("#FFF7ED", "#151D31"),
("#A5F3FC", "#1B2440"),
("#67E8F9", "#1B2440"),
("#E2E8F0", "#29334F"),
("#DCE3EC", "#29334F"),
("#CBD5E1", "#29334F"),
("#FCD34D", "#29334F"),
("#BBF7D0", "#29334F"),
("#FECACA", "#29334F"),
("#D5E5E2", "#29334F"),
("#E2EBE8", "#29334F"),
("#D9DEDA", "#29334F"),
("#CBD3CE", "#29334F"),
("#E5E7EB", "#29334F"),
("#FED7AA", "#29334F"),
("#D9ECFF", "#29334F"),
("#E6EBF2", "#29334F"),
("#0F172A", "#EEF2FF"),
("#134E4A", "#EEF2FF"),
("#1F2937", "#EEF2FF"),
("#333333", "#EEF2FF"),
("#64748B", "#9AA7C0"),
("#5B7A76", "#9AA7C0"),
("#475569", "#9AA7C0"),
("#2A6B64", "#9AA7C0"),
("#66736D", "#9AA7C0"),
("#6B7280", "#9AA7C0"),
("#999999", "#9AA7C0"),
("#9AA39E", "#9AA7C0"),
("#94A8A4", "#9AA7C0"),
("#C0C4CC", "#9AA7C0"),
("#0891B2", "#6675F5"),
("#0E7490", "#6675F5"),
("#22D3EE", "#78A7FF"),
("#16A34A", "#49C6A5"),
("#15803D", "#49C6A5"),
("#B45309", "#E4B967"),
("#EA580C", "#E4B967"),
("#F97316", "#E4B967"),
("#DC2626", "#F07886"),
("#B91C1C", "#F07886"),
("#F56C6C", "#F07886"),
("#FCA5A5", "#F07886"),
("#F0FDFA", "#1B2440"),
("#F8F8F8", "#151D31"),
# Run last so foreground white remains readable while former white
# surfaces stay in the dark elevation system.
("background-color: #EEF2FF", "background-color: #101626"),
("background: #EEF2FF", "background: #101626"),
)
# Light is the default application theme. Keep the replacement table available
# for a future explicit dark-mode switch, but do not mutate the light source QSS.
# Normalize the legacy component palette in one place. Keeping the selectors
# untouched protects the mature drawer behavior while aligning every nested
# editor, table and state with the shell's current blue-white theme.
_DIAGNOSIS_BLUE_REPLACEMENTS = (
("#0891B2", "#5265F6"),
("#0E7490", "#4D57D8"),
("#22D3EE", "#6871F6"),
("#67E8F9", "#C9CEFF"),
("#A5F3FC", "#D8DCFF"),
("#CFFAFE", "#F0F2FF"),
("#ECFEFF", "#F5F8FF"),
("#D9ECFF", "#DDE7FF"),
("#134E4A", "#15224A"),
("#2A6B64", "#3F4E75"),
("#5B7A76", "#7481A3"),
("#66736D", "#7481A3"),
("#94A8A4", "#A4ADC3"),
("#67B8C9", "#6871F6"),
("#D5E5E2", "#E2E7F4"),
("#E2EBE8", "#E2E7F4"),
("#D9DEDA", "#E2E7F4"),
("#CBD3CE", "#DCE3F2"),
("#F5F8F7", "#FCFDFE"),
("#F0F2EF", "#F2F6FE"),
("#F0F5F3", "#F7F9FE"),
("#F0FDFA", "#F7F9FE"),
("#0F172A", "#15224A"),
("#1F2937", "#15224A"),
("#333333", "#15224A"),
("#475569", "#3F4E75"),
("#64748B", "#7481A3"),
("#6B7280", "#7481A3"),
("#999999", "#A4ADC3"),
("#9AA39E", "#A4ADC3"),
("#C0C4CC", "#A4ADC3"),
("#F1F5F9", "#F7F9FE"),
("#E2E8F0", "#E2E7F4"),
("#DCE3EC", "#E2E7F4"),
("#CBD5E1", "#DCE3F2"),
("#E5E7EB", "#E2E7F4"),
("#E6EBF2", "#E2E7F4"),
("#F8FAFC", "#FCFDFE"),
("#F6F6F6", "#FCFDFE"),
("#FAFAFA", "#FCFDFE"),
("#F8F8F8", "#F7F9FE"),
("#16A34A", "#17A77D"),
("#15803D", "#17A77D"),
("#F0FDF4", "#EAF9F3"),
("#ECFDF5", "#EAF9F3"),
("#BBF7D0", "#BFE9DC"),
("#A7F3D0", "#BFE9DC"),
("#B45309", "#D38625"),
("#EA580C", "#D38625"),
("#F97316", "#D38625"),
("#FFFBEB", "#FFF5E6"),
("#FFF7ED", "#FFF5E6"),
("#FCD34D", "#F3D6AC"),
("#FDE68A", "#F3D6AC"),
("#FED7AA", "#F3D6AC"),
("#DC2626", "#F15B67"),
("#B91C1C", "#D94856"),
("#F56C6C", "#F15B67"),
("#FEF2F2", "#FFF1F3"),
("#FFF7F7", "#FFF1F3"),
("#FEE2E2", "#FFE4E8"),
("#FECACA", "#F7C8CD"),
("#FCA5A5", "#F19BA4"),
)
for _source_color, _theme_color in _DIAGNOSIS_BLUE_REPLACEMENTS:
DIAGNOSIS_QSS = DIAGNOSIS_QSS.replace(_source_color, _theme_color)
def _text(value: Any, default: str = "") -> str:
@@ -1037,7 +1087,7 @@ class DiagnosisSwitch(QAbstractButton):
track = QRectF(1, 3, self.width() - 2, self.height() - 6)
checked = self.isChecked()
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#4F63D9" if checked else "#D8DEEA"))
painter.setBrush(QColor("#5761F4" if checked else "#D8DEEE"))
painter.drawRoundedRect(track, track.height() / 2, track.height() / 2)
diameter = track.height() - 4
x = track.right() - diameter - 2 if checked else track.left() + 2
@@ -1081,9 +1131,9 @@ class SaveStateButton(QPushButton):
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
enabled = self.isEnabled()
color = {
"success": "#16876C",
"error": "#C43E55",
}.get(self._state, "#4F63D9" if enabled else "#98A2B3")
"success": "#17A77D",
"error": "#F15B67",
}.get(self._state, "#5761F4" if enabled else "#A4ADC3")
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(color))
painter.drawRoundedRect(QRectF(self.rect()).adjusted(1, 1, -1, -1), 10, 10)
@@ -1602,18 +1652,18 @@ class MessageStrip(QFrame):
self.glyph = QLabel("i")
self.glyph.setFixedWidth(18)
self.glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.glyph.setStyleSheet("font-weight:700; color:#2F6EDB;")
self.glyph.setStyleSheet("font-weight:700; color:#4D69ED;")
self.label = QLabel()
self.label.setWordWrap(True)
self.label.setStyleSheet("color:#667085; font-size:12px;")
self.label.setStyleSheet("color:#3F4E75; font-size:12px;")
layout.addWidget(self.glyph)
layout.addWidget(self.label, 1)
self.action_button = QPushButton()
self.action_button.setObjectName("DiagnosisMessageAction")
self.action_button.setStyleSheet(
"QPushButton{min-height:28px;padding:0 10px;color:#3446AF;background:#E9EDFF;"
"border:1px solid #C8D1FF;border-radius:7px;font-weight:600;}"
"QPushButton:hover,QPushButton:focus{background:#DCE3FF;border-color:#4F63D9;}"
"QPushButton{min-height:28px;padding:0 10px;color:#4451E2;background:#F0F2FF;"
"border:1px solid #D3D8FF;border-radius:7px;font-weight:600;}"
"QPushButton:hover,QPushButton:focus{background:#E4E7FF;border-color:#5761F4;}"
)
self.action_button.clicked.connect(self.action_requested)
self.action_button.hide()
@@ -1772,15 +1822,15 @@ class RecordTable(QTableWidget):
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
if column_index in danger_columns:
item.setForeground(QColor("#F07886"))
item.setForeground(QColor("#D94856"))
kind = semantic.get(column_index)
if kind:
palette = {
"success": ("#49C6A5", "#1B2440"),
"warning": ("#E4B967", "#1B2440"),
"danger": ("#F07886", "#1B2440"),
"info": ("#78A7FF", "#1B2440"),
"neutral": ("#9AA7C0", "#1B2440"),
"success": ("#137A61", "#EAF9F3"),
"warning": ("#A86616", "#FFF5E6"),
"danger": ("#D94856", "#FFF1F3"),
"info": ("#4059D8", "#F0F4FF"),
"neutral": ("#64739A", "#F5F7FC"),
}
foreground, background = palette.get(kind, palette["neutral"])
item.setForeground(QColor(foreground))
@@ -1820,20 +1870,20 @@ class BloodTrendChart(QWidget):
plot = self.rect().adjusted(48, 24, -22, -38)
values = [value for value in (*self._fasting, *self._postprandial) if value is not None]
if not values or plot.width() <= 0 or plot.height() <= 0:
painter.setPen(QColor("#9AA7C0"))
painter.setPen(QColor("#7886AA"))
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "当前时间范围暂无血糖数据")
painter.end()
return
upper = max(12.0, max(values) * 1.15)
painter.setPen(QPen(QColor("#29334F"), 1))
painter.setPen(QPen(QColor("#E6EAF5"), 1))
for step in range(5):
y = plot.bottom() - round(plot.height() * step / 4)
painter.drawLine(plot.left(), y, plot.right(), y)
painter.setPen(QColor("#9AA7C0"))
painter.setPen(QColor("#7886AA"))
painter.drawText(
4, y - 8, 40, 16, Qt.AlignmentFlag.AlignRight, f"{upper * step / 4:.0f}"
)
painter.setPen(QPen(QColor("#29334F"), 1))
painter.setPen(QPen(QColor("#E6EAF5"), 1))
count = max(1, len(self._dates) - 1)
def point(index: int, value: float) -> QPointF:
@@ -1843,8 +1893,8 @@ class BloodTrendChart(QWidget):
)
for series, color in (
(self._fasting, QColor("#6675F5")),
(self._postprandial, QColor("#E4B967")),
(self._fasting, QColor("#5761F4")),
(self._postprandial, QColor("#D38625")),
):
previous: QPointF | None = None
painter.setPen(QPen(color, 2.5))
@@ -1858,12 +1908,12 @@ class BloodTrendChart(QWidget):
painter.drawLine(previous, current)
painter.drawEllipse(current, 3.2, 3.2)
previous = current
painter.setPen(QColor("#6675F5"))
painter.setPen(QColor("#5761F4"))
painter.drawText(plot.left(), 4, 94, 18, Qt.AlignmentFlag.AlignLeft, "● 空腹血糖")
painter.setPen(QColor("#E4B967"))
painter.setPen(QColor("#D38625"))
painter.drawText(plot.left() + 100, 4, 110, 18, Qt.AlignmentFlag.AlignLeft, "● 餐后血糖")
if self._dates:
painter.setPen(QColor("#9AA7C0"))
painter.setPen(QColor("#7886AA"))
painter.drawText(
plot.left(),
plot.bottom() + 8,
@@ -2061,7 +2111,7 @@ class DailyRecordPanel(QFrame):
self.todo_table.setMinimumHeight(180)
todo_layout.addWidget(self.todo_table)
self.todo_summary = QLabel("共 0 条")
self.todo_summary.setStyleSheet("color:#9AA7C0; font-size:12px;")
self.todo_summary.setStyleSheet("color:#7886AA; font-size:12px;")
todo_layout.addWidget(self.todo_summary, 0, Qt.AlignmentFlag.AlignRight)
root.addWidget(todo_card)
self.clear()
@@ -2215,8 +2265,8 @@ class DailyRecordPanel(QFrame):
has_records = False
for row_index, (metric, label) in enumerate(self.METRICS):
label_item = QTableWidgetItem(label)
label_item.setForeground(QColor("#9AA7C0"))
label_item.setBackground(QColor("#151D31"))
label_item.setForeground(QColor("#64739A"))
label_item.setBackground(QColor("#F5F7FC"))
self.matrix.setItem(row_index, 0, label_item)
for date_index, date in enumerate(dates, 1):
b = blood.get(date)
@@ -2269,11 +2319,11 @@ class DailyRecordPanel(QFrame):
item = QTableWidgetItem(value)
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if high:
item.setForeground(QColor("#F07886"))
item.setBackground(QColor("#1B2440"))
item.setForeground(QColor("#D94856"))
item.setBackground(QColor("#FFF1F3"))
elif value.endswith("· 自录"):
item.setForeground(QColor("#78A7FF"))
item.setBackground(QColor("#1B2440"))
item.setForeground(QColor("#4059D8"))
item.setBackground(QColor("#F0F4FF"))
edit_kind = ""
edit_source: Any = None
if metric in {"fasting", "postprandial", "other", "bp", "western", "insulin"}:
@@ -2764,7 +2814,7 @@ class ChatPanel(QWidget):
toolbar_layout.setContentsMargins(0, 0, 0, 0)
toolbar_layout.setSpacing(8)
archive = QLabel("仅展示服务端已归档消息(only_archived=1")
archive.setStyleSheet("color:#9AA7C0;font-size:12px;")
archive.setStyleSheet("color:#7886AA;font-size:12px;")
toolbar_layout.addWidget(archive)
toolbar_layout.addStretch(1)
self.sync_button = QPushButton("同步最新(后台异步)")
@@ -2992,7 +3042,7 @@ class CaseGrid(QFrame):
title_row.addWidget(self.title_label)
self.subtitle = QLabel("病例 · 诊断日期 —")
self.subtitle.setStyleSheet(
'color:#9AA7C0;font-size:12px;font-family:"IBM Plex Mono",Consolas,monospace;'
'color:#7886AA;font-size:12px;font-family:"IBM Plex Mono",Consolas,monospace;'
)
title_row.addWidget(self.subtitle)
title_row.addStretch(1)
@@ -3002,7 +3052,7 @@ class CaseGrid(QFrame):
if group_index:
divider = QFrame()
divider.setFrameShape(QFrame.Shape.HLine)
divider.setStyleSheet("border:0; border-top:1px dashed #29334F;")
divider.setStyleSheet("border:0; border-top:1px dashed #D8DEEE;")
self.root.addWidget(divider)
group_title = QLabel(f"{group_name}")
group_title.setProperty("diagnosisCaseGroup", True)
@@ -3155,11 +3205,11 @@ def readonly_card(title: str, object_name: str, body: QWidget) -> QFrame:
def set_tag_item(item: QTableWidgetItem, kind: str) -> None:
palette = {
"success": ("#49C6A5", "#1B2440"),
"warning": ("#E4B967", "#1B2440"),
"danger": ("#F07886", "#1B2440"),
"info": ("#78A7FF", "#1B2440"),
"neutral": ("#9AA7C0", "#1B2440"),
"success": ("#137A61", "#EAF9F3"),
"warning": ("#A86616", "#FFF5E6"),
"danger": ("#D94856", "#FFF1F3"),
"info": ("#4059D8", "#F0F4FF"),
"neutral": ("#64739A", "#F5F7FC"),
}
foreground, background = palette.get(kind, palette["neutral"])
item.setForeground(QColor(foreground))
@@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
QDialog,
QDoubleSpinBox,
QFormLayout,
QFrame,
QHBoxLayout,
QLabel,
QLayout,
@@ -161,27 +162,45 @@ class DailyRecordEditorDialog(QDialog):
self.resize(650, 620 if kind == "blood" else 590)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(14)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
header = QFrame()
header.setObjectName("DiagnosisEditorHeader")
header_layout = QVBoxLayout(header)
header_layout.setContentsMargins(22, 18, 22, 16)
header_layout.setSpacing(5)
heading = QLabel(self.windowTitle())
heading.setObjectName("DiagnosisDialogHeading")
root.addWidget(heading)
header_layout.addWidget(heading)
guidance = QLabel("保存后将重新加载当前日期范围;带 * 的字段为必填项。")
guidance.setObjectName("DiagnosisDialogGuidance")
guidance.setWordWrap(True)
root.addWidget(guidance)
header_layout.addWidget(guidance)
root.addWidget(header)
scroll = QScrollArea()
scroll.setObjectName("DiagnosisEditorScroll")
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
content.setObjectName("DiagnosisEditorContent")
self.form = QFormLayout(content)
self.form.setContentsMargins(8, 8, 12, 8)
self.form.setHorizontalSpacing(18)
self.form.setVerticalSpacing(11)
self.form.setContentsMargins(22, 20, 22, 22)
self.form.setHorizontalSpacing(20)
self.form.setVerticalSpacing(12)
self.form.setLabelAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self.form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
scroll.setWidget(content)
root.addWidget(scroll, 1)
footer = QFrame()
footer.setObjectName("DiagnosisEditorFooter")
footer_layout = QVBoxLayout(footer)
footer_layout.setContentsMargins(22, 12, 22, 14)
footer_layout.setSpacing(10)
self.date_edit = QDateEdit()
self.date_edit.setCalendarPopup(True)
self.date_edit.setDisplayFormat("yyyy-MM-dd")
@@ -202,8 +221,10 @@ class DailyRecordEditorDialog(QDialog):
self.error_label.setObjectName("DiagnosisEditorError")
self.error_label.setWordWrap(True)
self.error_label.hide()
root.addWidget(self.error_label)
footer_layout.addWidget(self.error_label)
actions = QHBoxLayout()
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(8)
actions.addStretch(1)
cancel = QPushButton("取消")
cancel.setProperty("variant", "ghost")
@@ -215,7 +236,8 @@ class DailyRecordEditorDialog(QDialog):
save.setDefault(True)
save.clicked.connect(self.accept)
actions.addWidget(save)
root.addLayout(actions)
footer_layout.addLayout(actions)
root.addWidget(footer)
@staticmethod
def _field(widget: QWidget) -> QWidget:

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