Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18b92f1767 | ||
|
|
79601ea176 | ||
|
|
c1eee2d28a | ||
|
|
2fc8048844 | ||
|
|
18c15d1262 | ||
|
|
21790e35f4 | ||
|
|
cbe71905c0 | ||
|
|
72a5300b08 | ||
|
|
07188e82f8 | ||
|
|
c3713a8785 | ||
|
|
dddf2348d3 | ||
|
|
18ed3c48fa | ||
|
|
26839c2957 | ||
|
|
b2eb18c75f | ||
|
|
a1092c02c3 |
@@ -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
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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 药房联)样式
|
||||
* 严格按 A4:210mm × 297mm,padding 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -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. 同文件队列分页/筛选 fixtures(247-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。
|
||||
@@ -0,0 +1,203 @@
|
||||
# 诊疗 / 病例 / 订单 / AI 报告子窗口蓝白参考审计
|
||||
|
||||
审计日期:2026-08-13
|
||||
审计性质:只读;未修改业务源码、测试、脚本或既有 PNG。
|
||||
审计边界:诊疗详情/编辑抽屉、独立病例只读页、诊断上下文中的业务订单详情、诊断 AI 报告。未审计或改动 `theme.py`、`widgets.py`、处方编辑/患者页/挂号页;AI 部分只看诊断报告模式,不扩展到处方业务。
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
1. **用户所指“蓝白参考”最接近的现有实图是 `artifacts/pixel_exact_v3/consultations.png`**(1710×920,2026-08-13 19:56)。它是当前最新、最完整的蓝白医生工作站视觉:浅蓝壳层、近白内容底、白色卡片、靛蓝主操作、冷灰蓝文字与边框。子窗口应从它取色,不应从旧 Diagnosis 深色图取色。
|
||||
2. **子窗口结构不能统一成同一种 modal。** 现有正确结构分别是:诊疗编辑/viewOnly 为右侧 60% Drawer;独立病例为无 Tabs 的纵向滚动详情页;订单详情为右侧 80% readonly Drawer;AI 报告为 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×720,2026-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 11–12 px;卡片标题 14–15 px;Drawer 标题 18–19 px;AI 报告标题 20 px。
|
||||
- 4 px 间距基线;常用 8/12/16/20/24 px。不要产生 5、13、17、21 等无依据的主布局间距。
|
||||
- 控件/普通按钮高 34 px;诊疗 Footer 主按钮高 40 px;紧凑 close 为 32×32;诊疗 Tab 高 42 px;AI Tab 高 36 px。
|
||||
- 内控件/按钮圆角 7–8 px;字段卡/分区 9–10 px;Hero 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 px,1440×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 4;close 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 的视觉尺度保持 12–12.5 px;label `#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 px,1440 owner 为 1152 px;左侧 20% 为 scrim。
|
||||
- Header/Body/Footer 三段独立;Body 单独纵向滚动,Footer 始终可见。
|
||||
- Header 当前内边距 `20px 15px 18px 15px`,gap 12;标题栈 gap 4;标题 19 px,meta 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 18;selected 字 `#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%,覆盖 owner,readonly 属性与各信息区存在。
|
||||
- 空字段必须显示明确空态,不能伪造 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
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||