更新
This commit is contained in:
@@ -145,7 +145,7 @@
|
|||||||
<div class="panel-heading panel-heading--table">
|
<div class="panel-heading panel-heading--table">
|
||||||
<div>
|
<div>
|
||||||
<h2>明细数据列表</h2>
|
<h2>明细数据列表</h2>
|
||||||
<p>展开部门可查看人员明细;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/预约,接诊率=接诊诊单/加粉</p>
|
<p>展开部门可查看人员明细;加粉=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/预约,预约接诊率=接诊诊单/面诊,面诊接诊率=面诊/挂号,接诊率=接诊诊单/加粉</p>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,9 +196,12 @@
|
|||||||
<el-table-column label="面诊率" min-width="96" align="right">
|
<el-table-column label="面诊率" min-width="96" align="right">
|
||||||
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
|
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="面诊接诊率" min-width="116" align="right">
|
<el-table-column label="预约接诊率" min-width="116" align="right">
|
||||||
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
|
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="面诊接诊率" min-width="116" 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="96" align="right">
|
||||||
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
|
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -327,7 +330,7 @@ const timeOptions = [
|
|||||||
{ label: '自定义', value: 'custom' }
|
{ label: '自定义', value: 'custom' }
|
||||||
]
|
]
|
||||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
|
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户' },
|
||||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||||
|
|||||||
@@ -22,10 +22,37 @@
|
|||||||
</article>
|
</article>
|
||||||
<article :class="['overview-card', isOwnershipMode ? 'overview-card-completed' : 'overview-card-empty']">
|
<article :class="['overview-card', isOwnershipMode ? 'overview-card-completed' : 'overview-card-empty']">
|
||||||
<span>{{ isOwnershipMode ? '已完成' : '空号' }}</span>
|
<span>{{ isOwnershipMode ? '已完成' : '空号' }}</span>
|
||||||
<strong>{{ isOwnershipMode ? todayOverview.completed : todayOverview.emptySlots }}</strong>
|
<strong>{{ isOwnershipMode ? todayOverview.completed : todayOverview.remainingSlots }}</strong>
|
||||||
<small>{{ isOwnershipMode ? `已过号 ${todayOverview.missed} 人` : '未被有效挂号占用' }}</small>
|
<small v-if="isOwnershipMode">已过号 {{ todayOverview.missed }} 人</small>
|
||||||
|
<small v-else>过号 {{ todayOverview.passedSlots }} · 未挂号 {{ todayOverview.remainingSlots }}</small>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isOwnershipMode && todayRemainingDoctors.length" class="remaining-detail">
|
||||||
|
<div class="remaining-detail-heading">
|
||||||
|
<h3>医生剩余号源详情</h3>
|
||||||
|
<span>合计剩余可约 {{ todayOverview.remainingSlots }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="remaining-doctor-list">
|
||||||
|
<article
|
||||||
|
v-for="(doctor, index) in todayRemainingDoctors"
|
||||||
|
:key="doctor.doctor_id"
|
||||||
|
class="remaining-doctor-row"
|
||||||
|
>
|
||||||
|
<div class="remaining-doctor-identity">
|
||||||
|
<span class="doctor-index">{{ index + 1 }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ doctor.doctor_name }}</strong>
|
||||||
|
<small>{{ doctorWindows(doctor) }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="remaining-doctor-metrics">
|
||||||
|
<span>过号 <strong>{{ doctor.passed_slots || 0 }}</strong></span>
|
||||||
|
<span class="is-remaining">未挂号 / 剩余 <strong>{{ doctor.remaining_slots || 0 }}</strong></span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="board-section schedule-section">
|
<section class="board-section schedule-section">
|
||||||
@@ -55,7 +82,10 @@
|
|||||||
<span class="schedule-card-stats">
|
<span class="schedule-card-stats">
|
||||||
<span>{{ isOwnershipMode ? '待诊' : '已约' }} {{ isOwnershipMode ? day.waiting_appointments : day.booked_slots }}</span>
|
<span>{{ isOwnershipMode ? '待诊' : '已约' }} {{ isOwnershipMode ? day.waiting_appointments : day.booked_slots }}</span>
|
||||||
<i>/</i>
|
<i>/</i>
|
||||||
<span :class="{ 'is-completed': isOwnershipMode }">{{ isOwnershipMode ? '完成' : '空' }} {{ isOwnershipMode ? day.completed_appointments : day.empty_slots }}</span>
|
<span :class="{ 'is-completed': isOwnershipMode }">
|
||||||
|
{{ isOwnershipMode ? '完成' : '剩余' }}
|
||||||
|
{{ isOwnershipMode ? day.completed_appointments : (day.remaining_slots ?? day.empty_slots) }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="schedule-card-action">
|
<span class="schedule-card-action">
|
||||||
<span>{{ day.doctor_count }} 位医生</span>
|
<span>{{ day.doctor_count }} 位医生</span>
|
||||||
@@ -73,8 +103,14 @@
|
|||||||
<div :class="['drilldown-summary', { 'is-ownership': isOwnershipMode }]">
|
<div :class="['drilldown-summary', { 'is-ownership': isOwnershipMode }]">
|
||||||
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.total_appointments : selectedScheduleDay.total_slots }}</strong></span>
|
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.total_appointments : selectedScheduleDay.total_slots }}</strong></span>
|
||||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.waiting_appointments : selectedScheduleDay.booked_slots }}</strong></span>
|
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.waiting_appointments : selectedScheduleDay.booked_slots }}</strong></span>
|
||||||
<span>{{ isOwnershipMode ? '已完成' : '空号' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.completed_appointments : selectedScheduleDay.empty_slots }}</strong></span>
|
<template v-if="isOwnershipMode">
|
||||||
<span v-if="isOwnershipMode">已过号 <strong>{{ selectedScheduleDay.missed_appointments }}</strong></span>
|
<span>已完成 <strong>{{ selectedScheduleDay.completed_appointments }}</strong></span>
|
||||||
|
<span>已过号 <strong>{{ selectedScheduleDay.missed_appointments }}</strong></span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>过号 <strong>{{ selectedScheduleDay.passed_slots || 0 }}</strong></span>
|
||||||
|
<span>剩余可约 <strong>{{ selectedScheduleDay.remaining_slots ?? selectedScheduleDay.empty_slots }}</strong></span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,6 +119,7 @@
|
|||||||
v-for="(doctor, index) in selectedScheduleDoctors"
|
v-for="(doctor, index) in selectedScheduleDoctors"
|
||||||
:key="doctor.doctor_id"
|
:key="doctor.doctor_id"
|
||||||
class="doctor-schedule-row"
|
class="doctor-schedule-row"
|
||||||
|
:class="{ 'is-ownership': isOwnershipMode }"
|
||||||
>
|
>
|
||||||
<div class="doctor-identity">
|
<div class="doctor-identity">
|
||||||
<span class="doctor-index">{{ index + 1 }}</span>
|
<span class="doctor-index">{{ index + 1 }}</span>
|
||||||
@@ -103,10 +140,22 @@
|
|||||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
|
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
|
||||||
<strong>{{ isOwnershipMode ? doctor.waiting_appointments : doctor.booked_slots }}</strong>
|
<strong>{{ isOwnershipMode ? doctor.waiting_appointments : doctor.booked_slots }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['doctor-metric', isOwnershipMode ? 'doctor-metric-completed' : 'doctor-metric-empty']">
|
<template v-if="isOwnershipMode">
|
||||||
<span>{{ isOwnershipMode ? '完成 / 过号' : '空号' }}</span>
|
<div class="doctor-metric doctor-metric-completed">
|
||||||
<strong>{{ isOwnershipMode ? `${doctor.completed_appointments} / ${doctor.missed_appointments}` : doctor.empty_slots }}</strong>
|
<span>完成 / 过号</span>
|
||||||
|
<strong>{{ doctor.completed_appointments }} / {{ doctor.missed_appointments }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="doctor-metric doctor-metric-passed">
|
||||||
|
<span>过号</span>
|
||||||
|
<strong>{{ doctor.passed_slots || 0 }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="doctor-metric doctor-metric-empty">
|
||||||
|
<span>未挂号 / 剩余</span>
|
||||||
|
<strong>{{ doctor.remaining_slots ?? doctor.empty_slots }}</strong>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<el-empty
|
<el-empty
|
||||||
@@ -210,6 +259,12 @@ const todayOverview = computed(() => ({
|
|||||||
completed: Number(pager.extend?.today_overview?.completed || 0),
|
completed: Number(pager.extend?.today_overview?.completed || 0),
|
||||||
missed: Number(pager.extend?.today_overview?.missed || 0),
|
missed: Number(pager.extend?.today_overview?.missed || 0),
|
||||||
emptySlots: Number(pager.extend?.today_overview?.empty_slots || 0),
|
emptySlots: Number(pager.extend?.today_overview?.empty_slots || 0),
|
||||||
|
passedSlots: Number(pager.extend?.today_overview?.passed_slots || 0),
|
||||||
|
remainingSlots: Number(
|
||||||
|
pager.extend?.today_overview?.remaining_slots
|
||||||
|
?? pager.extend?.today_overview?.empty_slots
|
||||||
|
?? 0
|
||||||
|
),
|
||||||
doctorCount: Number(pager.extend?.today_overview?.doctor_count || 0)
|
doctorCount: Number(pager.extend?.today_overview?.doctor_count || 0)
|
||||||
}))
|
}))
|
||||||
const isOwnershipMode = computed(() => pager.extend?.schedule_mode === 'ownership')
|
const isOwnershipMode = computed(() => pager.extend?.schedule_mode === 'ownership')
|
||||||
@@ -226,6 +281,8 @@ const weekSchedule = computed(() => {
|
|||||||
weekday: `周${'日一二三四五六'[date.day()]}`,
|
weekday: `周${'日一二三四五六'[date.day()]}`,
|
||||||
total_slots: 0,
|
total_slots: 0,
|
||||||
booked_slots: 0,
|
booked_slots: 0,
|
||||||
|
passed_slots: 0,
|
||||||
|
remaining_slots: 0,
|
||||||
empty_slots: 0,
|
empty_slots: 0,
|
||||||
doctor_count: 0,
|
doctor_count: 0,
|
||||||
doctors: [],
|
doctors: [],
|
||||||
@@ -244,6 +301,8 @@ const selectedScheduleDay = computed(() => {
|
|||||||
weekday: `周${'日一二三四五六'[dayjs().day()]}`,
|
weekday: `周${'日一二三四五六'[dayjs().day()]}`,
|
||||||
total_slots: 0,
|
total_slots: 0,
|
||||||
booked_slots: 0,
|
booked_slots: 0,
|
||||||
|
passed_slots: 0,
|
||||||
|
remaining_slots: 0,
|
||||||
empty_slots: 0,
|
empty_slots: 0,
|
||||||
doctor_count: 0,
|
doctor_count: 0,
|
||||||
doctors: [],
|
doctors: [],
|
||||||
@@ -256,6 +315,21 @@ const selectedScheduleDay = computed(() => {
|
|||||||
const selectedScheduleDoctors = computed(() => {
|
const selectedScheduleDoctors = computed(() => {
|
||||||
return Array.isArray(selectedScheduleDay.value?.doctors) ? selectedScheduleDay.value.doctors : []
|
return Array.isArray(selectedScheduleDay.value?.doctors) ? selectedScheduleDay.value.doctors : []
|
||||||
})
|
})
|
||||||
|
const todayRemainingDoctors = computed(() => {
|
||||||
|
const todayDay = weekSchedule.value.find((day: any) => day.date === today)
|
||||||
|
const doctors = Array.isArray(todayDay?.doctors) ? todayDay.doctors : []
|
||||||
|
return doctors
|
||||||
|
.map((doctor: any) => ({
|
||||||
|
...doctor,
|
||||||
|
remaining_slots: Number(doctor.remaining_slots ?? doctor.empty_slots ?? 0),
|
||||||
|
passed_slots: Number(doctor.passed_slots || 0)
|
||||||
|
}))
|
||||||
|
.sort((left: any, right: any) =>
|
||||||
|
right.remaining_slots - left.remaining_slots
|
||||||
|
|| right.booked_slots - left.booked_slots
|
||||||
|
|| String(left.doctor_name || '').localeCompare(String(right.doctor_name || ''), 'zh-CN')
|
||||||
|
)
|
||||||
|
})
|
||||||
const selectedScheduleLabel = computed(() => {
|
const selectedScheduleLabel = computed(() => {
|
||||||
const day = selectedScheduleDay.value
|
const day = selectedScheduleDay.value
|
||||||
return `${day.date_text || ''} ${day.weekday || ''} `
|
return `${day.date_text || ''} ${day.weekday || ''} `
|
||||||
@@ -433,6 +507,106 @@ onUnmounted(() => {
|
|||||||
color: #07886d;
|
color: #07886d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remaining-detail {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #e8edf3;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remaining-detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #0f766e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remaining-doctor-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remaining-doctor-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #e4e9ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remaining-doctor-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong,
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #202b3d;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #9aa4b2;
|
||||||
|
font-size: 10px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.remaining-doctor-metrics {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
color: #7a8698;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin-left: 3px;
|
||||||
|
color: #243044;
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-remaining strong {
|
||||||
|
color: #ee4d55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-range,
|
.schedule-range,
|
||||||
.refresh-time,
|
.refresh-time,
|
||||||
.queue-count {
|
.queue-count {
|
||||||
@@ -597,7 +771,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.doctor-schedule-row {
|
.doctor-schedule-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(3, minmax(64px, 0.55fr));
|
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(4, minmax(64px, 0.55fr));
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
@@ -605,6 +779,10 @@ onUnmounted(() => {
|
|||||||
border: 1px solid #e4e9ef;
|
border: 1px solid #e4e9ef;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
||||||
|
&.is-ownership {
|
||||||
|
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(3, minmax(64px, 0.55fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-identity {
|
.doctor-identity {
|
||||||
@@ -691,6 +869,10 @@ onUnmounted(() => {
|
|||||||
color: #07886d;
|
color: #07886d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doctor-metric-passed strong {
|
||||||
|
color: #98a2b3;
|
||||||
|
}
|
||||||
|
|
||||||
.doctor-metric-empty strong {
|
.doctor-metric-empty strong {
|
||||||
color: #d17820;
|
color: #d17820;
|
||||||
}
|
}
|
||||||
@@ -792,7 +974,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.doctor-schedule-row {
|
.doctor-schedule-row {
|
||||||
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) repeat(3, minmax(56px, 0.5fr));
|
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) repeat(4, minmax(56px, 0.5fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -818,17 +1000,20 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drilldown-heading,
|
.drilldown-heading,
|
||||||
.drilldown-summary {
|
.drilldown-summary,
|
||||||
|
.remaining-detail-heading,
|
||||||
|
.remaining-doctor-row {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drilldown-summary {
|
.drilldown-summary,
|
||||||
|
.remaining-doctor-metrics {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-schedule-row {
|
.doctor-schedule-row {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.doctor-identity,
|
.doctor-identity,
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项
|
|||||||
|
|
||||||
## 已实现范围
|
## 已实现范围
|
||||||
|
|
||||||
- 账号密码登录、token 会话、记住账号(不保存密码)和退出登录。
|
- 账号密码登录、token 会话、通过系统凭据管理器安全记住密码和退出登录。
|
||||||
- 登录后读取 `/adminapi/auth.admin/mySelf`,按 `permissions` 动态控制页面和操作按钮;`*` 超级权限兼容现有后台。
|
- 登录后读取 `/adminapi/auth.admin/mySelf`,按 `permissions` 动态控制页面和操作按钮;`*` 超级权限兼容现有后台。
|
||||||
- 接诊台:今日待接诊/已过号、患者详情、医生备注、通知医助、完成接诊、发起视频面诊。
|
- 接诊台:今日待接诊/已过号、患者详情、医生备注、通知医助、完成接诊、发起视频面诊。
|
||||||
- 挂号列表:对齐管理端 `tcm/appointment/list`,支持状态 Tab、日期/确认/部门筛选、通话、完成、开方与取消挂号。
|
- 挂号列表:对齐管理端 `tcm/appointment/list`,支持状态 Tab、日期/确认/部门筛选、通话、完成、开方与取消挂号。
|
||||||
@@ -76,7 +76,7 @@ DOCTOR_VERIFY_SSL=true
|
|||||||
- 响应 envelope:`code=1` 成功、`0` 业务失败、`-1` 登录失效、`10` 需绑定企业微信。
|
- 响应 envelope:`code=1` 成功、`0` 业务失败、`-1` 登录失效、`10` 需绑定企业微信。
|
||||||
- 权限与数据范围:完全以后端 `/auth.admin/mySelf` 返回为准。
|
- 权限与数据范围:完全以后端 `/auth.admin/mySelf` 返回为准。
|
||||||
|
|
||||||
环境配置不会保存密码、TRTC SecretKey 或腾讯云长期凭据。登录 token 优先存入系统凭据库;无法使用时仅回退到用户配置目录中的受限文件。
|
环境配置不会保存明文密码、TRTC SecretKey 或腾讯云长期凭据。登录密码优先存入系统凭据管理器;Windows 凭据后端不可用时仅保存当前 Windows 用户可解密的 DPAPI 密文。登录 token 优先存入系统凭据库,无法使用时仅回退到用户配置目录中的受限文件。
|
||||||
|
|
||||||
## 视频伴随页
|
## 视频伴随页
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import time
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Qt, QTimer
|
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
|
||||||
from PySide6.QtGui import QGuiApplication, QIcon
|
from PySide6.QtGui import QGuiApplication, QIcon
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -46,6 +46,70 @@ from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
|||||||
LOGGER = logging.getLogger(__name__)
|
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]
|
||||||
|
|
||||||
|
|
||||||
class _UnconfiguredRepository:
|
class _UnconfiguredRepository:
|
||||||
"""Login boundary used until an administrator supplies a backend URL."""
|
"""Login boundary used until an administrator supplies a backend URL."""
|
||||||
|
|
||||||
@@ -204,6 +268,7 @@ class ApplicationController(QObject):
|
|||||||
self._base_repository(),
|
self._base_repository(),
|
||||||
self.config,
|
self.config,
|
||||||
self.demo_repository,
|
self.demo_repository,
|
||||||
|
credential_store=self.token_store,
|
||||||
)
|
)
|
||||||
self.login_window.login_succeeded.connect(self._on_login_succeeded)
|
self.login_window.login_succeeded.connect(self._on_login_succeeded)
|
||||||
self.login_window.config_changed.connect(self._on_config_changed)
|
self.login_window.config_changed.connect(self._on_config_changed)
|
||||||
@@ -214,6 +279,7 @@ class ApplicationController(QObject):
|
|||||||
self.login_window.config = self.config
|
self.login_window.config = self.config
|
||||||
if not self.login_window.demo_check.isChecked():
|
if not self.login_window.demo_check.isChecked():
|
||||||
self.login_window.active_repository = self._base_repository()
|
self.login_window.active_repository = self._base_repository()
|
||||||
|
self.login_window.restore_remembered_credentials()
|
||||||
self.login_window.show()
|
self.login_window.show()
|
||||||
self.login_window.raise_()
|
self.login_window.raise_()
|
||||||
self.login_window.activateWindow()
|
self.login_window.activateWindow()
|
||||||
@@ -500,19 +566,39 @@ class ApplicationController(QObject):
|
|||||||
patient_id = payload.get("patient_id")
|
patient_id = payload.get("patient_id")
|
||||||
diagnosis_id = payload.get("diagnosis_id")
|
diagnosis_id = payload.get("diagnosis_id")
|
||||||
patient_name = str(payload.get("patient_name") or "患者")
|
patient_name = str(payload.get("patient_name") or "患者")
|
||||||
|
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||||
return
|
return
|
||||||
|
|
||||||
call_key = str(diagnosis_id)
|
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 (
|
if (
|
||||||
call_key in self.video_pending
|
call_key in self.video_pending
|
||||||
or call_key in self.video_calls
|
or existing_call is not None
|
||||||
or call_key in self.demo_video_dialogs
|
or call_key in self.demo_video_dialogs
|
||||||
):
|
):
|
||||||
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
||||||
return
|
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:
|
if self.current_demo_mode:
|
||||||
dialog = DemoVideoDialog(patient_name, parent)
|
dialog = DemoVideoDialog(patient_name, parent)
|
||||||
dialog.finished.connect(
|
dialog.finished.connect(
|
||||||
@@ -525,7 +611,11 @@ class ApplicationController(QObject):
|
|||||||
dialog.show()
|
dialog.show()
|
||||||
return
|
return
|
||||||
|
|
||||||
show_toast(parent, "正在获取安全通话凭证…", "info")
|
show_toast(
|
||||||
|
parent,
|
||||||
|
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
repository = self.current_repository
|
repository = self.current_repository
|
||||||
marker = object()
|
marker = object()
|
||||||
self.video_pending[call_key] = marker
|
self.video_pending[call_key] = marker
|
||||||
@@ -536,6 +626,9 @@ class ApplicationController(QObject):
|
|||||||
diagnosis_id=int(diagnosis_id),
|
diagnosis_id=int(diagnosis_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def request_ticket() -> None:
|
||||||
|
if self.video_pending.get(call_key) is not marker:
|
||||||
|
return
|
||||||
run_async(
|
run_async(
|
||||||
get_ticket,
|
get_ticket,
|
||||||
on_success=lambda ticket: self._launch_video(
|
on_success=lambda ticket: self._launch_video(
|
||||||
@@ -545,6 +638,8 @@ class ApplicationController(QObject):
|
|||||||
repository=repository,
|
repository=repository,
|
||||||
call_key=call_key,
|
call_key=call_key,
|
||||||
marker=marker,
|
marker=marker,
|
||||||
|
open_im=open_im,
|
||||||
|
patient_name=patient_name,
|
||||||
),
|
),
|
||||||
on_error=lambda error: self._video_ticket_error(
|
on_error=lambda error: self._video_ticket_error(
|
||||||
call_key,
|
call_key,
|
||||||
@@ -554,6 +649,13 @@ class ApplicationController(QObject):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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(
|
def _video_ticket_error(
|
||||||
self,
|
self,
|
||||||
call_key: str,
|
call_key: str,
|
||||||
@@ -581,6 +683,8 @@ class ApplicationController(QObject):
|
|||||||
repository: Any,
|
repository: Any,
|
||||||
call_key: str,
|
call_key: str,
|
||||||
marker: object,
|
marker: object,
|
||||||
|
open_im: bool = False,
|
||||||
|
patient_name: str = "患者",
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.video_pending.get(call_key) is not marker:
|
if self.video_pending.get(call_key) is not marker:
|
||||||
return
|
return
|
||||||
@@ -606,6 +710,8 @@ class ApplicationController(QObject):
|
|||||||
local_dist=video_dist_path(),
|
local_dist=video_dist_path(),
|
||||||
remote_url=self.config.video_web_url or None,
|
remote_url=self.config.video_web_url or None,
|
||||||
logger=logging.getLogger("doctor_workstation.video"),
|
logger=logging.getLogger("doctor_workstation.video"),
|
||||||
|
open_im=open_im,
|
||||||
|
patient_name=patient_name,
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
LOGGER.exception("video call could not be launched")
|
LOGGER.exception("video call could not be launched")
|
||||||
@@ -683,6 +789,7 @@ def _create_application(argv: list[str]) -> QApplication:
|
|||||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||||
)
|
)
|
||||||
application = QApplication(argv)
|
application = QApplication(argv)
|
||||||
|
_install_chinese_translations(application)
|
||||||
application.setApplicationName("甄养堂医生工作站")
|
application.setApplicationName("甄养堂医生工作站")
|
||||||
application.setApplicationDisplayName("甄养堂医生工作站")
|
application.setApplicationDisplayName("甄养堂医生工作站")
|
||||||
application.setOrganizationName("ZhenYangTang")
|
application.setOrganizationName("ZhenYangTang")
|
||||||
|
|||||||
@@ -593,6 +593,28 @@ class DemoDoctorRepository:
|
|||||||
safe_name = source.name.replace("\\", "_").replace("/", "_")
|
safe_name = source.name.replace("\\", "_").replace("/", "_")
|
||||||
return f"/demo/uploads/{kind}/{material_id}-{safe_name}"
|
return f"/demo/uploads/{kind}/{material_id}-{safe_name}"
|
||||||
|
|
||||||
|
def upload_material_bytes(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
filename: str,
|
||||||
|
material_type: Literal["image", "video", "file"],
|
||||||
|
cid: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Return a synthetic server URI for an in-memory demo capture."""
|
||||||
|
|
||||||
|
if cid < 0:
|
||||||
|
raise ValueError("cid must be non-negative")
|
||||||
|
if not content:
|
||||||
|
raise ValueError("material content must not be empty")
|
||||||
|
safe_name = Path(filename).name.strip().replace("\\", "_").replace("/", "_")
|
||||||
|
if not safe_name:
|
||||||
|
raise ValueError("filename is required")
|
||||||
|
kind = _material_kind(material_type)
|
||||||
|
with self._lock:
|
||||||
|
material_id = self._next_material_id
|
||||||
|
self._next_material_id += 1
|
||||||
|
return f"/demo/uploads/{kind}/{material_id}-{safe_name}"
|
||||||
|
|
||||||
def add_doctor_note(
|
def add_doctor_note(
|
||||||
self,
|
self,
|
||||||
diagnosis_id: int,
|
diagnosis_id: int,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import time
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from io import BytesIO
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Final, Literal, Protocol
|
from typing import Any, Final, Literal, Protocol
|
||||||
@@ -109,6 +110,15 @@ class DoctorRepository(Protocol):
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Upload one local note material and return its server URI."""
|
"""Upload one local note material and return its server URI."""
|
||||||
|
|
||||||
|
def upload_material_bytes(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
filename: str,
|
||||||
|
material_type: Literal["image", "video", "file"],
|
||||||
|
cid: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Upload an in-memory capture and return its server URI."""
|
||||||
|
|
||||||
def get_prescription_template(self, template_id: int) -> PrescriptionTemplate:
|
def get_prescription_template(self, template_id: int) -> PrescriptionTemplate:
|
||||||
"""Return one prescription-library record."""
|
"""Return one prescription-library record."""
|
||||||
|
|
||||||
@@ -960,6 +970,34 @@ class RemoteDoctorRepository:
|
|||||||
)
|
)
|
||||||
return _normalise_material_reference(payload, endpoint)
|
return _normalise_material_reference(payload, endpoint)
|
||||||
|
|
||||||
|
def upload_material_bytes(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
filename: str,
|
||||||
|
material_type: Literal["image", "video", "file"],
|
||||||
|
cid: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Upload a trusted in-memory capture without a local plaintext file."""
|
||||||
|
|
||||||
|
if cid < 0:
|
||||||
|
raise ValueError("cid must be non-negative")
|
||||||
|
if not content:
|
||||||
|
raise ValueError("material content must not be empty")
|
||||||
|
if len(content) > 10 * 1024 * 1024:
|
||||||
|
raise ValueError("material content exceeds 10 MB")
|
||||||
|
safe_name = Path(filename).name.strip()
|
||||||
|
if not safe_name or safe_name in {".", ".."}:
|
||||||
|
raise ValueError("filename is required")
|
||||||
|
kind = _material_kind(material_type)
|
||||||
|
mime_type = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
|
||||||
|
endpoint = f"upload/{kind}"
|
||||||
|
payload = self.client.post_multipart(
|
||||||
|
endpoint,
|
||||||
|
files={"file": (safe_name, BytesIO(content), mime_type)},
|
||||||
|
data={"cid": str(cid)},
|
||||||
|
)
|
||||||
|
return _normalise_material_reference(payload, endpoint)
|
||||||
|
|
||||||
def notify_assistant(self, appointment_id: int) -> Any:
|
def notify_assistant(self, appointment_id: int) -> Any:
|
||||||
"""Ask the server to notify the assigned medical assistant."""
|
"""Ask the server to notify the assigned medical assistant."""
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import ctypes
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
@@ -28,12 +31,13 @@ _AUTO_KEYRING = object()
|
|||||||
|
|
||||||
|
|
||||||
class TokenStore:
|
class TokenStore:
|
||||||
"""Store access tokens, but never user passwords.
|
"""Store tokens and optional login passwords with OS-protected storage.
|
||||||
|
|
||||||
If a working ``keyring`` backend is importable, the token is stored there
|
If a working ``keyring`` backend is importable, the token is stored there
|
||||||
and the JSON file contains at most the remembered account name and the
|
and the JSON file contains at most the remembered account name and the
|
||||||
non-secret API scope. Otherwise the JSON file is atomically written with
|
non-secret API scope. Login passwords use the keyring first; on Windows,
|
||||||
owner-only ``0600`` permissions.
|
an unavailable keyring falls back to a DPAPI-encrypted blob that only the
|
||||||
|
current Windows user can decrypt. Plaintext passwords are never written.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -103,7 +107,7 @@ class TokenStore:
|
|||||||
"""Persist a token plus optional account and API-scope metadata.
|
"""Persist a token plus optional account and API-scope metadata.
|
||||||
|
|
||||||
Passing an empty ``account`` explicitly forgets a previously remembered
|
Passing an empty ``account`` explicitly forgets a previously remembered
|
||||||
account. Passwords are never accepted or persisted.
|
account and its keyring password.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cleaned = token.strip()
|
cleaned = token.strip()
|
||||||
@@ -115,6 +119,17 @@ class TokenStore:
|
|||||||
data = self._read_file()
|
data = self._read_file()
|
||||||
if account is not None:
|
if account is not None:
|
||||||
account_value = account.strip()
|
account_value = account.strip()
|
||||||
|
previous_account = str(data.get("account") or "").strip()
|
||||||
|
previous_scope = self._normalise_scope(data.get("scope"))
|
||||||
|
next_scope = (
|
||||||
|
self._normalise_scope(scope) if scope is not None else previous_scope
|
||||||
|
)
|
||||||
|
if previous_account and (
|
||||||
|
previous_account != account_value
|
||||||
|
or (account_value and previous_scope != next_scope)
|
||||||
|
):
|
||||||
|
self.clear_password(account=previous_account, scope=previous_scope)
|
||||||
|
data.pop("credential", None)
|
||||||
if account_value:
|
if account_value:
|
||||||
data["account"] = account_value
|
data["account"] = account_value
|
||||||
else:
|
else:
|
||||||
@@ -137,7 +152,7 @@ class TokenStore:
|
|||||||
self._write_file(data)
|
self._write_file(data)
|
||||||
|
|
||||||
def clear_token(self) -> None:
|
def clear_token(self) -> None:
|
||||||
"""Delete the token while retaining an explicitly remembered account."""
|
"""Delete the token while retaining remembered login credentials."""
|
||||||
|
|
||||||
if self._uses_keyring and self._keyring is not None:
|
if self._uses_keyring and self._keyring is not None:
|
||||||
try:
|
try:
|
||||||
@@ -146,11 +161,12 @@ class TokenStore:
|
|||||||
self._uses_keyring = False
|
self._uses_keyring = False
|
||||||
data = self._read_file()
|
data = self._read_file()
|
||||||
data.pop("token", None)
|
data.pop("token", None)
|
||||||
|
if not data.get("account"):
|
||||||
data.pop("scope", None)
|
data.pop("scope", None)
|
||||||
self._write_file(data)
|
self._write_file(data)
|
||||||
|
|
||||||
def load_account(self) -> str | None:
|
def load_account(self) -> str | None:
|
||||||
"""Load the remembered login account; no password is ever stored."""
|
"""Load the non-secret remembered login account."""
|
||||||
|
|
||||||
value = self._read_file().get("account")
|
value = self._read_file().get("account")
|
||||||
return str(value) if isinstance(value, str) and value else None
|
return str(value) if isinstance(value, str) and value else None
|
||||||
@@ -167,13 +183,104 @@ class TokenStore:
|
|||||||
self._write_file(data)
|
self._write_file(data)
|
||||||
|
|
||||||
def clear_account(self) -> None:
|
def clear_account(self) -> None:
|
||||||
"""Forget the remembered account without changing the stored token."""
|
"""Forget the remembered account and its keyring password."""
|
||||||
|
|
||||||
|
data = self._read_file()
|
||||||
|
self.clear_password(
|
||||||
|
account=str(data.get("account") or ""),
|
||||||
|
scope=str(data.get("scope") or ""),
|
||||||
|
)
|
||||||
self.save_account("")
|
self.save_account("")
|
||||||
|
|
||||||
|
def load_password(self, *, account: str, scope: str) -> str | None:
|
||||||
|
"""Load one scoped password from keyring or a Windows DPAPI blob."""
|
||||||
|
|
||||||
|
target = self._password_target(account, scope)
|
||||||
|
if not target:
|
||||||
|
return None
|
||||||
|
if self._uses_keyring and self._keyring is not None:
|
||||||
|
try:
|
||||||
|
value = self._keyring.get_password(self.service_name, target)
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
except Exception:
|
||||||
|
self._uses_keyring = False
|
||||||
|
data = self._read_file()
|
||||||
|
if self._password_target(
|
||||||
|
str(data.get("account") or ""),
|
||||||
|
str(data.get("scope") or ""),
|
||||||
|
) != target:
|
||||||
|
return None
|
||||||
|
credential = data.get("credential")
|
||||||
|
if not isinstance(credential, str) or not credential:
|
||||||
|
return None
|
||||||
|
return self._unprotect_password(credential, target)
|
||||||
|
|
||||||
|
def save_password(self, password: str, *, account: str, scope: str) -> bool:
|
||||||
|
"""Save one password using keyring or Windows user-scoped DPAPI."""
|
||||||
|
|
||||||
|
target = self._password_target(account, scope)
|
||||||
|
if not target or not password:
|
||||||
|
return False
|
||||||
|
data = self._read_file()
|
||||||
|
previous_target = self._password_target(
|
||||||
|
str(data.get("account") or ""),
|
||||||
|
str(data.get("scope") or ""),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
previous_target
|
||||||
|
and previous_target != target
|
||||||
|
and self._uses_keyring
|
||||||
|
and self._keyring is not None
|
||||||
|
):
|
||||||
|
with suppress(Exception):
|
||||||
|
self._keyring.delete_password(self.service_name, previous_target)
|
||||||
|
if self._uses_keyring and self._keyring is not None:
|
||||||
|
try:
|
||||||
|
self._keyring.set_password(self.service_name, target, password)
|
||||||
|
except Exception:
|
||||||
|
self._uses_keyring = False
|
||||||
|
else:
|
||||||
|
data["account"] = account.strip()
|
||||||
|
data["scope"] = self._normalise_scope(scope)
|
||||||
|
data.pop("credential", None)
|
||||||
|
self._write_file(data)
|
||||||
|
return True
|
||||||
|
protected = self._protect_password(password, target)
|
||||||
|
if not protected:
|
||||||
|
return False
|
||||||
|
data["account"] = account.strip()
|
||||||
|
data["scope"] = self._normalise_scope(scope)
|
||||||
|
data["credential"] = protected
|
||||||
|
self._write_file(data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear_password(self, *, account: str, scope: str) -> None:
|
||||||
|
"""Delete one scoped login password from the OS keyring."""
|
||||||
|
|
||||||
|
target = self._password_target(account, scope)
|
||||||
|
if not target:
|
||||||
|
return
|
||||||
|
if self._uses_keyring and self._keyring is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
self._keyring.delete_password(self.service_name, target)
|
||||||
|
data = self._read_file()
|
||||||
|
stored_target = self._password_target(
|
||||||
|
str(data.get("account") or ""),
|
||||||
|
str(data.get("scope") or ""),
|
||||||
|
)
|
||||||
|
if stored_target == target and "credential" in data:
|
||||||
|
data.pop("credential", None)
|
||||||
|
self._write_file(data)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Delete both token and remembered account information."""
|
"""Delete both token and remembered account information."""
|
||||||
|
|
||||||
|
data = self._read_file()
|
||||||
|
self.clear_password(
|
||||||
|
account=str(data.get("account") or ""),
|
||||||
|
scope=str(data.get("scope") or ""),
|
||||||
|
)
|
||||||
if self._uses_keyring and self._keyring is not None:
|
if self._uses_keyring and self._keyring is not None:
|
||||||
try:
|
try:
|
||||||
self._keyring.delete_password(self.service_name, self.token_name)
|
self._keyring.delete_password(self.service_name, self.token_name)
|
||||||
@@ -225,6 +332,120 @@ class TokenStore:
|
|||||||
|
|
||||||
return str(value or "").strip().rstrip("/")
|
return str(value or "").strip().rstrip("/")
|
||||||
|
|
||||||
|
def _password_target(self, account: str, scope: str) -> str:
|
||||||
|
"""Return an opaque keyring name isolated by account and API server."""
|
||||||
|
|
||||||
|
clean_account = account.strip().casefold()
|
||||||
|
clean_scope = self._normalise_scope(scope).casefold()
|
||||||
|
if not clean_account or not clean_scope:
|
||||||
|
return ""
|
||||||
|
digest = hashlib.sha256(f"{clean_scope}\n{clean_account}".encode()).hexdigest()
|
||||||
|
return f"login-password:{digest}"
|
||||||
|
|
||||||
|
def _protect_password(self, password: str, target: str) -> str | None:
|
||||||
|
"""Return a Windows DPAPI blob, or ``None`` on unsupported systems."""
|
||||||
|
|
||||||
|
if os.name != "nt":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
encrypted = self._crypt_protect(
|
||||||
|
password.encode("utf-8"),
|
||||||
|
self._credential_entropy(target),
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return base64.b64encode(encrypted).decode("ascii")
|
||||||
|
|
||||||
|
def _unprotect_password(self, protected: str, target: str) -> str | None:
|
||||||
|
"""Decrypt a Windows DPAPI blob for this account/server target."""
|
||||||
|
|
||||||
|
if os.name != "nt":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
encrypted = base64.b64decode(protected, validate=True)
|
||||||
|
plaintext = self._crypt_unprotect(
|
||||||
|
encrypted,
|
||||||
|
self._credential_entropy(target),
|
||||||
|
)
|
||||||
|
return plaintext.decode("utf-8")
|
||||||
|
except (OSError, UnicodeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _credential_entropy(self, target: str) -> bytes:
|
||||||
|
return hashlib.sha256(f"{self.service_name}\n{target}".encode()).digest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _crypt_protect(data: bytes, entropy: bytes) -> bytes:
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
class DataBlob(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("size", wintypes.DWORD),
|
||||||
|
("data", ctypes.POINTER(ctypes.c_ubyte)),
|
||||||
|
]
|
||||||
|
|
||||||
|
def make_blob(value: bytes) -> tuple[DataBlob, ctypes.Array[Any]]:
|
||||||
|
buffer = ctypes.create_string_buffer(value)
|
||||||
|
blob = DataBlob(
|
||||||
|
len(value),
|
||||||
|
ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)),
|
||||||
|
)
|
||||||
|
return blob, buffer
|
||||||
|
|
||||||
|
source, _source_buffer = make_blob(data)
|
||||||
|
optional_entropy, _entropy_buffer = make_blob(entropy)
|
||||||
|
destination = DataBlob()
|
||||||
|
if not ctypes.windll.crypt32.CryptProtectData(
|
||||||
|
ctypes.byref(source),
|
||||||
|
None,
|
||||||
|
ctypes.byref(optional_entropy),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0x01,
|
||||||
|
ctypes.byref(destination),
|
||||||
|
):
|
||||||
|
raise ctypes.WinError()
|
||||||
|
try:
|
||||||
|
return ctypes.string_at(destination.data, destination.size)
|
||||||
|
finally:
|
||||||
|
ctypes.windll.kernel32.LocalFree(destination.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _crypt_unprotect(data: bytes, entropy: bytes) -> bytes:
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
class DataBlob(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("size", wintypes.DWORD),
|
||||||
|
("data", ctypes.POINTER(ctypes.c_ubyte)),
|
||||||
|
]
|
||||||
|
|
||||||
|
def make_blob(value: bytes) -> tuple[DataBlob, ctypes.Array[Any]]:
|
||||||
|
buffer = ctypes.create_string_buffer(value)
|
||||||
|
blob = DataBlob(
|
||||||
|
len(value),
|
||||||
|
ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)),
|
||||||
|
)
|
||||||
|
return blob, buffer
|
||||||
|
|
||||||
|
source, _source_buffer = make_blob(data)
|
||||||
|
optional_entropy, _entropy_buffer = make_blob(entropy)
|
||||||
|
destination = DataBlob()
|
||||||
|
if not ctypes.windll.crypt32.CryptUnprotectData(
|
||||||
|
ctypes.byref(source),
|
||||||
|
None,
|
||||||
|
ctypes.byref(optional_entropy),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0x01,
|
||||||
|
ctypes.byref(destination),
|
||||||
|
):
|
||||||
|
raise ctypes.WinError()
|
||||||
|
try:
|
||||||
|
return ctypes.string_at(destination.data, destination.size)
|
||||||
|
finally:
|
||||||
|
ctypes.windll.kernel32.LocalFree(destination.data)
|
||||||
|
|
||||||
def _read_file(self) -> dict[str, Any]:
|
def _read_file(self) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
content = self.path.read_text(encoding="utf-8")
|
content = self.path.read_text(encoding="utf-8")
|
||||||
@@ -234,10 +455,18 @@ class TokenStore:
|
|||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
return {}
|
||||||
# Explicit allow-list guarantees accidental password-like fields are ignored.
|
# Explicit allow-list guarantees accidental password-like fields are ignored.
|
||||||
return {key: value[key] for key in ("token", "account", "scope") if key in value}
|
return {
|
||||||
|
key: value[key]
|
||||||
|
for key in ("token", "account", "scope", "credential")
|
||||||
|
if key in value
|
||||||
|
}
|
||||||
|
|
||||||
def _write_file(self, data: dict[str, Any]) -> None:
|
def _write_file(self, data: dict[str, Any]) -> None:
|
||||||
safe = {key: data[key] for key in ("token", "account", "scope") if data.get(key)}
|
safe = {
|
||||||
|
key: data[key]
|
||||||
|
for key in ("token", "account", "scope", "credential")
|
||||||
|
if data.get(key)
|
||||||
|
}
|
||||||
if not safe:
|
if not safe:
|
||||||
with suppress(OSError):
|
with suppress(OSError):
|
||||||
self.path.unlink(missing_ok=True)
|
self.path.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import suppress
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QPoint, QSettings, Qt, QTimer, Signal
|
from PySide6.QtCore import QPoint, QSettings, Qt, QTimer, Signal
|
||||||
@@ -70,8 +71,9 @@ class LoginWindow(QMainWindow):
|
|||||||
``login_succeeded`` emits a dictionary containing ``user``, ``session``,
|
``login_succeeded`` emits a dictionary containing ``user``, ``session``,
|
||||||
``repository`` and ``demo_mode``. Keeping the selected repository in the
|
``repository`` and ``demo_mode``. Keeping the selected repository in the
|
||||||
payload lets the composition root construct the shell without guessing.
|
payload lets the composition root construct the shell without guessing.
|
||||||
``remember_account`` is captured before the worker starts and is forwarded
|
The remember-password choice is captured before the worker starts. The
|
||||||
to the repository so every account metadata store follows the same choice.
|
repository receives the corresponding account-metadata flag, while this
|
||||||
|
window stores the password only after authentication succeeds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
login_succeeded = Signal(object)
|
login_succeeded = Signal(object)
|
||||||
@@ -87,6 +89,7 @@ class LoginWindow(QMainWindow):
|
|||||||
config: Any | None = None,
|
config: Any | None = None,
|
||||||
demo_repository: Any | None = None,
|
demo_repository: Any | None = None,
|
||||||
settings: QSettings | None = None,
|
settings: QSettings | None = None,
|
||||||
|
credential_store: Any | None = None,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -96,9 +99,12 @@ class LoginWindow(QMainWindow):
|
|||||||
demo_repository = getattr(config, "demo_repository", None)
|
demo_repository = getattr(config, "demo_repository", None)
|
||||||
self.demo_repository = demo_repository
|
self.demo_repository = demo_repository
|
||||||
self.settings = settings or QSettings("ZhenYangTang", "DoctorWorkstation")
|
self.settings = settings or QSettings("ZhenYangTang", "DoctorWorkstation")
|
||||||
|
self.credential_store = credential_store
|
||||||
self.active_repository = repository
|
self.active_repository = repository
|
||||||
self.authenticated_user: Any = None
|
self.authenticated_user: Any = None
|
||||||
self._loading = False
|
self._loading = False
|
||||||
|
self._restored_account = ""
|
||||||
|
self._restored_scope = ""
|
||||||
|
|
||||||
self.setWindowTitle("甄养堂 · 医生工作站")
|
self.setWindowTitle("甄养堂 · 医生工作站")
|
||||||
self.setMinimumSize(860, 590)
|
self.setMinimumSize(860, 590)
|
||||||
@@ -214,7 +220,7 @@ class LoginWindow(QMainWindow):
|
|||||||
layout.addLayout(brand_row)
|
layout.addLayout(brand_row)
|
||||||
layout.addStretch(2)
|
layout.addStretch(2)
|
||||||
|
|
||||||
eyebrow = QLabel("DOCTOR WORKSTATION")
|
eyebrow = QLabel("医生工作站")
|
||||||
eyebrow.setStyleSheet(
|
eyebrow.setStyleSheet(
|
||||||
"color:#4F63D9; font-size:11px; font-weight:700; letter-spacing:1px;"
|
"color:#4F63D9; font-size:11px; font-weight:700; letter-spacing:1px;"
|
||||||
)
|
)
|
||||||
@@ -300,8 +306,8 @@ class LoginWindow(QMainWindow):
|
|||||||
card_layout.addLayout(password_row)
|
card_layout.addLayout(password_row)
|
||||||
|
|
||||||
choices = QHBoxLayout()
|
choices = QHBoxLayout()
|
||||||
self.remember_check = _VisibleCheckBox("记住账号")
|
self.remember_check = _VisibleCheckBox("记住密码")
|
||||||
self.remember_check.setToolTip("仅保存账号,不保存密码")
|
self.remember_check.setToolTip("密码使用系统安全凭据或 Windows DPAPI 加密,不保存明文")
|
||||||
choices.addWidget(self.remember_check)
|
choices.addWidget(self.remember_check)
|
||||||
choices.addStretch(1)
|
choices.addStretch(1)
|
||||||
self.demo_check = _VisibleCheckBox("演示模式")
|
self.demo_check = _VisibleCheckBox("演示模式")
|
||||||
@@ -394,8 +400,6 @@ class LoginWindow(QMainWindow):
|
|||||||
def _restore_settings(self) -> None:
|
def _restore_settings(self) -> None:
|
||||||
configured_account = getattr(self.config, "remembered_account", "")
|
configured_account = getattr(self.config, "remembered_account", "")
|
||||||
remembered = str(self.settings.value("auth/remembered_account", configured_account) or "")
|
remembered = str(self.settings.value("auth/remembered_account", configured_account) or "")
|
||||||
self.account_edit.setText(remembered)
|
|
||||||
self.remember_check.setChecked(bool(remembered))
|
|
||||||
configured_url = getattr(self.config, "base_url", "") or getattr(
|
configured_url = getattr(self.config, "base_url", "") or getattr(
|
||||||
self.config, "api_base_url", ""
|
self.config, "api_base_url", ""
|
||||||
)
|
)
|
||||||
@@ -418,11 +422,49 @@ class LoginWindow(QMainWindow):
|
|||||||
self.allow_self_signed_check.setChecked(not verify_ssl)
|
self.allow_self_signed_check.setChecked(not verify_ssl)
|
||||||
if self.demo_repository is not None and bool(getattr(self.config, "demo_mode", False)):
|
if self.demo_repository is not None and bool(getattr(self.config, "demo_mode", False)):
|
||||||
self.demo_check.setChecked(True)
|
self.demo_check.setChecked(True)
|
||||||
|
self.account_edit.setText(remembered)
|
||||||
|
self.restore_remembered_credentials()
|
||||||
if remembered:
|
if remembered:
|
||||||
self.password_edit.setFocus()
|
self.password_edit.setFocus()
|
||||||
else:
|
else:
|
||||||
self.account_edit.setFocus()
|
self.account_edit.setFocus()
|
||||||
|
|
||||||
|
def _credential_scope(self) -> str:
|
||||||
|
if hasattr(self, "server_url_edit"):
|
||||||
|
scope = self.server_url_edit.text().strip()
|
||||||
|
if scope:
|
||||||
|
return scope.rstrip("/")
|
||||||
|
return str(
|
||||||
|
getattr(self.config, "base_url", "")
|
||||||
|
or getattr(self.config, "api_base_url", "")
|
||||||
|
or ""
|
||||||
|
).strip().rstrip("/")
|
||||||
|
|
||||||
|
def restore_remembered_credentials(self) -> None:
|
||||||
|
"""Restore a password from the OS keyring without touching config files."""
|
||||||
|
|
||||||
|
configured_account = getattr(self.config, "remembered_account", "")
|
||||||
|
account = str(
|
||||||
|
self.settings.value("auth/remembered_account", configured_account) or ""
|
||||||
|
).strip()
|
||||||
|
scope = self._credential_scope()
|
||||||
|
password = ""
|
||||||
|
should_restore = _setting_bool(
|
||||||
|
self.settings.value("auth/remember_password", False),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
loader = getattr(self.credential_store, "load_password", None)
|
||||||
|
if should_restore and account and scope and callable(loader):
|
||||||
|
try:
|
||||||
|
password = str(loader(account=account, scope=scope) or "")
|
||||||
|
except Exception:
|
||||||
|
password = ""
|
||||||
|
self.account_edit.setText(account)
|
||||||
|
self.password_edit.setText(password)
|
||||||
|
self.remember_check.setChecked(bool(password))
|
||||||
|
self._restored_account = account if password else ""
|
||||||
|
self._restored_scope = scope if password else ""
|
||||||
|
|
||||||
def _toggle_password(self, visible: bool) -> None:
|
def _toggle_password(self, visible: bool) -> None:
|
||||||
self.password_edit.setEchoMode(
|
self.password_edit.setEchoMode(
|
||||||
QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password
|
QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password
|
||||||
@@ -557,6 +599,7 @@ class LoginWindow(QMainWindow):
|
|||||||
payload,
|
payload,
|
||||||
account,
|
account,
|
||||||
remember_account,
|
remember_account,
|
||||||
|
password,
|
||||||
),
|
),
|
||||||
on_error=self._on_login_error,
|
on_error=self._on_login_error,
|
||||||
on_finished=lambda: self._set_loading(False),
|
on_finished=lambda: self._set_loading(False),
|
||||||
@@ -604,15 +647,39 @@ class LoginWindow(QMainWindow):
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
account: str,
|
account: str,
|
||||||
remember_account: bool | None = None,
|
remember_account: bool | None = None,
|
||||||
|
password: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if remember_account is None:
|
if remember_account is None:
|
||||||
remember_account = self.remember_check.isChecked()
|
remember_account = self.remember_check.isChecked()
|
||||||
|
scope = self._credential_scope()
|
||||||
|
is_demo = bool(payload.get("demo_mode"))
|
||||||
|
password_saved = False
|
||||||
|
clearer = getattr(self.credential_store, "clear_password", None)
|
||||||
|
if callable(clearer) and self._restored_account and (
|
||||||
|
not remember_account
|
||||||
|
or self._restored_account != account
|
||||||
|
or self._restored_scope != scope
|
||||||
|
):
|
||||||
|
with suppress(Exception):
|
||||||
|
clearer(account=self._restored_account, scope=self._restored_scope)
|
||||||
if remember_account:
|
if remember_account:
|
||||||
self.settings.setValue("auth/remembered_account", account)
|
self.settings.setValue("auth/remembered_account", account)
|
||||||
|
saver = getattr(self.credential_store, "save_password", None)
|
||||||
|
if not is_demo and password and scope and callable(saver):
|
||||||
|
try:
|
||||||
|
password_saved = bool(saver(password, account=account, scope=scope))
|
||||||
|
except Exception:
|
||||||
|
password_saved = False
|
||||||
else:
|
else:
|
||||||
self.settings.remove("auth/remembered_account")
|
self.settings.remove("auth/remembered_account")
|
||||||
|
if callable(clearer):
|
||||||
|
with suppress(Exception):
|
||||||
|
clearer(account=account, scope=scope)
|
||||||
|
self.settings.setValue("auth/remember_password", password_saved)
|
||||||
self.settings.sync()
|
self.settings.sync()
|
||||||
self._emit_config_update(remembered_account=account if remember_account else "")
|
self._emit_config_update(remembered_account=account if remember_account else "")
|
||||||
|
self._restored_account = account if password_saved else ""
|
||||||
|
self._restored_scope = scope if password_saved else ""
|
||||||
self.password_edit.clear()
|
self.password_edit.clear()
|
||||||
self.authenticated_user = payload.get("user")
|
self.authenticated_user = payload.get("user")
|
||||||
self.login_succeeded.emit(payload)
|
self.login_succeeded.emit(payload)
|
||||||
|
|||||||
@@ -843,6 +843,7 @@ class AppointmentsPage(QWidget):
|
|||||||
"patient_id": patient_id,
|
"patient_id": patient_id,
|
||||||
"diagnosis_id": diagnosis_id,
|
"diagnosis_id": diagnosis_id,
|
||||||
"patient_name": first_value(row, "patient_name", default="患者"),
|
"patient_name": first_value(row, "patient_name", default="患者"),
|
||||||
|
"mode": "im",
|
||||||
"record": row,
|
"record": row,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ from datetime import date, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QTimer, Signal
|
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
|
||||||
from PySide6.QtGui import QTextCursor
|
from PySide6.QtGui import QDesktopServices, QPixmap, QTextCursor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
@@ -22,6 +23,7 @@ from PySide6.QtWidgets import (
|
|||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QStackedWidget,
|
QStackedWidget,
|
||||||
QTabBar,
|
QTabBar,
|
||||||
@@ -118,6 +120,13 @@ def _attachment_name(value: object) -> str:
|
|||||||
return name or text
|
return name or text
|
||||||
|
|
||||||
|
|
||||||
|
def _is_image_attachment(value: object) -> bool:
|
||||||
|
"""Return whether a server attachment can be previewed as an image."""
|
||||||
|
|
||||||
|
path = str(value or "").split("?", 1)[0].split("#", 1)[0].lower()
|
||||||
|
return path.endswith((".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"))
|
||||||
|
|
||||||
|
|
||||||
def _is_local_material_reference(value: str) -> bool:
|
def _is_local_material_reference(value: str) -> bool:
|
||||||
"""Reject local filesystem references before a note JSON is constructed."""
|
"""Reject local filesystem references before a note JSON is constructed."""
|
||||||
|
|
||||||
@@ -139,12 +148,14 @@ class QueueRow(QWidget):
|
|||||||
layout.setContentsMargins(12, 9, 12, 9)
|
layout.setContentsMargins(12, 9, 12, 9)
|
||||||
layout.setSpacing(5)
|
layout.setSpacing(5)
|
||||||
top = QHBoxLayout()
|
top = QHBoxLayout()
|
||||||
|
top.setSpacing(8)
|
||||||
name = QLabel(
|
name = QLabel(
|
||||||
display_text(first_value(record, "patient_name", "name", default="未命名患者"))
|
display_text(first_value(record, "patient_name", "name", default="未命名患者"))
|
||||||
)
|
)
|
||||||
name.setStyleSheet("font-size:14px; font-weight:700; color:#172033;")
|
name.setStyleSheet("font-size:14px; font-weight:700; color:#172033;")
|
||||||
top.addWidget(name)
|
name.setMinimumWidth(0)
|
||||||
top.addStretch(1)
|
name.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
top.addWidget(name, 1)
|
||||||
status_number = _as_int(first_value(record, "status", default=1), 1) or 1
|
status_number = _as_int(first_value(record, "status", default=1), 1) or 1
|
||||||
badge = StatusBadge(
|
badge = StatusBadge(
|
||||||
display_text(
|
display_text(
|
||||||
@@ -157,7 +168,12 @@ class QueueRow(QWidget):
|
|||||||
),
|
),
|
||||||
STATUS_KIND.get(status_number, "neutral"),
|
STATUS_KIND.get(status_number, "neutral"),
|
||||||
)
|
)
|
||||||
top.addWidget(badge)
|
badge.setMinimumWidth(max(54, badge.sizeHint().width()))
|
||||||
|
top.addWidget(
|
||||||
|
badge,
|
||||||
|
0,
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop,
|
||||||
|
)
|
||||||
layout.addLayout(top)
|
layout.addLayout(top)
|
||||||
time = first_value(
|
time = first_value(
|
||||||
record, "appointment_time_text", "appointment_time", "time", default="时间待确认"
|
record, "appointment_time_text", "appointment_time", "time", default="时间待确认"
|
||||||
@@ -168,6 +184,10 @@ class QueueRow(QWidget):
|
|||||||
f"{display_text(first_value(record, 'age'))}岁"
|
f"{display_text(first_value(record, 'age'))}岁"
|
||||||
)
|
)
|
||||||
meta.setProperty("role", "muted")
|
meta.setProperty("role", "muted")
|
||||||
|
# Long timestamps must not dictate the minimum width of the whole
|
||||||
|
# queue row and push the status badge underneath the viewport edge.
|
||||||
|
meta.setMinimumWidth(0)
|
||||||
|
meta.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||||||
layout.addWidget(meta)
|
layout.addWidget(meta)
|
||||||
assistant = first_value(record, "assistant_name", default=None)
|
assistant = first_value(record, "assistant_name", default=None)
|
||||||
if assistant:
|
if assistant:
|
||||||
@@ -211,6 +231,7 @@ class ReceptionPage(QWidget):
|
|||||||
self._pending_tongue_images: list[str] = []
|
self._pending_tongue_images: list[str] = []
|
||||||
self._pending_report_files: list[str] = []
|
self._pending_report_files: list[str] = []
|
||||||
self._note_busy = False
|
self._note_busy = False
|
||||||
|
self._attachment_preview_generation = 0
|
||||||
|
|
||||||
self._can_complete = has_permission(self.permissions, "doctor.appointment/complete")
|
self._can_complete = has_permission(self.permissions, "doctor.appointment/complete")
|
||||||
self._can_note = has_permission(self.permissions, "doctor.appointment/addDoctorNote")
|
self._can_note = has_permission(self.permissions, "doctor.appointment/addDoctorNote")
|
||||||
@@ -335,7 +356,8 @@ class ReceptionPage(QWidget):
|
|||||||
self.notify_button = QPushButton("通知医助")
|
self.notify_button = QPushButton("通知医助")
|
||||||
self.notify_button.clicked.connect(self._notify_assistant)
|
self.notify_button.clicked.connect(self._notify_assistant)
|
||||||
action_row.addWidget(self.notify_button)
|
action_row.addWidget(self.notify_button)
|
||||||
self.video_button = QPushButton("发起视频")
|
self.video_button = QPushButton("IM 问诊")
|
||||||
|
self.video_button.setToolTip("打开患者 IM,可发送消息并从会话中发起视频")
|
||||||
self.video_button.setProperty("variant", "secondary")
|
self.video_button.setProperty("variant", "secondary")
|
||||||
self.video_button.clicked.connect(self._request_video)
|
self.video_button.clicked.connect(self._request_video)
|
||||||
action_row.addWidget(self.video_button)
|
action_row.addWidget(self.video_button)
|
||||||
@@ -1245,13 +1267,25 @@ class ReceptionPage(QWidget):
|
|||||||
("report_files", "检查报告"),
|
("report_files", "检查报告"),
|
||||||
):
|
):
|
||||||
for path in _sequence(first_value(note, image_type, default=[])):
|
for path in _sequence(first_value(note, image_type, default=[])):
|
||||||
|
path_text = str(path).strip()
|
||||||
attachment = QWidget()
|
attachment = QWidget()
|
||||||
attachment_layout = QHBoxLayout(attachment)
|
attachment_layout = QHBoxLayout(attachment)
|
||||||
attachment_layout.setContentsMargins(0, 0, 0, 0)
|
attachment_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
label = QLabel(f"{caption}:{_attachment_name(path)}")
|
label = QLabel(f"{caption}:{_attachment_name(path_text)}")
|
||||||
label.setToolTip(str(path))
|
label.setToolTip(path_text)
|
||||||
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
attachment_layout.addWidget(label, 1)
|
attachment_layout.addWidget(label, 1)
|
||||||
|
preview_button = QPushButton(
|
||||||
|
"预览" if _is_image_attachment(path_text) else "打开"
|
||||||
|
)
|
||||||
|
preview_button.setProperty("variant", "secondary")
|
||||||
|
preview_button.setEnabled(bool(path_text))
|
||||||
|
preview_button.clicked.connect(
|
||||||
|
lambda _checked=False, current_path=path_text, button=preview_button: (
|
||||||
|
self._preview_note_attachment(current_path, button)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
attachment_layout.addWidget(preview_button)
|
||||||
if self._can_note and note_id is not None:
|
if self._can_note and note_id is not None:
|
||||||
delete_button = QPushButton("删除")
|
delete_button = QPushButton("删除")
|
||||||
delete_button.setProperty("variant", "secondary")
|
delete_button.setProperty("variant", "secondary")
|
||||||
@@ -1266,6 +1300,97 @@ class ReceptionPage(QWidget):
|
|||||||
layout.addWidget(attachment)
|
layout.addWidget(attachment)
|
||||||
self.notes_layout.addWidget(card)
|
self.notes_layout.addWidget(card)
|
||||||
|
|
||||||
|
def _preview_note_attachment(self, path: str, button: QPushButton) -> None:
|
||||||
|
"""Preview note images in-app and open non-image reports safely."""
|
||||||
|
|
||||||
|
target = str(path or "").strip()
|
||||||
|
if not target:
|
||||||
|
return
|
||||||
|
if not _is_image_attachment(target):
|
||||||
|
url = QUrl(target)
|
||||||
|
if (
|
||||||
|
not url.isValid()
|
||||||
|
or url.scheme().lower() not in {"https", "http"}
|
||||||
|
or not url.host()
|
||||||
|
):
|
||||||
|
show_toast(self, "附件地址无效,无法打开。", "warning", 4200)
|
||||||
|
return
|
||||||
|
if not QDesktopServices.openUrl(url):
|
||||||
|
show_toast(self, "系统未能打开该附件。", "danger", 4200)
|
||||||
|
return
|
||||||
|
|
||||||
|
download = getattr(self.repository, "download_public_image", None)
|
||||||
|
if not callable(download):
|
||||||
|
show_toast(self, "当前数据源不支持图片预览。", "warning", 4200)
|
||||||
|
return
|
||||||
|
self._attachment_preview_generation += 1
|
||||||
|
generation = self._attachment_preview_generation
|
||||||
|
button.setEnabled(False)
|
||||||
|
button.setText("加载中…")
|
||||||
|
run_async(
|
||||||
|
lambda: invoke(self.repository, "download_public_image", url=target),
|
||||||
|
on_success=lambda payload: self._show_note_image_preview(
|
||||||
|
target, payload, button, generation
|
||||||
|
),
|
||||||
|
on_error=lambda error: self._note_image_preview_failed(
|
||||||
|
error, button, generation
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _show_note_image_preview(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
payload: Any,
|
||||||
|
button: QPushButton,
|
||||||
|
generation: int,
|
||||||
|
) -> None:
|
||||||
|
if generation != self._attachment_preview_generation:
|
||||||
|
return
|
||||||
|
button.setEnabled(True)
|
||||||
|
button.setText("预览")
|
||||||
|
content = bytes(payload or b"")
|
||||||
|
pixmap = QPixmap()
|
||||||
|
if not content or len(content) > 10 * 1024 * 1024 or not pixmap.loadFromData(content):
|
||||||
|
show_toast(self, "服务器返回的图片无法预览。", "danger", 4600)
|
||||||
|
return
|
||||||
|
dialog = QDialog(self)
|
||||||
|
dialog.setWindowTitle(f"预览 · {_attachment_name(path)}")
|
||||||
|
dialog.setModal(True)
|
||||||
|
dialog.resize(920, 680)
|
||||||
|
layout = QVBoxLayout(dialog)
|
||||||
|
layout.setContentsMargins(14, 14, 14, 14)
|
||||||
|
scroll = QScrollArea(dialog)
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
image = QLabel()
|
||||||
|
image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
image.setPixmap(
|
||||||
|
pixmap.scaled(
|
||||||
|
880,
|
||||||
|
620,
|
||||||
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scroll.setWidget(image)
|
||||||
|
layout.addWidget(scroll, 1)
|
||||||
|
close_button = QPushButton("关闭")
|
||||||
|
close_button.setProperty("variant", "primary")
|
||||||
|
close_button.clicked.connect(dialog.accept)
|
||||||
|
layout.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight)
|
||||||
|
dialog.exec()
|
||||||
|
|
||||||
|
def _note_image_preview_failed(
|
||||||
|
self,
|
||||||
|
error: Exception,
|
||||||
|
button: QPushButton,
|
||||||
|
generation: int,
|
||||||
|
) -> None:
|
||||||
|
if generation != self._attachment_preview_generation:
|
||||||
|
return
|
||||||
|
button.setEnabled(True)
|
||||||
|
button.setText("预览")
|
||||||
|
show_toast(self, f"图片预览失败:{friendly_error(error)}", "danger", 5200)
|
||||||
|
|
||||||
def _reset_detail_content(self, seed: Any = None) -> None:
|
def _reset_detail_content(self, seed: Any = None) -> None:
|
||||||
self.patient_name_label.setText(
|
self.patient_name_label.setText(
|
||||||
display_text(first_value(seed, "patient_name", "name", default="—"))
|
display_text(first_value(seed, "patient_name", "name", default="—"))
|
||||||
@@ -1753,6 +1878,7 @@ class ReceptionPage(QWidget):
|
|||||||
"patient_name",
|
"patient_name",
|
||||||
default=first_value(diagnosis, "patient_name", default="患者"),
|
default=first_value(diagnosis, "patient_name", default="患者"),
|
||||||
),
|
),
|
||||||
|
"mode": "im",
|
||||||
"record": self._selected_record,
|
"record": self._selected_record,
|
||||||
}
|
}
|
||||||
self.video_requested.emit(payload)
|
self.video_requested.emit(payload)
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ from PySide6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from doctor_workstation.core.errors import AuthenticationExpiredError
|
from doctor_workstation.core.errors import (
|
||||||
|
ApiHttpError,
|
||||||
|
ApiProtocolError,
|
||||||
|
ApiTimeoutError,
|
||||||
|
ApiTransportError,
|
||||||
|
AuthenticationExpiredError,
|
||||||
|
)
|
||||||
|
|
||||||
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
|
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
|
||||||
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
|
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
|
||||||
@@ -331,7 +337,45 @@ def friendly_error(error: Any) -> str:
|
|||||||
"服务器证书不受系统信任。若这是可信内网的自签名服务器,请展开“服务器设置”,"
|
"服务器证书不受系统信任。若这是可信内网的自签名服务器,请展开“服务器设置”,"
|
||||||
"勾选“信任自签名证书(仅内网调试)”后重新登录,设置会自动应用。"
|
"勾选“信任自签名证书(仅内网调试)”后重新登录,设置会自动应用。"
|
||||||
)
|
)
|
||||||
return text or "操作未完成,请稍后重试。"
|
if isinstance(error, AuthenticationExpiredError):
|
||||||
|
return "登录状态已失效,请重新登录。"
|
||||||
|
if isinstance(error, ApiTimeoutError) or "timed out" in lowered or "timeout" in lowered:
|
||||||
|
return "连接服务器超时,请检查网络后重试。"
|
||||||
|
if isinstance(error, ApiProtocolError) or any(
|
||||||
|
marker in lowered
|
||||||
|
for marker in (
|
||||||
|
"api response envelope",
|
||||||
|
"api response is not valid json",
|
||||||
|
"invalid json",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return "服务器返回的数据格式不正确,请联系管理员检查接口。"
|
||||||
|
if isinstance(error, ApiHttpError):
|
||||||
|
status_code = getattr(error, "status_code", None)
|
||||||
|
suffix = f"(状态码 {status_code})" if status_code else ""
|
||||||
|
return f"服务器请求失败{suffix},请稍后重试。"
|
||||||
|
if isinstance(error, ApiTransportError) or any(
|
||||||
|
marker in lowered
|
||||||
|
for marker in (
|
||||||
|
"connection refused",
|
||||||
|
"connecterror",
|
||||||
|
"connection error",
|
||||||
|
"failed to connect",
|
||||||
|
"getaddrinfo failed",
|
||||||
|
"name or service not known",
|
||||||
|
"network is unreachable",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return "无法连接服务器,请检查服务器地址与网络。"
|
||||||
|
if any(marker in lowered for marker in ("unauthorized", "forbidden", "permission denied")):
|
||||||
|
return "当前账号无权执行此操作。"
|
||||||
|
if "not found" in lowered:
|
||||||
|
return "未找到所需数据。"
|
||||||
|
if any("\u4e00" <= character <= "\u9fff" for character in text):
|
||||||
|
return text
|
||||||
|
if isinstance(error, TypeError):
|
||||||
|
return "程序执行失败,请重试;若问题持续出现,请联系管理员。"
|
||||||
|
return "操作未完成,请稍后重试。"
|
||||||
|
|
||||||
|
|
||||||
class PageHeader(QWidget):
|
class PageHeader(QWidget):
|
||||||
@@ -379,6 +423,10 @@ class StatusBadge(QLabel):
|
|||||||
self.setObjectName("StatusBadge")
|
self.setObjectName("StatusBadge")
|
||||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||||||
|
# QSS padding alone is not a reliable minimum on every Windows DPI
|
||||||
|
# scale. Keep enough physical row height so badge text is never
|
||||||
|
# squeezed into the thin coloured strip seen in list item widgets.
|
||||||
|
self.setMinimumHeight(24)
|
||||||
self.set_kind(kind)
|
self.set_kind(kind)
|
||||||
|
|
||||||
def set_kind(self, kind: str) -> None:
|
def set_kind(self, kind: str) -> None:
|
||||||
|
|||||||
@@ -359,6 +359,8 @@ class VideoCallLauncher:
|
|||||||
*,
|
*,
|
||||||
diagnosis_id: Any = None,
|
diagnosis_id: Any = None,
|
||||||
patient_id: Any = None,
|
patient_id: Any = None,
|
||||||
|
open_im: bool = False,
|
||||||
|
patient_name: str = "患者",
|
||||||
) -> Any:
|
) -> Any:
|
||||||
request = self.prepare(
|
request = self.prepare(
|
||||||
ticket,
|
ticket,
|
||||||
@@ -374,6 +376,8 @@ class VideoCallLauncher:
|
|||||||
remote_url=self.remote_url,
|
remote_url=self.remote_url,
|
||||||
logger=self.logger,
|
logger=self.logger,
|
||||||
browser_opener=self.browser_opener,
|
browser_opener=self.browser_opener,
|
||||||
|
open_im=open_im,
|
||||||
|
patient_name=patient_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -388,6 +392,8 @@ def launch_video_call(
|
|||||||
remote_url: str | None = None,
|
remote_url: str | None = None,
|
||||||
logger: Any = None,
|
logger: Any = None,
|
||||||
browser_opener: Callable[[str], bool] | None = None,
|
browser_opener: Callable[[str], bool] | None = None,
|
||||||
|
open_im: bool = False,
|
||||||
|
patient_name: str = "患者",
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Normalize a ticket and open a call with the requested backend."""
|
"""Normalize a ticket and open a call with the requested backend."""
|
||||||
|
|
||||||
@@ -402,4 +408,6 @@ def launch_video_call(
|
|||||||
ticket,
|
ticket,
|
||||||
diagnosis_id=diagnosis_id,
|
diagnosis_id=diagnosis_id,
|
||||||
patient_id=patient_id,
|
patient_id=patient_id,
|
||||||
|
open_im=open_im,
|
||||||
|
patient_name=patient_name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -262,6 +262,53 @@ class OrderedCallLifecycle:
|
|||||||
self._bind_future = self._worker.submit("bind", operation)
|
self._bind_future = self._worker.submit("bind", operation)
|
||||||
return self._bind_future
|
return self._bind_future
|
||||||
|
|
||||||
|
def save_screenshot(self, content: bytes, filename: str) -> Future[str]:
|
||||||
|
"""Upload one video frame and append it to the diagnosis doctor notes."""
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
raise ValueError("screenshot content must not be empty")
|
||||||
|
if len(content) > 10 * 1024 * 1024:
|
||||||
|
raise ValueError("screenshot content exceeds 10 MB")
|
||||||
|
clean_name = str(filename or "callshot.jpg").strip() or "callshot.jpg"
|
||||||
|
with self._lock:
|
||||||
|
if self._end_future is not None:
|
||||||
|
raise RuntimeError("video call has already ended")
|
||||||
|
upload = getattr(self.repository, "upload_material_bytes", None)
|
||||||
|
add_note = getattr(self.repository, "add_doctor_note", None)
|
||||||
|
if not callable(upload) or not callable(add_note):
|
||||||
|
raise ValueError("video repository does not implement screenshot storage")
|
||||||
|
|
||||||
|
def operation() -> str:
|
||||||
|
reference = str(
|
||||||
|
_call_repository_method(
|
||||||
|
upload,
|
||||||
|
{
|
||||||
|
"content": content,
|
||||||
|
"filename": clean_name,
|
||||||
|
"material_type": "image",
|
||||||
|
"cid": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not reference:
|
||||||
|
raise ValueError("screenshot upload returned no server reference")
|
||||||
|
_call_repository_method(
|
||||||
|
add_note,
|
||||||
|
{
|
||||||
|
"diagnosis_id": self.request.diagnosis_id,
|
||||||
|
"content": "",
|
||||||
|
"tongue_images": [reference],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
"video screenshot stored in doctor notes",
|
||||||
|
extra={"video_call": self.request.safe_log_context()},
|
||||||
|
)
|
||||||
|
return reference
|
||||||
|
|
||||||
|
return self._worker.submit("screenshot", operation)
|
||||||
|
|
||||||
def end(self, reason: str) -> Future[bool]:
|
def end(self, reason: str) -> Future[bool]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._end_future is not None:
|
if self._end_future is not None:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ actual call requires an isolated QtWebEngine profile and an active QApplication.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
@@ -181,11 +183,33 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
|
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
|
||||||
self._callback(message)
|
self._callback(message)
|
||||||
|
|
||||||
|
@Slot(str) # type: ignore[misc]
|
||||||
|
def saveScreenshot(self, data_url: str) -> None: # noqa: N802 - Qt bridge API
|
||||||
|
"""Forward one bounded JPEG data URL to the trusted desktop host."""
|
||||||
|
|
||||||
|
if not isinstance(data_url, str) or len(data_url) > 14 * 1024 * 1024:
|
||||||
|
self._callback(
|
||||||
|
{
|
||||||
|
"source": "doctor-call",
|
||||||
|
"event": "screenshot-invalid",
|
||||||
|
"message": "截屏图片过大,无法保存。",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._callback(
|
||||||
|
{
|
||||||
|
"source": "doctor-call",
|
||||||
|
"event": "screenshot",
|
||||||
|
"dataUrl": data_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
|
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
|
||||||
status_changed = Signal(str) # type: ignore[misc]
|
status_changed = Signal(str) # type: ignore[misc]
|
||||||
call_ended = Signal(str) # type: ignore[misc]
|
call_ended = Signal(str) # type: ignore[misc]
|
||||||
call_error = Signal(str) # type: ignore[misc]
|
call_error = Signal(str) # type: ignore[misc]
|
||||||
_start_completed = Signal(bool) # type: ignore[misc]
|
_start_completed = Signal(bool) # type: ignore[misc]
|
||||||
|
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -194,12 +218,19 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
lifecycle: OrderedCallLifecycle,
|
lifecycle: OrderedCallLifecycle,
|
||||||
*,
|
*,
|
||||||
logger: logging.Logger,
|
logger: logging.Logger,
|
||||||
|
lifecycle_factory: Callable[[], OrderedCallLifecycle],
|
||||||
|
open_im: bool = False,
|
||||||
|
patient_name: str = "患者",
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.request = request
|
self.request = request
|
||||||
self.location = location
|
self.location = location
|
||||||
self.lifecycle = lifecycle
|
self.lifecycle = lifecycle
|
||||||
|
self._lifecycle_factory = lifecycle_factory
|
||||||
|
self._lifecycles = [lifecycle]
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
self.open_im = bool(open_im)
|
||||||
|
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
||||||
try:
|
try:
|
||||||
self._policy = TrustedDocumentPolicy.from_url(
|
self._policy = TrustedDocumentPolicy.from_url(
|
||||||
location.url,
|
location.url,
|
||||||
@@ -213,10 +244,14 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self._companion_ended = False
|
self._companion_ended = False
|
||||||
self._released = False
|
self._released = False
|
||||||
self._close_reason = "window-closed"
|
self._close_reason = "window-closed"
|
||||||
|
self._call_cycle_closed = False
|
||||||
|
self._start_requested = False
|
||||||
self._legacy_grants: list[tuple[Any, Any]] = []
|
self._legacy_grants: list[tuple[Any, Any]] = []
|
||||||
self._permission_grants: list[Any] = []
|
self._permission_grants: list[Any] = []
|
||||||
|
|
||||||
self.setWindowTitle("视频面诊")
|
self.setWindowTitle(
|
||||||
|
f"与 {self.patient_name} IM 问诊" if self.open_im else "视频面诊"
|
||||||
|
)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||||
self.resize(1120, 760)
|
self.resize(1120, 760)
|
||||||
self.setMinimumSize(760, 520)
|
self.setMinimumSize(760, 520)
|
||||||
@@ -248,6 +283,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self._connect_permissions()
|
self._connect_permissions()
|
||||||
|
|
||||||
self._start_completed.connect(self._on_lifecycle_started)
|
self._start_completed.connect(self._on_lifecycle_started)
|
||||||
|
self._screenshot_completed.connect(self._on_screenshot_completed)
|
||||||
self.web_view.loadFinished.connect(self._on_load_finished)
|
self.web_view.loadFinished.connect(self._on_load_finished)
|
||||||
self.web_view.setUrl(QUrl(self.location.url))
|
self.web_view.setUrl(QUrl(self.location.url))
|
||||||
|
|
||||||
@@ -326,17 +362,24 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self.close()
|
self.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
self._media_active = True
|
||||||
start_future = self.lifecycle.start()
|
config = {
|
||||||
except Exception:
|
**self.request.to_web_config(),
|
||||||
self.logger.error(
|
"patientName": self.patient_name,
|
||||||
"video call record could not be queued",
|
"mode": "chat" if self.open_im else "video",
|
||||||
extra={"video_call": self.request.safe_log_context()},
|
}
|
||||||
)
|
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
|
||||||
self._close_reason = "record-start-queue-failed"
|
script = f"""
|
||||||
self.close()
|
(() => {{
|
||||||
return
|
if (!window.doctorConsultation
|
||||||
start_future.add_done_callback(self._notify_start_completed)
|
|| typeof window.doctorConsultation.open !== 'function') {{
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
void window.doctorConsultation.open({config_json}).catch(() => undefined);
|
||||||
|
return true;
|
||||||
|
}})()
|
||||||
|
"""
|
||||||
|
self._page.runJavaScript(script, self._after_injection)
|
||||||
|
|
||||||
def _notify_start_completed(self, future: Future[bool]) -> None:
|
def _notify_start_completed(self, future: Future[bool]) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -350,25 +393,16 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
if not succeeded:
|
if not succeeded:
|
||||||
self._close_reason = "record-start-failed"
|
self._start_requested = False
|
||||||
self.close()
|
self._call_cycle_closed = True
|
||||||
|
self._page.runJavaScript(
|
||||||
|
"window.doctorConsultation?.hostCallReady?.(false, "
|
||||||
|
'"通话记录创建失败,请稍后重试。");'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
self._media_active = True
|
self._page.runJavaScript(
|
||||||
config_json = json.dumps(
|
"window.doctorConsultation?.hostCallReady?.(true, '');"
|
||||||
self.request.to_web_config(),
|
|
||||||
ensure_ascii=True,
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
)
|
||||||
script = f"""
|
|
||||||
(() => {{
|
|
||||||
if (!window.doctorCall || typeof window.doctorCall.start !== 'function') {{
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
void window.doctorCall.start({config_json}).catch(() => undefined);
|
|
||||||
return true;
|
|
||||||
}})()
|
|
||||||
"""
|
|
||||||
self._page.runJavaScript(script, self._after_injection)
|
|
||||||
|
|
||||||
def _after_injection(self, result: Any) -> None:
|
def _after_injection(self, result: Any) -> None:
|
||||||
self._injected = result is not False
|
self._injected = result is not False
|
||||||
@@ -380,6 +414,18 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
event = str(message.get("event", ""))
|
event = str(message.get("event", ""))
|
||||||
|
if event == "call-start-request":
|
||||||
|
self._start_call_cycle()
|
||||||
|
return
|
||||||
|
if event == "screenshot":
|
||||||
|
self._save_screenshot(str(message.get("dataUrl") or ""))
|
||||||
|
return
|
||||||
|
if event == "screenshot-invalid":
|
||||||
|
self._on_screenshot_completed(
|
||||||
|
False,
|
||||||
|
str(message.get("message") or "截屏图片无效。")[:200],
|
||||||
|
)
|
||||||
|
return
|
||||||
room_id = message.get("roomId", message.get("room_id"))
|
room_id = message.get("roomId", message.get("room_id"))
|
||||||
if room_id not in (None, ""):
|
if room_id not in (None, ""):
|
||||||
self.lifecycle.bind_room(room_id)
|
self.lifecycle.bind_room(room_id)
|
||||||
@@ -388,17 +434,81 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
if event == "status":
|
if event == "status":
|
||||||
status = str(message.get("status", "unknown"))[:80]
|
status = str(message.get("status", "unknown"))[:80]
|
||||||
self.status_changed.emit(status)
|
self.status_changed.emit(status)
|
||||||
if status == "idle":
|
if status == "idle" and not self.open_im:
|
||||||
self._close_from_companion("remote-idle")
|
self._close_from_companion("remote-idle")
|
||||||
elif event == "hangup":
|
elif event == "hangup":
|
||||||
status = str(message.get("status", "ended"))[:80]
|
status = str(message.get("status", "ended"))[:80]
|
||||||
self.call_ended.emit(status)
|
self.call_ended.emit(status)
|
||||||
|
self.lifecycle.end(f"companion-{status}")
|
||||||
|
self._call_cycle_closed = True
|
||||||
|
self._start_requested = False
|
||||||
|
if not self.open_im:
|
||||||
self._close_from_companion("companion-hangup")
|
self._close_from_companion("companion-hangup")
|
||||||
elif event == "error":
|
elif event == "error":
|
||||||
message_text = str(message.get("message", "视频通话错误"))[:400]
|
message_text = str(message.get("message", "视频通话错误"))[:400]
|
||||||
self.call_error.emit(message_text)
|
self.call_error.emit(message_text)
|
||||||
|
if self._start_requested:
|
||||||
|
self.lifecycle.end("companion-error")
|
||||||
|
self._call_cycle_closed = True
|
||||||
|
self._start_requested = False
|
||||||
|
if not self.open_im:
|
||||||
self._close_from_companion("companion-error")
|
self._close_from_companion("companion-error")
|
||||||
|
|
||||||
|
def _start_call_cycle(self) -> None:
|
||||||
|
if self._closing or self._start_requested:
|
||||||
|
return
|
||||||
|
if self._call_cycle_closed:
|
||||||
|
self.lifecycle = self._lifecycle_factory()
|
||||||
|
self._lifecycles.append(self.lifecycle)
|
||||||
|
self._call_cycle_closed = False
|
||||||
|
self._start_requested = True
|
||||||
|
try:
|
||||||
|
future = self.lifecycle.start()
|
||||||
|
except Exception:
|
||||||
|
self._start_requested = False
|
||||||
|
self._on_lifecycle_started(False)
|
||||||
|
return
|
||||||
|
future.add_done_callback(self._notify_start_completed)
|
||||||
|
|
||||||
|
def _save_screenshot(self, data_url: str) -> None:
|
||||||
|
prefix = "data:image/jpeg;base64,"
|
||||||
|
if not data_url.startswith(prefix):
|
||||||
|
self._on_screenshot_completed(False, "截屏图片格式不正确。")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(data_url[len(prefix) :], validate=True)
|
||||||
|
except (ValueError, binascii.Error):
|
||||||
|
self._on_screenshot_completed(False, "截屏图片解析失败。")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
future = self.lifecycle.save_screenshot(
|
||||||
|
content,
|
||||||
|
f"callshot-{self.request.diagnosis_id}.jpg",
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
self._on_screenshot_completed(False, str(error)[:200])
|
||||||
|
return
|
||||||
|
future.add_done_callback(self._notify_screenshot_completed)
|
||||||
|
|
||||||
|
def _notify_screenshot_completed(self, future: Future[str]) -> None:
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
except Exception as error:
|
||||||
|
succeeded = False
|
||||||
|
message = str(error)[:200] or "截屏保存失败。"
|
||||||
|
else:
|
||||||
|
succeeded = True
|
||||||
|
message = "截屏已保存到患者信息。"
|
||||||
|
with suppress(RuntimeError):
|
||||||
|
self._screenshot_completed.emit(succeeded, message)
|
||||||
|
|
||||||
|
def _on_screenshot_completed(self, succeeded: bool, message: str) -> None:
|
||||||
|
payload = json.dumps(str(message)[:200], ensure_ascii=True)
|
||||||
|
state = "true" if succeeded else "false"
|
||||||
|
self._page.runJavaScript(
|
||||||
|
f"window.doctorConsultation?.screenshotResult?.({state}, {payload});"
|
||||||
|
)
|
||||||
|
|
||||||
def _close_from_companion(self, reason: str) -> None:
|
def _close_from_companion(self, reason: str) -> None:
|
||||||
self._companion_ended = True
|
self._companion_ended = True
|
||||||
self._close_reason = reason
|
self._close_reason = reason
|
||||||
@@ -415,11 +525,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
|||||||
self._media_active = False
|
self._media_active = False
|
||||||
if self._injected and not self._companion_ended and not self._released:
|
if self._injected and not self._companion_ended and not self._released:
|
||||||
self._page.runJavaScript(
|
self._page.runJavaScript(
|
||||||
"void window.doctorCall?.hangup?.().catch(() => undefined)"
|
"void window.doctorConsultation?.close?.().catch(() => undefined)"
|
||||||
)
|
)
|
||||||
|
if self._start_requested and not self._call_cycle_closed:
|
||||||
self.lifecycle.end(self._close_reason)
|
self.lifecycle.end(self._close_reason)
|
||||||
self._release_webengine()
|
self._release_webengine()
|
||||||
|
|
||||||
|
def wait_for_lifecycles(self, timeout: float) -> bool:
|
||||||
|
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
|
||||||
|
|
||||||
def _release_webengine(self) -> None:
|
def _release_webengine(self) -> None:
|
||||||
if self._released:
|
if self._released:
|
||||||
return
|
return
|
||||||
@@ -486,6 +600,8 @@ class VideoCallWindow:
|
|||||||
remote_url: str | None = None,
|
remote_url: str | None = None,
|
||||||
logger: logging.Logger | None = None,
|
logger: logging.Logger | None = None,
|
||||||
browser_opener: Callable[[str], bool] | None = None,
|
browser_opener: Callable[[str], bool] | None = None,
|
||||||
|
open_im: bool = False,
|
||||||
|
patient_name: str = "患者",
|
||||||
) -> None:
|
) -> None:
|
||||||
del browser_opener # Reserved for a future authenticated handoff implementation.
|
del browser_opener # Reserved for a future authenticated handoff implementation.
|
||||||
try:
|
try:
|
||||||
@@ -500,14 +616,23 @@ class VideoCallWindow:
|
|||||||
raise VideoWindowError("embedded video requires an active QApplication")
|
raise VideoWindowError("embedded video requires an active QApplication")
|
||||||
|
|
||||||
self.request = request
|
self.request = request
|
||||||
|
self.repository = repository
|
||||||
|
self.open_im = bool(open_im)
|
||||||
|
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
||||||
self.logger = logger or _LOGGER
|
self.logger = logger or _LOGGER
|
||||||
self.location = resolve_companion_location(
|
self.location = resolve_companion_location(
|
||||||
local_dist=local_dist,
|
local_dist=local_dist,
|
||||||
remote_url=remote_url,
|
remote_url=remote_url,
|
||||||
)
|
)
|
||||||
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
|
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
|
||||||
|
self._lifecycles = [self.lifecycle]
|
||||||
self._session: Any = None
|
self._session: Any = None
|
||||||
|
|
||||||
|
def _new_lifecycle(self) -> OrderedCallLifecycle:
|
||||||
|
lifecycle = OrderedCallLifecycle(self.request, self.repository, self.logger)
|
||||||
|
self._lifecycles.append(lifecycle)
|
||||||
|
return lifecycle
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def qt_window(self) -> Any:
|
def qt_window(self) -> Any:
|
||||||
return self._session
|
return self._session
|
||||||
@@ -519,6 +644,9 @@ class VideoCallWindow:
|
|||||||
self.location,
|
self.location,
|
||||||
self.lifecycle,
|
self.lifecycle,
|
||||||
logger=self.logger,
|
logger=self.logger,
|
||||||
|
lifecycle_factory=self._new_lifecycle,
|
||||||
|
open_im=self.open_im,
|
||||||
|
patient_name=self.patient_name,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.lifecycle.end("window-open-failed")
|
self.lifecycle.end("window-open-failed")
|
||||||
@@ -545,7 +673,9 @@ class VideoCallWindow:
|
|||||||
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
|
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
|
||||||
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
|
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
|
||||||
|
|
||||||
return self.lifecycle.wait(timeout)
|
if self._session is not None:
|
||||||
|
return self._session.wait_for_lifecycles(timeout)
|
||||||
|
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
|
||||||
|
|
||||||
wait = wait_for_lifecycle
|
wait = wait_for_lifecycle
|
||||||
|
|
||||||
@@ -558,6 +688,8 @@ def open_video_call(
|
|||||||
remote_url: str | None = None,
|
remote_url: str | None = None,
|
||||||
logger: logging.Logger | None = None,
|
logger: logging.Logger | None = None,
|
||||||
browser_opener: Callable[[str], bool] | None = None,
|
browser_opener: Callable[[str], bool] | None = None,
|
||||||
|
open_im: bool = False,
|
||||||
|
patient_name: str = "患者",
|
||||||
) -> VideoCallWindow:
|
) -> VideoCallWindow:
|
||||||
"""Create and immediately open a trusted embedded video window."""
|
"""Create and immediately open a trusted embedded video window."""
|
||||||
|
|
||||||
@@ -570,6 +702,8 @@ def open_video_call(
|
|||||||
remote_url=remote_url,
|
remote_url=remote_url,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
browser_opener=browser_opener,
|
browser_opener=browser_opener,
|
||||||
|
open_im=open_im,
|
||||||
|
patient_name=patient_name,
|
||||||
).open()
|
).open()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -215,21 +215,43 @@ def test_invalid_envelope_raises_protocol_error() -> None:
|
|||||||
client.get("broken")
|
client.get("broken")
|
||||||
|
|
||||||
|
|
||||||
def test_token_store_file_fallback_never_persists_password(tmp_path: Path) -> None:
|
def test_token_store_file_fallback_never_persists_plaintext_password(tmp_path: Path) -> None:
|
||||||
"""The fallback contains only an access token and an optional account name."""
|
"""The fallback may contain a Windows DPAPI blob, but never plaintext."""
|
||||||
|
|
||||||
path = tmp_path / "credentials.json"
|
path = tmp_path / "credentials.json"
|
||||||
store = TokenStore(path, keyring_backend=None)
|
store = TokenStore(path, keyring_backend=None)
|
||||||
store.save_token("token-value", account="doctor")
|
store.save_token("token-value", account="doctor")
|
||||||
|
password_saved = store.save_password(
|
||||||
|
"must-not-reach-disk",
|
||||||
|
account="doctor",
|
||||||
|
scope="https://example.test/adminapi",
|
||||||
|
)
|
||||||
|
|
||||||
assert store.load_token() == "token-value"
|
assert store.load_token() == "token-value"
|
||||||
assert store.load_account() == "doctor"
|
assert store.load_account() == "doctor"
|
||||||
|
restored = store.load_password(account="doctor", scope="https://example.test/adminapi")
|
||||||
|
if password_saved:
|
||||||
|
assert restored == "must-not-reach-disk"
|
||||||
|
else:
|
||||||
|
assert restored is None
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
assert payload == {"token": "token-value", "account": "doctor"}
|
assert payload["token"] == "token-value"
|
||||||
|
assert payload["account"] == "doctor"
|
||||||
assert "password" not in path.read_text(encoding="utf-8").lower()
|
assert "password" not in path.read_text(encoding="utf-8").lower()
|
||||||
|
assert "must-not-reach-disk" not in path.read_text(encoding="utf-8")
|
||||||
store.clear_token()
|
store.clear_token()
|
||||||
assert store.load_token() is None
|
assert store.load_token() is None
|
||||||
assert store.load_account() == "doctor"
|
assert store.load_account() == "doctor"
|
||||||
|
after_logout = store.load_password(
|
||||||
|
account="doctor",
|
||||||
|
scope="https://example.test/adminapi",
|
||||||
|
)
|
||||||
|
if password_saved:
|
||||||
|
assert after_logout == "must-not-reach-disk"
|
||||||
|
else:
|
||||||
|
assert after_logout is None
|
||||||
|
store.clear_account()
|
||||||
|
assert store.load_password(account="doctor", scope="https://example.test/adminapi") is None
|
||||||
|
|
||||||
|
|
||||||
class _MemoryKeyring:
|
class _MemoryKeyring:
|
||||||
@@ -267,6 +289,33 @@ def test_token_store_prefers_available_keyring(tmp_path: Path) -> None:
|
|||||||
assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"}
|
assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_store_keeps_login_password_in_scoped_keyring_only(tmp_path: Path) -> None:
|
||||||
|
backend = _MemoryKeyring()
|
||||||
|
path = tmp_path / "credentials.json"
|
||||||
|
store = TokenStore(path, keyring_backend=backend)
|
||||||
|
scope = "https://example.test/adminapi/"
|
||||||
|
|
||||||
|
assert store.save_password("secret-value", account="doctor", scope=scope)
|
||||||
|
assert (
|
||||||
|
store.load_password(account="doctor", scope="https://example.test/adminapi")
|
||||||
|
== "secret-value"
|
||||||
|
)
|
||||||
|
assert store.load_password(account="doctor", scope="https://other.test/adminapi") is None
|
||||||
|
assert "secret-value" not in path.read_text(encoding="utf-8")
|
||||||
|
assert json.loads(path.read_text(encoding="utf-8")) == {
|
||||||
|
"account": "doctor",
|
||||||
|
"scope": "https://example.test/adminapi",
|
||||||
|
}
|
||||||
|
|
||||||
|
next_scope = "https://next.test/adminapi"
|
||||||
|
assert store.save_password("next-secret", account="doctor", scope=next_scope)
|
||||||
|
assert store.load_password(account="doctor", scope=scope) is None
|
||||||
|
assert store.load_password(account="doctor", scope=next_scope) == "next-secret"
|
||||||
|
|
||||||
|
store.clear_password(account="doctor", scope=next_scope)
|
||||||
|
assert store.load_password(account="doctor", scope=next_scope) is None
|
||||||
|
|
||||||
|
|
||||||
def test_token_store_scopes_automatic_restore_and_forgets_account(tmp_path: Path) -> None:
|
def test_token_store_scopes_automatic_restore_and_forgets_account(tmp_path: Path) -> None:
|
||||||
"""Automatic restore never returns a token issued for another API base."""
|
"""Automatic restore never returns a token issued for another API base."""
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
from doctor_workstation.core import PermissionSet
|
from doctor_workstation.core import PermissionSet
|
||||||
from doctor_workstation.services.api_client import ApiClient
|
from doctor_workstation.services.api_client import ApiClient
|
||||||
|
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||||
from doctor_workstation.ui.pages import reception as reception_module
|
from doctor_workstation.ui.pages import reception as reception_module
|
||||||
from doctor_workstation.ui.pages.reception import NOTE_LIMIT, ReceptionPage
|
from doctor_workstation.ui.pages.reception import (
|
||||||
|
NOTE_LIMIT,
|
||||||
|
QueueRow,
|
||||||
|
ReceptionPage,
|
||||||
|
_is_image_attachment,
|
||||||
|
)
|
||||||
|
from doctor_workstation.ui.widgets import StatusBadge
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
@@ -50,6 +57,71 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setattr(reception_module, "run_async", run_immediately)
|
monkeypatch.setattr(reception_module, "run_async", run_immediately)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "expected"),
|
||||||
|
[
|
||||||
|
("https://cdn.test/tongue.JPG?token=1", True),
|
||||||
|
("https://cdn.test/report.webp", True),
|
||||||
|
("https://cdn.test/report.pdf", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_note_attachment_preview_type_is_extension_aware(path: str, expected: bool) -> None:
|
||||||
|
assert _is_image_attachment(path) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_attachments_offer_image_preview_and_file_open(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
|
||||||
|
page._render_notes(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"note_date": "2026-08-12",
|
||||||
|
"tongue_images": ["https://cdn.test/tongue.jpg"],
|
||||||
|
"report_files": [
|
||||||
|
"https://cdn.test/check.png",
|
||||||
|
"https://cdn.test/check.pdf",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
labels = [button.text() for button in page.notes_container.findChildren(QPushButton)]
|
||||||
|
assert labels.count("预览") == 2
|
||||||
|
assert labels.count("打开") == 1
|
||||||
|
page.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_status_badge_is_not_clipped_in_narrow_panel(
|
||||||
|
application: QApplication,
|
||||||
|
) -> None:
|
||||||
|
row = QueueRow(
|
||||||
|
{
|
||||||
|
"patient_name": "张蒙",
|
||||||
|
"status": 1,
|
||||||
|
"status_desc": "待接诊",
|
||||||
|
"appointment_time": "12:45:00",
|
||||||
|
"gender": 1,
|
||||||
|
"age": 36,
|
||||||
|
"assistant_name": "苏亚梅",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row.setFixedWidth(280)
|
||||||
|
row.show()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
badge = row.findChild(StatusBadge)
|
||||||
|
assert badge is not None
|
||||||
|
assert badge.height() >= 24
|
||||||
|
assert badge.width() >= 54
|
||||||
|
assert badge.geometry().right() < row.width()
|
||||||
|
|
||||||
|
row.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def _detail(
|
def _detail(
|
||||||
appointment_id: int,
|
appointment_id: int,
|
||||||
*,
|
*,
|
||||||
@@ -361,6 +433,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
|||||||
"patient_id": 141,
|
"patient_id": 141,
|
||||||
"diagnosis_id": 241,
|
"diagnosis_id": 241,
|
||||||
"patient_name": "视频患者",
|
"patient_name": "视频患者",
|
||||||
|
"mode": "im",
|
||||||
"record": detail["appointment"],
|
"record": detail["appointment"],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QSettings
|
from PySide6.QtCore import QSettings
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox
|
||||||
|
|
||||||
from doctor_workstation import app as app_module
|
from doctor_workstation import app as app_module
|
||||||
from doctor_workstation.app import ApplicationController
|
from doctor_workstation.app import ApplicationController
|
||||||
@@ -232,11 +232,28 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
|
|||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
|
def test_remembered_password_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||||
application = QApplication.instance() or QApplication([])
|
application = QApplication.instance() or QApplication([])
|
||||||
settings_path = tmp_path / "remember-account.ini"
|
settings_path = tmp_path / "remember-password.ini"
|
||||||
|
secrets: dict[tuple[str, str], str] = {}
|
||||||
|
|
||||||
|
def load_password(*, account: str, scope: str) -> str | None:
|
||||||
|
return secrets.get((account, scope.rstrip("/")))
|
||||||
|
|
||||||
|
def save_password(password: str, *, account: str, scope: str) -> bool:
|
||||||
|
secrets[(account, scope.rstrip("/"))] = password
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear_password(*, account: str, scope: str) -> None:
|
||||||
|
secrets.pop((account, scope.rstrip("/")), None)
|
||||||
|
|
||||||
|
credentials = SimpleNamespace(
|
||||||
|
load_password=load_password,
|
||||||
|
save_password=save_password,
|
||||||
|
clear_password=clear_password,
|
||||||
|
)
|
||||||
config = SimpleNamespace(
|
config = SimpleNamespace(
|
||||||
api_base_url="",
|
api_base_url="https://example.test/adminapi",
|
||||||
request_timeout=30,
|
request_timeout=30,
|
||||||
verify_ssl=True,
|
verify_ssl=True,
|
||||||
demo_mode=False,
|
demo_mode=False,
|
||||||
@@ -246,8 +263,9 @@ def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
|
|||||||
object(),
|
object(),
|
||||||
config=config,
|
config=config,
|
||||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||||
|
credential_store=credentials,
|
||||||
)
|
)
|
||||||
first._on_login_success({}, "admin", True)
|
first._on_login_success({}, "admin", True, "secret-value")
|
||||||
first.close()
|
first.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
@@ -255,12 +273,28 @@ def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
|
|||||||
object(),
|
object(),
|
||||||
config=config,
|
config=config,
|
||||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||||
|
credential_store=credentials,
|
||||||
)
|
)
|
||||||
assert restored.account_edit.text() == "admin"
|
assert restored.account_edit.text() == "admin"
|
||||||
|
assert restored.password_edit.text() == "secret-value"
|
||||||
|
assert restored.remember_check.text() == "记住密码"
|
||||||
assert restored.remember_check.isChecked()
|
assert restored.remember_check.isChecked()
|
||||||
|
|
||||||
|
restored._on_login_success({}, "admin", False, "secret-value")
|
||||||
restored.close()
|
restored.close()
|
||||||
application.processEvents()
|
application.processEvents()
|
||||||
|
|
||||||
|
forgotten = LoginWindow(
|
||||||
|
object(),
|
||||||
|
config=config,
|
||||||
|
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||||
|
credential_store=credentials,
|
||||||
|
)
|
||||||
|
assert forgotten.password_edit.text() == ""
|
||||||
|
assert not forgotten.remember_check.isChecked()
|
||||||
|
forgotten.close()
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
|
||||||
def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
|
def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
|
||||||
tmp_path: Any,
|
tmp_path: Any,
|
||||||
@@ -386,6 +420,37 @@ def test_certificate_error_explains_self_signed_server_setting() -> None:
|
|||||||
assert "服务器设置" in message
|
assert "服务器设置" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_qt_standard_dialog_buttons_are_localized_to_chinese() -> None:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
app_module._install_chinese_translations(application)
|
||||||
|
|
||||||
|
question = QMessageBox()
|
||||||
|
question.setStandardButtons(
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
|
||||||
|
)
|
||||||
|
assert question.button(QMessageBox.StandardButton.Yes).text() == "是"
|
||||||
|
assert question.button(QMessageBox.StandardButton.Cancel).text() == "取消"
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok
|
||||||
|
| QDialogButtonBox.StandardButton.Save
|
||||||
|
| QDialogButtonBox.StandardButton.Close
|
||||||
|
)
|
||||||
|
assert buttons.button(QDialogButtonBox.StandardButton.Ok).text() == "确定"
|
||||||
|
assert buttons.button(QDialogButtonBox.StandardButton.Save).text() == "保存"
|
||||||
|
assert buttons.button(QDialogButtonBox.StandardButton.Close).text() == "关闭"
|
||||||
|
|
||||||
|
|
||||||
|
def test_friendly_error_hides_english_technical_messages() -> None:
|
||||||
|
message = friendly_error(
|
||||||
|
TypeError("invoke() takes 2 positional arguments but 3 were given")
|
||||||
|
)
|
||||||
|
assert message == "程序执行失败,请重试;若问题持续出现,请联系管理员。"
|
||||||
|
assert friendly_error(RuntimeError("API response envelope must be an object")) == (
|
||||||
|
"服务器返回的数据格式不正确,请联系管理员检查接口。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
||||||
application = QApplication.instance() or QApplication([])
|
application = QApplication.instance() or QApplication([])
|
||||||
settings = QSettings(str(tmp_path / "certificate-error.ini"), QSettings.Format.IniFormat)
|
settings = QSettings(str(tmp_path / "certificate-error.ini"), QSettings.Format.IniFormat)
|
||||||
|
|||||||
@@ -274,6 +274,57 @@ def test_failed_start_prevents_bind_and_end_writes() -> None:
|
|||||||
assert events == ["start"]
|
assert events == ["start"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
|
||||||
|
events: list[tuple[object, ...]] = []
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def start_call(self, diagnosis_id: int, *, call_type: int) -> None:
|
||||||
|
events.append(("start", diagnosis_id, call_type))
|
||||||
|
|
||||||
|
def upload_material_bytes(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
filename: str,
|
||||||
|
material_type: str,
|
||||||
|
cid: int = 0,
|
||||||
|
) -> str:
|
||||||
|
events.append(("upload", content, filename, material_type, cid))
|
||||||
|
return "/uploads/image/callshot-123.jpg"
|
||||||
|
|
||||||
|
def add_doctor_note(
|
||||||
|
self,
|
||||||
|
diagnosis_id: int,
|
||||||
|
content: str,
|
||||||
|
tongue_images: list[str],
|
||||||
|
) -> None:
|
||||||
|
events.append(("note", diagnosis_id, content, tongue_images))
|
||||||
|
|
||||||
|
def end_call(self, diagnosis_id: int) -> None:
|
||||||
|
events.append(("end", diagnosis_id))
|
||||||
|
|
||||||
|
request = VideoCallRequest(
|
||||||
|
sdk_app_id=1400123456,
|
||||||
|
user_id="doctor_42",
|
||||||
|
user_sig="short-lived-ticket",
|
||||||
|
target_user_id="patient_8",
|
||||||
|
diagnosis_id=123,
|
||||||
|
)
|
||||||
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||||
|
|
||||||
|
lifecycle.start()
|
||||||
|
screenshot = lifecycle.save_screenshot(b"jpeg-frame", "callshot-123.jpg")
|
||||||
|
lifecycle.end("test")
|
||||||
|
|
||||||
|
assert screenshot.result(timeout=2) == "/uploads/image/callshot-123.jpg"
|
||||||
|
assert lifecycle.wait(1) is True
|
||||||
|
assert events == [
|
||||||
|
("start", 123, 2),
|
||||||
|
("upload", b"jpeg-frame", "callshot-123.jpg", "image", 0),
|
||||||
|
("note", 123, "", ["/uploads/image/callshot-123.jpg"]),
|
||||||
|
("end", 123),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_https_document_policy_is_exact_and_origin_scoped() -> None:
|
def test_https_document_policy_is_exact_and_origin_scoped() -> None:
|
||||||
policy = TrustedDocumentPolicy.from_url(
|
policy = TrustedDocumentPolicy.from_url(
|
||||||
"https://RTC.Example.com/doctor-call/index.html?tenant=a#boot",
|
"https://RTC.Example.com/doctor-call/index.html?tenant=a#boot",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
:root{font-family:Inter,PingFang SC,Microsoft YaHei,system-ui,sans-serif;color:#f7f8fa;background:#0b0f14;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}html,body,#app{width:100%;height:100%;margin:0;overflow:hidden}button,input{font:inherit}.call-stage{position:relative;width:100%;height:100%;min-height:420px;overflow:hidden;background:radial-gradient(circle at 50% 35%,rgba(39,74,83,.22),transparent 38%),#0b0f14}.call-kit,.call-stage :is(.TUICallKit-desktop,.TUICallKit-mobile,#tuicallkit-id){width:100%!important;height:100%!important;max-width:none!important;max-height:none!important}.status-card{position:absolute;inset:50% auto auto 50%;display:grid;grid-template-columns:12px minmax(0,1fr);gap:18px;width:min(520px,calc(100% - 48px));padding:30px 32px;transform:translate(-50%,-50%);border:1px solid rgba(255,255,255,.09);border-radius:20px;background:#131920e6;box-shadow:0 24px 70px #00000052;backdrop-filter:blur(18px)}.eyebrow{margin:0 0 12px;color:#8f9ba8;font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase}.status-card h1{margin:0;font-size:clamp(22px,3.2vw,34px);font-weight:600;line-height:1.25}.status-hint{margin:14px 0 0;color:#9aa5b1;font-size:14px}.status-dot{width:10px;height:10px;margin-top:5px;border-radius:50%;background:#77818c;box-shadow:0 0 0 5px #77818c1f}.status-dot--starting,.status-dot--live{background:#52c99a;box-shadow:0 0 0 5px #52c99a24}.status-dot--error{background:#f26d6d;box-shadow:0 0 0 5px #f26d6d24}.live-status{position:absolute;z-index:20;top:18px;left:50%;display:flex;align-items:center;gap:10px;padding:9px 14px;transform:translate(-50%);border:1px solid rgba(255,255,255,.1);border-radius:999px;background:#0b0f14c2;color:#e8edf2;font-size:13px;backdrop-filter:blur(14px)}.live-status .status-dot{width:7px;height:7px;margin:0;box-shadow:none}
|
|
||||||
+632
File diff suppressed because one or more lines are too long
-532
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+3
-3
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="light" />
|
||||||
<title>视频面诊</title>
|
<title>视频面诊</title>
|
||||||
<script type="module" crossorigin src="./assets/index-le5ZH3pL.js"></script>
|
<script type="module" crossorigin src="./assets/index-DwSVWep6.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BuO_uDya.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-qOBmgxQV.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="light" />
|
||||||
<title>视频面诊</title>
|
<title>视频面诊</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+8
@@ -8,7 +8,9 @@
|
|||||||
"name": "doctor-video-companion",
|
"name": "doctor-video-companion",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tencentcloud/lite-chat": "1.6.18",
|
||||||
"@trtc/calls-uikit-vue": "4.4.6",
|
"@trtc/calls-uikit-vue": "4.4.6",
|
||||||
|
"tim-upload-plugin": "1.4.3",
|
||||||
"vue": "3.5.13"
|
"vue": "3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1432,6 +1434,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tim-upload-plugin": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tim-upload-plugin/-/tim-upload-plugin-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-3ZmbA36dr3eG9YGDon9MLBUtbNawYWkL+TBa+VS0Uviguc7PlVSOIVRG2C4irXX16slDT2Kj+HAZapp+Xqp2xg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/trtc-cloud-js-sdk": {
|
"node_modules/trtc-cloud-js-sdk": {
|
||||||
"version": "2.10.19",
|
"version": "2.10.19",
|
||||||
"resolved": "https://registry.npmjs.org/trtc-cloud-js-sdk/-/trtc-cloud-js-sdk-2.10.19.tgz",
|
"resolved": "https://registry.npmjs.org/trtc-cloud-js-sdk/-/trtc-cloud-js-sdk-2.10.19.tgz",
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"preview": "vite preview --port 4173"
|
"preview": "vite preview --port 4173"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tencentcloud/lite-chat": "1.6.18",
|
||||||
"@trtc/calls-uikit-vue": "4.4.6",
|
"@trtc/calls-uikit-vue": "4.4.6",
|
||||||
|
"tim-upload-plugin": "1.4.3",
|
||||||
"vue": "3.5.13"
|
"vue": "3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,15 +1,226 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { TUICallKit } from '@trtc/calls-uikit-vue'
|
import { TUICallKit } from '@trtc/calls-uikit-vue'
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
mine: boolean
|
||||||
|
type: 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||||
|
text: string
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
phase: Readonly<Ref<string>>
|
phase: Readonly<Ref<string>>
|
||||||
statusText: Readonly<Ref<string>>
|
statusText: Readonly<Ref<string>>
|
||||||
|
patientName: Readonly<Ref<string>>
|
||||||
|
mode: Readonly<Ref<string>>
|
||||||
|
messages: Readonly<Ref<ChatMessage[]>>
|
||||||
|
chatReady: Readonly<Ref<boolean>>
|
||||||
|
chatBusy: Readonly<Ref<boolean>>
|
||||||
|
notice: Readonly<Ref<string>>
|
||||||
|
hasMoreMessages: Readonly<Ref<boolean>>
|
||||||
|
onSendText: (text: string) => Promise<void>
|
||||||
|
onSendAttachment: (file: File) => Promise<void>
|
||||||
|
onLoadMore: () => Promise<void>
|
||||||
|
onReconnectChat: () => Promise<void>
|
||||||
|
onStartVideo: () => Promise<void>
|
||||||
|
onHangup: () => Promise<void>
|
||||||
|
onSaveScreenshot: (dataUrl: string) => Promise<void>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const draft = ref('')
|
||||||
|
const actionBusy = ref(false)
|
||||||
|
const localError = ref('')
|
||||||
|
const messageList = ref<HTMLElement | null>(null)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const isChat = computed(() => props.mode.value === 'chat')
|
||||||
|
const isCalling = computed(() => ['starting', 'dialing', 'connected'].includes(props.phase.value))
|
||||||
|
const videoVisible = computed(() => !isChat.value || isCalling.value)
|
||||||
|
const canCapture = computed(() => props.phase.value === 'connected')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.messages.value.length,
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function runAction(action: () => Promise<void>): Promise<void> {
|
||||||
|
if (actionBusy.value) return
|
||||||
|
actionBusy.value = true
|
||||||
|
localError.value = ''
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
} catch (error) {
|
||||||
|
localError.value = error instanceof Error ? error.message : '操作失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
actionBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendText(): Promise<void> {
|
||||||
|
const content = draft.value.trim()
|
||||||
|
if (!content) return
|
||||||
|
await runAction(async () => {
|
||||||
|
await props.onSendText(content)
|
||||||
|
draft.value = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAttachment(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ''
|
||||||
|
if (!file) return
|
||||||
|
await runAction(() => props.onSendAttachment(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRemoteVideo(): HTMLVideoElement | null {
|
||||||
|
const videos = Array.from(document.querySelectorAll('video'))
|
||||||
|
.filter((video) => video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0)
|
||||||
|
if (!videos.length) return null
|
||||||
|
return videos.sort((left, right) => {
|
||||||
|
const leftArea = left.getBoundingClientRect().width * left.getBoundingClientRect().height
|
||||||
|
const rightArea = right.getBoundingClientRect().width * right.getBoundingClientRect().height
|
||||||
|
return rightArea - leftArea
|
||||||
|
})[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureScreenshot(): Promise<void> {
|
||||||
|
await runAction(async () => {
|
||||||
|
const video = findRemoteVideo()
|
||||||
|
if (!video) throw new Error('尚未检测到患者视频画面')
|
||||||
|
const scale = Math.min(1, 1920 / video.videoWidth)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = Math.max(1, Math.round(video.videoWidth * scale))
|
||||||
|
canvas.height = Math.max(1, Math.round(video.videoHeight * scale))
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('无法创建截图画布')
|
||||||
|
context.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
await props.onSaveScreenshot(canvas.toDataURL('image/jpeg', 0.9))
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="call-stage">
|
<main class="consultation-shell" :class="{ 'consultation-shell--video-only': !isChat }">
|
||||||
|
<section v-if="isChat" class="chat-panel">
|
||||||
|
<header class="chat-header">
|
||||||
|
<div class="patient-avatar" aria-hidden="true">{{ patientName.value.slice(0, 1) }}</div>
|
||||||
|
<div class="chat-heading">
|
||||||
|
<h1>{{ patientName.value }}</h1>
|
||||||
|
<p>
|
||||||
|
<span class="connection-dot" :class="{ 'connection-dot--online': chatReady.value }" />
|
||||||
|
{{ chatReady.value ? 'IM 已连接' : statusText.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!chatReady.value"
|
||||||
|
class="secondary-action"
|
||||||
|
type="button"
|
||||||
|
:disabled="actionBusy"
|
||||||
|
@click="runAction(onReconnectChat)"
|
||||||
|
>
|
||||||
|
重新连接 IM
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="primary-action"
|
||||||
|
type="button"
|
||||||
|
:disabled="actionBusy || isCalling || !chatReady.value"
|
||||||
|
@click="runAction(onStartVideo)"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">▣</span>
|
||||||
|
{{ isCalling ? '视频通话中' : '发起视频' }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div ref="messageList" class="message-list" aria-live="polite">
|
||||||
|
<button
|
||||||
|
v-if="hasMoreMessages.value"
|
||||||
|
class="load-more"
|
||||||
|
type="button"
|
||||||
|
:disabled="chatBusy.value"
|
||||||
|
@click="runAction(onLoadMore)"
|
||||||
|
>
|
||||||
|
{{ chatBusy.value ? '正在读取…' : '查看更早消息' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="!messages.value.length && !chatBusy.value" class="empty-chat">
|
||||||
|
<div class="empty-chat__icon" aria-hidden="true">IM</div>
|
||||||
|
<h2>开始问诊沟通</h2>
|
||||||
|
<p>消息会通过腾讯云 IM 实时发送给患者。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="message in messages.value"
|
||||||
|
:key="message.id"
|
||||||
|
class="message-row"
|
||||||
|
:class="{ 'message-row--mine': message.mine }"
|
||||||
|
>
|
||||||
|
<div class="message-meta">{{ message.mine ? '我' : patientName.value }} · {{ message.time }}</div>
|
||||||
|
<div class="message-bubble">
|
||||||
|
<p v-if="message.type === 'text'">{{ message.text }}</p>
|
||||||
|
<img
|
||||||
|
v-else-if="message.type === 'image' && message.url"
|
||||||
|
class="message-image"
|
||||||
|
:src="message.url"
|
||||||
|
alt="问诊图片"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-else-if="message.type === 'file' && message.url"
|
||||||
|
class="message-file"
|
||||||
|
:href="message.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">▤</span>{{ message.name || '查看文件' }}
|
||||||
|
</a>
|
||||||
|
<audio v-else-if="message.type === 'audio' && message.url" :src="message.url" controls />
|
||||||
|
<video v-else-if="message.type === 'video' && message.url" class="message-video" :src="message.url" controls />
|
||||||
|
<p v-else>{{ message.text }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="composer">
|
||||||
|
<div v-if="localError || notice.value" class="inline-notice" :class="{ 'inline-notice--error': localError }">
|
||||||
|
{{ localError || notice.value }}
|
||||||
|
</div>
|
||||||
|
<div class="composer-toolbar">
|
||||||
|
<button type="button" title="发送图片或文件" :disabled="!chatReady.value || actionBusy" @click="fileInput?.click()">
|
||||||
|
+ 图片/文件
|
||||||
|
</button>
|
||||||
|
<input ref="fileInput" type="file" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" hidden @change="selectAttachment">
|
||||||
|
<span>Enter 发送,Shift + Enter 换行</span>
|
||||||
|
</div>
|
||||||
|
<div class="composer-row">
|
||||||
|
<textarea
|
||||||
|
v-model="draft"
|
||||||
|
rows="3"
|
||||||
|
maxlength="3000"
|
||||||
|
placeholder="输入问诊消息…"
|
||||||
|
:disabled="!chatReady.value"
|
||||||
|
@keydown.enter.exact.prevent="sendText"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="send-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!chatReady.value || !draft.trim() || actionBusy"
|
||||||
|
@click="sendText"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="videoVisible" class="video-layer" :class="{ 'video-layer--overlay': isChat }">
|
||||||
<TUICallKit
|
<TUICallKit
|
||||||
class="call-kit"
|
class="call-kit"
|
||||||
:allowed-minimized="false"
|
:allowed-minimized="false"
|
||||||
@@ -19,9 +230,9 @@ defineProps<{
|
|||||||
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
|
<section v-if="phase.value === 'ready' || phase.value === 'starting' || phase.value === 'error'" class="status-card">
|
||||||
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
<span class="status-dot" :class="`status-dot--${phase.value}`" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">中医视频面诊</p>
|
<p class="eyebrow">中医视频问诊</p>
|
||||||
<h1>{{ statusText.value }}</h1>
|
<h2>{{ statusText.value }}</h2>
|
||||||
<p v-if="phase.value === 'ready'" class="status-hint">通话凭证仅由业务后端签发</p>
|
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -29,5 +240,24 @@ defineProps<{
|
|||||||
<span class="status-dot status-dot--live" aria-hidden="true" />
|
<span class="status-dot status-dot--live" aria-hidden="true" />
|
||||||
{{ statusText.value }}
|
{{ statusText.value }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isCalling" class="video-actions">
|
||||||
|
<button
|
||||||
|
class="capture-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canCapture || actionBusy"
|
||||||
|
@click="captureScreenshot"
|
||||||
|
>
|
||||||
|
截屏并保存患者资料
|
||||||
|
</button>
|
||||||
|
<button class="hangup-button" type="button" :disabled="actionBusy" @click="runAction(onHangup)">
|
||||||
|
结束视频
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="localError || notice.value" class="video-notice" :class="{ 'video-notice--error': localError }">
|
||||||
|
{{ localError || notice.value }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Vendored
+18
@@ -1,5 +1,10 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module 'tim-upload-plugin' {
|
||||||
|
const plugin: unknown
|
||||||
|
export default plugin
|
||||||
|
}
|
||||||
|
|
||||||
interface DoctorCallConfig {
|
interface DoctorCallConfig {
|
||||||
SDKAppID?: number | string
|
SDKAppID?: number | string
|
||||||
sdkAppId?: number | string
|
sdkAppId?: number | string
|
||||||
@@ -9,6 +14,8 @@ interface DoctorCallConfig {
|
|||||||
targetUserId?: string
|
targetUserId?: string
|
||||||
patientUserId?: string
|
patientUserId?: string
|
||||||
diagnosisId: number | string
|
diagnosisId: number | string
|
||||||
|
patientName?: string
|
||||||
|
mode?: 'chat' | 'video'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DoctorCallApi {
|
interface DoctorCallApi {
|
||||||
@@ -16,12 +23,23 @@ interface DoctorCallApi {
|
|||||||
hangup(): Promise<void>
|
hangup(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DoctorConsultationApi {
|
||||||
|
open(config: DoctorCallConfig): Promise<void>
|
||||||
|
close(): Promise<void>
|
||||||
|
startVideo(): Promise<void>
|
||||||
|
hangup(): Promise<void>
|
||||||
|
hostCallReady(ok: boolean, message?: string): void
|
||||||
|
screenshotResult(ok: boolean, message: string): void
|
||||||
|
}
|
||||||
|
|
||||||
interface QtVideoBridge {
|
interface QtVideoBridge {
|
||||||
notify?: (payload: string) => void
|
notify?: (payload: string) => void
|
||||||
|
saveScreenshot?: (dataUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
doctorCall: DoctorCallApi
|
doctorCall: DoctorCallApi
|
||||||
|
doctorConsultation: DoctorConsultationApi
|
||||||
qtVideoBridge?: QtVideoBridge
|
qtVideoBridge?: QtVideoBridge
|
||||||
qt?: { webChannelTransport?: unknown }
|
qt?: { webChannelTransport?: unknown }
|
||||||
QWebChannel?: new (
|
QWebChannel?: new (
|
||||||
|
|||||||
+470
-92
@@ -1,3 +1,5 @@
|
|||||||
|
import TencentCloudChat from '@tencentcloud/lite-chat'
|
||||||
|
import TIMUploadPlugin from 'tim-upload-plugin'
|
||||||
import { createApp, nextTick, readonly, ref } from 'vue'
|
import { createApp, nextTick, readonly, ref } from 'vue'
|
||||||
import {
|
import {
|
||||||
NAME,
|
NAME,
|
||||||
@@ -11,6 +13,8 @@ import App from './App.vue'
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
type CallPhase = 'ready' | 'starting' | 'dialing' | 'connected' | 'ended' | 'error'
|
||||||
|
type CompanionMode = 'chat' | 'video'
|
||||||
|
type ChatMessageType = 'text' | 'image' | 'file' | 'audio' | 'video' | 'system'
|
||||||
|
|
||||||
interface NormalizedCallConfig {
|
interface NormalizedCallConfig {
|
||||||
SDKAppID: number
|
SDKAppID: number
|
||||||
@@ -18,11 +22,23 @@ interface NormalizedCallConfig {
|
|||||||
userSig: string
|
userSig: string
|
||||||
targetUserId: string
|
targetUserId: string
|
||||||
diagnosisId: number | string
|
diagnosisId: number | string
|
||||||
|
patientName: string
|
||||||
|
mode: CompanionMode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UiChatMessage {
|
||||||
|
id: string
|
||||||
|
mine: boolean
|
||||||
|
type: ChatMessageType
|
||||||
|
text: string
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
time: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BridgeMessage {
|
interface BridgeMessage {
|
||||||
source: 'doctor-call'
|
source: 'doctor-call'
|
||||||
event: 'ready' | 'status' | 'room' | 'hangup' | 'error'
|
event: 'ready' | 'call-start-request' | 'status' | 'room' | 'hangup' | 'error'
|
||||||
diagnosisId?: number | string
|
diagnosisId?: number | string
|
||||||
status?: string
|
status?: string
|
||||||
roomId?: string
|
roomId?: string
|
||||||
@@ -30,11 +46,25 @@ interface BridgeMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const phase = ref<CallPhase>('ready')
|
const phase = ref<CallPhase>('ready')
|
||||||
const statusText = ref('等待桌面端发起视频面诊')
|
const statusText = ref('正在连接问诊服务')
|
||||||
|
const patientName = ref('患者')
|
||||||
|
const mode = ref<CompanionMode>('video')
|
||||||
|
const messages = ref<UiChatMessage[]>([])
|
||||||
|
const chatReady = ref(false)
|
||||||
|
const chatBusy = ref(false)
|
||||||
|
const notice = ref('')
|
||||||
|
const hasMoreMessages = ref(false)
|
||||||
|
|
||||||
let activeConfig: NormalizedCallConfig | null = null
|
let activeConfig: NormalizedCallConfig | null = null
|
||||||
let endNotified = false
|
let chat: any = null
|
||||||
|
let chatReadyPromise: Promise<void> | null = null
|
||||||
|
let resolveChatReady: (() => void) | null = null
|
||||||
|
let rejectChatReady: ((reason?: unknown) => void) | null = null
|
||||||
|
let nextReqMessageID = ''
|
||||||
|
let endNotified = true
|
||||||
let starting = false
|
let starting = false
|
||||||
let emittedRoomId = ''
|
let emittedRoomId = ''
|
||||||
|
let resolveHostCallReady: ((value: boolean) => void) | null = null
|
||||||
|
|
||||||
function initializeQtWebChannel(): void {
|
function initializeQtWebChannel(): void {
|
||||||
const transport = window.qt?.webChannelTransport
|
const transport = window.qt?.webChannelTransport
|
||||||
@@ -48,31 +78,28 @@ function initializeQtWebChannel(): void {
|
|||||||
emit({ source: 'doctor-call', event: 'ready' })
|
emit({ source: 'doctor-call', event: 'ready' })
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('[doctor-call] Qt WebChannel 初始化失败,将使用 postMessage 通知')
|
console.warn('[doctor-consultation] Qt 通信通道初始化失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function postToHost(message: BridgeMessage): boolean {
|
function postToHost(message: BridgeMessage): boolean {
|
||||||
let delivered = false
|
let delivered = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (window.parent && window.parent !== window) {
|
if (window.parent && window.parent !== window) {
|
||||||
window.parent.postMessage(message, '*')
|
window.parent.postMessage(message, '*')
|
||||||
delivered = true
|
delivered = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Cross-origin host may reject access; opener remains available as a fallback.
|
// The Qt bridge remains the primary transport.
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (window.opener && !window.opener.closed) {
|
if (window.opener && !window.opener.closed) {
|
||||||
window.opener.postMessage(message, '*')
|
window.opener.postMessage(message, '*')
|
||||||
delivered = true
|
delivered = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// The console fallback below is intentionally non-sensitive.
|
// A detached opener is harmless.
|
||||||
}
|
}
|
||||||
|
|
||||||
return delivered
|
return delivered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,62 +110,358 @@ function emit(message: BridgeMessage): void {
|
|||||||
bridge.notify(JSON.stringify(message))
|
bridge.notify(JSON.stringify(message))
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
// A detached WebChannel object is equivalent to an unavailable bridge.
|
// Fall through to browser messaging when the bridge has detached.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!postToHost(message)) {
|
if (!postToHost(message)) {
|
||||||
console.info('[doctor-call]', message.event, message.status ?? message.message ?? '')
|
console.info('[doctor-consultation]', message.event, message.status ?? message.message ?? '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanString(value: unknown, field: string): string {
|
function cleanString(value: unknown, field: string): string {
|
||||||
if (typeof value !== 'string' || value.trim() === '') {
|
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${field}不能为空`)
|
||||||
throw new Error(`${field} 不能为空`)
|
|
||||||
}
|
|
||||||
return value.trim()
|
return value.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
function normalizeConfig(config: DoctorCallConfig): NormalizedCallConfig {
|
||||||
if (!config || typeof config !== 'object') throw new Error('通话配置无效')
|
if (!config || typeof config !== 'object') throw new Error('问诊配置无效')
|
||||||
|
const SDKAppID = Number(config.SDKAppID ?? config.sdkAppId)
|
||||||
const rawSdkAppId = config.SDKAppID ?? config.sdkAppId
|
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) throw new Error('SDKAppID 必须是正整数')
|
||||||
const SDKAppID = Number(rawSdkAppId)
|
|
||||||
if (!Number.isSafeInteger(SDKAppID) || SDKAppID <= 0) {
|
|
||||||
throw new Error('SDKAppID 必须是正整数')
|
|
||||||
}
|
|
||||||
|
|
||||||
const diagnosisId = config.diagnosisId
|
const diagnosisId = config.diagnosisId
|
||||||
if (
|
if (diagnosisId === undefined || diagnosisId === null || String(diagnosisId).trim() === '') {
|
||||||
diagnosisId === undefined
|
throw new Error('诊单ID不能为空')
|
||||||
|| diagnosisId === null
|
|
||||||
|| (typeof diagnosisId === 'string' && diagnosisId.trim() === '')
|
|
||||||
) {
|
|
||||||
throw new Error('diagnosisId 不能为空')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
SDKAppID,
|
SDKAppID,
|
||||||
userID: cleanString(config.userID ?? config.userId, 'userID'),
|
userID: cleanString(config.userID ?? config.userId, '医生用户ID'),
|
||||||
userSig: cleanString(config.userSig, 'userSig'),
|
userSig: cleanString(config.userSig, '用户签名'),
|
||||||
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, 'targetUserId'),
|
targetUserId: cleanString(config.targetUserId ?? config.patientUserId, '患者用户ID'),
|
||||||
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
|
diagnosisId: typeof diagnosisId === 'string' ? diagnosisId.trim() : diagnosisId,
|
||||||
|
patientName: typeof config.patientName === 'string' && config.patientName.trim()
|
||||||
|
? config.patientName.trim()
|
||||||
|
: '患者',
|
||||||
|
mode: config.mode === 'chat' ? 'chat' : 'video',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeErrorMessage(error: unknown): string {
|
function safeErrorMessage(error: unknown, fallback = '问诊服务发生未知错误'): string {
|
||||||
let message = error instanceof Error ? error.message : '视频通话发生未知错误'
|
let message = error instanceof Error ? error.message : fallback
|
||||||
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[REDACTED]')
|
if (activeConfig?.userSig) message = message.split(activeConfig.userSig).join('[已隐藏]')
|
||||||
return message
|
message = message
|
||||||
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[REDACTED]')
|
.replace(/(user\s*sig\s*[:=]\s*)[^\s,;&]+/gi, '$1[已隐藏]')
|
||||||
.slice(0, 400)
|
.slice(0, 400)
|
||||||
|
if (/user\s+not\s+logged\s+in|not\s+logged\s+in/i.test(message)) {
|
||||||
|
return 'IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话'
|
||||||
|
}
|
||||||
|
if (/sdk.*not.*ready|not.*ready/i.test(message)) {
|
||||||
|
return 'IM 连接尚未就绪,请稍后重试'
|
||||||
|
}
|
||||||
|
if (/user\s*sig.*expired|signature.*expired/i.test(message)) {
|
||||||
|
return 'IM 登录凭证已过期,请关闭窗口后重新打开'
|
||||||
|
}
|
||||||
|
return message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeText(raw: unknown): string {
|
||||||
|
const seconds = Number(raw)
|
||||||
|
const date = Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000) : new Date()
|
||||||
|
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageUrl(payload: any): string {
|
||||||
|
if (!payload || typeof payload !== 'object') return ''
|
||||||
|
if (typeof payload.url === 'string') return payload.url
|
||||||
|
if (typeof payload.fileUrl === 'string') return payload.fileUrl
|
||||||
|
if (typeof payload.videoUrl === 'string') return payload.videoUrl
|
||||||
|
if (typeof payload.remoteAudioUrl === 'string') return payload.remoteAudioUrl
|
||||||
|
const images = Array.isArray(payload.imageInfoArray) ? payload.imageInfoArray : []
|
||||||
|
const image = images.find((item: any) => item?.type === 0) ?? images.at(-1) ?? images[0]
|
||||||
|
return typeof image?.url === 'string' ? image.url : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(value: unknown): Record<string, any> | null {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, any>
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !value.trim()) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, any>
|
||||||
|
: null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function customMessageContent(payload: any): { hidden: boolean; text: string } {
|
||||||
|
const outer = parseJsonObject(payload?.data) ?? parseJsonObject(payload) ?? {}
|
||||||
|
const inner = parseJsonObject(outer.data) ?? {}
|
||||||
|
const businessID = outer.businessID ?? outer.businessId ?? inner.businessID ?? inner.businessId
|
||||||
|
if (businessID === 1 || String(businessID).toLowerCase().includes('call')) {
|
||||||
|
return { hidden: true, text: '' }
|
||||||
|
}
|
||||||
|
const command = String(outer.command ?? outer.cmd ?? inner.command ?? inner.cmd ?? '').toLowerCase()
|
||||||
|
if (command.includes('call') || command.includes('invite')) {
|
||||||
|
return { hidden: true, text: '' }
|
||||||
|
}
|
||||||
|
const text = [
|
||||||
|
payload?.description,
|
||||||
|
outer.text,
|
||||||
|
outer.content,
|
||||||
|
outer.message,
|
||||||
|
outer.tips,
|
||||||
|
inner.text,
|
||||||
|
inner.content,
|
||||||
|
inner.message,
|
||||||
|
].find((value) => typeof value === 'string' && value.trim())
|
||||||
|
return { hidden: false, text: text ? String(text).trim() : '系统自定义消息' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessage(raw: any): UiChatMessage | null {
|
||||||
|
const payload = raw?.payload ?? {}
|
||||||
|
const rawType = String(raw?.type ?? '')
|
||||||
|
let type: ChatMessageType = 'system'
|
||||||
|
let text = '系统消息'
|
||||||
|
if (rawType === TencentCloudChat.TYPES.MSG_TEXT) {
|
||||||
|
type = 'text'
|
||||||
|
text = String(payload.text ?? '')
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_IMAGE) {
|
||||||
|
type = 'image'
|
||||||
|
text = '[图片]'
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_FILE) {
|
||||||
|
type = 'file'
|
||||||
|
text = '[文件]'
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_AUDIO) {
|
||||||
|
type = 'audio'
|
||||||
|
text = '[语音]'
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_VIDEO) {
|
||||||
|
type = 'video'
|
||||||
|
text = '[视频]'
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_CUSTOM) {
|
||||||
|
const custom = customMessageContent(payload)
|
||||||
|
if (custom.hidden) return null
|
||||||
|
text = custom.text
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_FACE) {
|
||||||
|
text = '[表情消息]'
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_LOCATION) {
|
||||||
|
text = `[位置] ${String(payload.description ?? payload.name ?? '').trim()}`.trim()
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_MERGER) {
|
||||||
|
text = `[合并消息] ${String(payload.title ?? '').trim()}`.trim()
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_GRP_TIP) {
|
||||||
|
text = '[群组通知]'
|
||||||
|
} else if (rawType === TencentCloudChat.TYPES.MSG_GRP_SYS_NOTICE) {
|
||||||
|
text = '[群组系统通知]'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(raw?.ID ?? raw?.id ?? `${raw?.time ?? Date.now()}-${Math.random()}`),
|
||||||
|
mine: Boolean(raw?.flow === 'out' || raw?.from === activeConfig?.userID),
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
url: messageUrl(payload),
|
||||||
|
name: String(payload.fileName ?? payload.name ?? (type === 'file' ? '文件' : '')),
|
||||||
|
time: timeText(raw?.time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMessages(rawList: any[], prepend = false): void {
|
||||||
|
const incoming = rawList
|
||||||
|
.map(normalizeMessage)
|
||||||
|
.filter((item): item is UiChatMessage => item !== null)
|
||||||
|
const combined = prepend ? [...incoming, ...messages.value] : [...messages.value, ...incoming]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
messages.value = combined.filter((item) => {
|
||||||
|
if (seen.has(item.id)) return false
|
||||||
|
seen.add(item.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMessageReceived(event: any): void {
|
||||||
|
if (!activeConfig) return
|
||||||
|
const expected = `C2C${activeConfig.targetUserId}`
|
||||||
|
const list = Array.isArray(event?.data) ? event.data : []
|
||||||
|
const matched = list.filter((item: any) => {
|
||||||
|
const conversationID = String(item?.conversationID ?? '')
|
||||||
|
return conversationID === expected || item?.from === activeConfig?.targetUserId
|
||||||
|
})
|
||||||
|
if (!matched.length) return
|
||||||
|
mergeMessages(matched)
|
||||||
|
void chat?.setMessageRead?.({ conversationID: expected })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSdkReady(): void {
|
||||||
|
chatReady.value = true
|
||||||
|
statusText.value = 'IM 已连接'
|
||||||
|
notice.value = ''
|
||||||
|
resolveChatReady?.()
|
||||||
|
resolveChatReady = null
|
||||||
|
rejectChatReady = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSdkNotReady(event: any): void {
|
||||||
|
const wasReady = chatReady.value
|
||||||
|
chatReady.value = false
|
||||||
|
statusText.value = 'IM 连接已断开'
|
||||||
|
if (wasReady) {
|
||||||
|
notice.value = safeErrorMessage(event?.data?.message ?? event?.message, 'IM 连接已断开,请重新打开会话')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKickedOut(event: any): void {
|
||||||
|
chatReady.value = false
|
||||||
|
statusText.value = 'IM 已在其他窗口登录'
|
||||||
|
const type = String(event?.data?.type ?? event?.data ?? '')
|
||||||
|
notice.value = type.includes('userSigExpired')
|
||||||
|
? 'IM 登录凭证已过期,请关闭窗口后重新打开'
|
||||||
|
: '当前账号已在另一个 IM 窗口登录,请关闭其他患者 IM 窗口后重新打开本会话'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNetStateChange(event: any): void {
|
||||||
|
const state = String(event?.data?.state ?? event?.data ?? '').toUpperCase()
|
||||||
|
if (state.includes('DISCONNECTED')) {
|
||||||
|
statusText.value = 'IM 网络连接中断,正在自动恢复'
|
||||||
|
} else if (state.includes('CONNECTED') && chatReady.value) {
|
||||||
|
statusText.value = 'IM 已连接'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginChat(): Promise<void> {
|
||||||
|
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
||||||
|
chatReady.value = false
|
||||||
|
statusText.value = '正在连接患者 IM'
|
||||||
|
chat = TencentCloudChat.create({ SDKAppID: activeConfig.SDKAppID })
|
||||||
|
chat.setLogLevel(2)
|
||||||
|
chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin })
|
||||||
|
chat.on(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
|
||||||
|
chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady)
|
||||||
|
chat.on(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut)
|
||||||
|
chat.on(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange)
|
||||||
|
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived)
|
||||||
|
chatReadyPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
resolveChatReady = resolve
|
||||||
|
rejectChatReady = reject
|
||||||
|
})
|
||||||
|
await chat.login({ userID: activeConfig.userID, userSig: activeConfig.userSig })
|
||||||
|
await Promise.race([
|
||||||
|
chatReadyPromise,
|
||||||
|
new Promise<void>((_, reject) => window.setTimeout(() => reject(new Error('IM 连接超时')), 15000)),
|
||||||
|
])
|
||||||
|
await loadMessages(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutChat(): Promise<void> {
|
||||||
|
if (!chat) return
|
||||||
|
try {
|
||||||
|
chat.off(TencentCloudChat.EVENT.SDK_READY, onSdkReady)
|
||||||
|
chat.off(TencentCloudChat.EVENT.SDK_NOT_READY, onSdkNotReady)
|
||||||
|
chat.off(TencentCloudChat.EVENT.KICKED_OUT, onKickedOut)
|
||||||
|
chat.off(TencentCloudChat.EVENT.NET_STATE_CHANGE, onNetStateChange)
|
||||||
|
chat.off(TencentCloudChat.EVENT.MESSAGE_RECEIVED, onMessageReceived)
|
||||||
|
await chat.logout()
|
||||||
|
} catch {
|
||||||
|
// Closing the desktop window must not be blocked by a stale IM connection.
|
||||||
|
} finally {
|
||||||
|
chat = null
|
||||||
|
chatReady.value = false
|
||||||
|
chatReadyPromise = null
|
||||||
|
resolveChatReady = null
|
||||||
|
rejectChatReady = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconnectChat(): Promise<void> {
|
||||||
|
if (!activeConfig || activeConfig.mode !== 'chat') throw new Error('当前不是 IM 会话')
|
||||||
|
notice.value = ''
|
||||||
|
await logoutChat()
|
||||||
|
await loginChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMessages(prepend: boolean): Promise<void> {
|
||||||
|
if (!chat || !activeConfig || chatBusy.value) return
|
||||||
|
chatBusy.value = true
|
||||||
|
try {
|
||||||
|
const response = await chat.getMessageList({
|
||||||
|
conversationID: `C2C${activeConfig.targetUserId}`,
|
||||||
|
nextReqMessageID: prepend ? nextReqMessageID : '',
|
||||||
|
count: 30,
|
||||||
|
})
|
||||||
|
const data = response?.data ?? {}
|
||||||
|
const list = Array.isArray(data.messageList) ? data.messageList : []
|
||||||
|
nextReqMessageID = String(data.nextReqMessageID ?? '')
|
||||||
|
hasMoreMessages.value = !Boolean(data.isCompleted) && Boolean(nextReqMessageID)
|
||||||
|
mergeMessages(list, prepend)
|
||||||
|
await chat.setMessageRead({ conversationID: `C2C${activeConfig.targetUserId}` })
|
||||||
|
} catch (error) {
|
||||||
|
notice.value = `读取消息失败:${safeErrorMessage(error)}`
|
||||||
|
} finally {
|
||||||
|
chatBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendText(text: string): Promise<void> {
|
||||||
|
if (
|
||||||
|
!chat
|
||||||
|
|| !activeConfig
|
||||||
|
|| !chatReady.value
|
||||||
|
|| !chat.isReady?.()
|
||||||
|
|| chat.getLoginUser?.() !== activeConfig.userID
|
||||||
|
) {
|
||||||
|
chatReady.value = false
|
||||||
|
statusText.value = 'IM 登录已失效'
|
||||||
|
throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话')
|
||||||
|
}
|
||||||
|
const content = text.trim()
|
||||||
|
if (!content) return
|
||||||
|
try {
|
||||||
|
const message = chat.createTextMessage({
|
||||||
|
to: activeConfig.targetUserId,
|
||||||
|
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||||
|
payload: { text: content },
|
||||||
|
})
|
||||||
|
const response = await chat.sendMessage(message)
|
||||||
|
mergeMessages([response?.data?.message ?? response?.data ?? message])
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(safeErrorMessage(error, '消息发送失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAttachment(file: File): Promise<void> {
|
||||||
|
if (
|
||||||
|
!chat
|
||||||
|
|| !activeConfig
|
||||||
|
|| !chatReady.value
|
||||||
|
|| !chat.isReady?.()
|
||||||
|
|| chat.getLoginUser?.() !== activeConfig.userID
|
||||||
|
) {
|
||||||
|
chatReady.value = false
|
||||||
|
statusText.value = 'IM 登录已失效'
|
||||||
|
throw new Error('IM 登录已失效,请关闭其他患者 IM 窗口后重新打开本会话')
|
||||||
|
}
|
||||||
|
if (file.size > 20 * 1024 * 1024) throw new Error('附件不能超过 20MB')
|
||||||
|
const base = {
|
||||||
|
to: activeConfig.targetUserId,
|
||||||
|
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||||
|
payload: { file },
|
||||||
|
}
|
||||||
|
const message = file.type.startsWith('image/')
|
||||||
|
? chat.createImageMessage(base)
|
||||||
|
: chat.createFileMessage(base)
|
||||||
|
try {
|
||||||
|
const response = await chat.sendMessage(message)
|
||||||
|
mergeMessages([response?.data?.message ?? response?.data ?? message])
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(safeErrorMessage(error, '附件发送失败'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyHangup(status = 'ended'): void {
|
function notifyHangup(status = 'ended'): void {
|
||||||
if (endNotified) return
|
if (endNotified) return
|
||||||
endNotified = true
|
endNotified = true
|
||||||
phase.value = 'ended'
|
phase.value = 'ended'
|
||||||
statusText.value = '视频面诊已结束'
|
statusText.value = mode.value === 'chat' ? '视频通话已结束,IM 保持连接' : '视频问诊已结束'
|
||||||
emit({
|
emit({
|
||||||
source: 'doctor-call',
|
source: 'doctor-call',
|
||||||
event: 'hangup',
|
event: 'hangup',
|
||||||
@@ -158,12 +481,7 @@ function emitRoomId(): boolean {
|
|||||||
const roomId = readRoomId()
|
const roomId = readRoomId()
|
||||||
if (!roomId || roomId === emittedRoomId) return Boolean(roomId)
|
if (!roomId || roomId === emittedRoomId) return Boolean(roomId)
|
||||||
emittedRoomId = roomId
|
emittedRoomId = roomId
|
||||||
emit({
|
emit({ source: 'doctor-call', event: 'room', diagnosisId: activeConfig?.diagnosisId, roomId })
|
||||||
source: 'doctor-call',
|
|
||||||
event: 'room',
|
|
||||||
diagnosisId: activeConfig?.diagnosisId,
|
|
||||||
roomId,
|
|
||||||
})
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,10 +497,9 @@ function handleStatusChanged(payload: unknown): void {
|
|||||||
? (payload as { newStatus?: unknown }).newStatus
|
? (payload as { newStatus?: unknown }).newStatus
|
||||||
: payload
|
: payload
|
||||||
const status = typeof value === 'string' ? value : 'unknown'
|
const status = typeof value === 'string' ? value : 'unknown'
|
||||||
|
|
||||||
if (status === 'connected' || status.startsWith('calling-')) {
|
if (status === 'connected' || status.startsWith('calling-')) {
|
||||||
phase.value = 'connected'
|
phase.value = 'connected'
|
||||||
statusText.value = '视频面诊进行中'
|
statusText.value = '视频问诊进行中'
|
||||||
void pollRoomId()
|
void pollRoomId()
|
||||||
} else if (status === 'calling' || status.startsWith('dialing')) {
|
} else if (status === 'calling' || status.startsWith('dialing')) {
|
||||||
phase.value = 'dialing'
|
phase.value = 'dialing'
|
||||||
@@ -190,70 +507,71 @@ function handleStatusChanged(payload: unknown): void {
|
|||||||
} else if (status === 'idle' && activeConfig && !starting) {
|
} else if (status === 'idle' && activeConfig && !starting) {
|
||||||
notifyHangup(status)
|
notifyHangup(status)
|
||||||
}
|
}
|
||||||
|
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig?.diagnosisId, status })
|
||||||
emit({
|
|
||||||
source: 'doctor-call',
|
|
||||||
event: 'status',
|
|
||||||
diagnosisId: activeConfig?.diagnosisId,
|
|
||||||
status,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TUICallKitAPI.setCallback({
|
TUICallKitAPI.setCallback({
|
||||||
statusChanged: handleStatusChanged,
|
statusChanged: handleStatusChanged,
|
||||||
afterCalling: () => notifyHangup('after-calling'),
|
afterCalling: () => notifyHangup('after-calling'),
|
||||||
})
|
})
|
||||||
|
TUICallKitAPI.setLanguage('zh-cn')
|
||||||
TUICallKitAPI.enableFloatWindow(false)
|
TUICallKitAPI.enableFloatWindow(false)
|
||||||
|
|
||||||
async function start(config: DoctorCallConfig): Promise<void> {
|
function requestHostCallStart(): Promise<boolean> {
|
||||||
if (starting || (activeConfig && !endNotified)) {
|
if (!activeConfig) return Promise.resolve(false)
|
||||||
throw new Error('已有视频通话正在进行')
|
if (!window.qtVideoBridge?.notify) return Promise.resolve(true)
|
||||||
}
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const currentResolver = resolve
|
||||||
|
resolveHostCallReady = currentResolver
|
||||||
|
emit({ source: 'doctor-call', event: 'call-start-request', diagnosisId: activeConfig?.diagnosisId })
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (resolveHostCallReady !== currentResolver) return
|
||||||
|
resolveHostCallReady = null
|
||||||
|
resolve(false)
|
||||||
|
}, 15000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostCallReady(ok: boolean, message = ''): void {
|
||||||
|
const resolver = resolveHostCallReady
|
||||||
|
resolveHostCallReady = null
|
||||||
|
if (message) notice.value = message
|
||||||
|
resolver?.(Boolean(ok))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startVideo(): Promise<void> {
|
||||||
|
if (!activeConfig) throw new Error('问诊配置尚未准备好')
|
||||||
|
if (starting || !endNotified) throw new Error('已有视频通话正在进行')
|
||||||
starting = true
|
starting = true
|
||||||
endNotified = false
|
endNotified = false
|
||||||
phase.value = 'starting'
|
phase.value = 'starting'
|
||||||
statusText.value = '正在初始化安全通话'
|
statusText.value = '正在创建安全视频通话'
|
||||||
|
notice.value = ''
|
||||||
try {
|
try {
|
||||||
const normalized = normalizeConfig(config)
|
const allowed = await requestHostCallStart()
|
||||||
activeConfig = normalized
|
if (!allowed) throw new Error(notice.value || '服务器未能创建视频通话记录')
|
||||||
emittedRoomId = ''
|
|
||||||
|
|
||||||
await TUICallKitAPI.init({
|
await TUICallKitAPI.init({
|
||||||
SDKAppID: normalized.SDKAppID,
|
SDKAppID: activeConfig.SDKAppID,
|
||||||
userID: normalized.userID,
|
userID: activeConfig.userID,
|
||||||
userSig: normalized.userSig,
|
userSig: activeConfig.userSig,
|
||||||
|
...(chat ? { tim: chat, isFromChat: true } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
phase.value = 'dialing'
|
phase.value = 'dialing'
|
||||||
statusText.value = '正在呼叫患者'
|
statusText.value = '正在呼叫患者'
|
||||||
|
emittedRoomId = ''
|
||||||
await TUICallKitAPI.calls({
|
await TUICallKitAPI.calls({
|
||||||
userIDList: [normalized.targetUserId],
|
userIDList: [activeConfig.targetUserId],
|
||||||
type: TUICallType.VIDEO_CALL,
|
type: TUICallType.VIDEO_CALL,
|
||||||
})
|
})
|
||||||
void pollRoomId()
|
void pollRoomId()
|
||||||
|
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
|
||||||
emit({
|
|
||||||
source: 'doctor-call',
|
|
||||||
event: 'status',
|
|
||||||
diagnosisId: normalized.diagnosisId,
|
|
||||||
status: 'dialing',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = safeErrorMessage(error)
|
const message = safeErrorMessage(error, '无法发起视频通话')
|
||||||
phase.value = 'error'
|
phase.value = 'error'
|
||||||
statusText.value = message
|
statusText.value = message
|
||||||
emit({
|
|
||||||
source: 'doctor-call',
|
|
||||||
event: 'error',
|
|
||||||
diagnosisId: activeConfig?.diagnosisId,
|
|
||||||
message,
|
|
||||||
})
|
|
||||||
activeConfig = null
|
|
||||||
endNotified = true
|
endNotified = true
|
||||||
|
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
} finally {
|
} finally {
|
||||||
starting = false
|
starting = false
|
||||||
@@ -262,28 +580,88 @@ async function start(config: DoctorCallConfig): Promise<void> {
|
|||||||
|
|
||||||
async function hangup(): Promise<void> {
|
async function hangup(): Promise<void> {
|
||||||
if (!activeConfig || endNotified) return
|
if (!activeConfig || endNotified) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TUICallKitAPI.hangup()
|
await TUICallKitAPI.hangup()
|
||||||
notifyHangup('local-hangup')
|
notifyHangup('local-hangup')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = safeErrorMessage(error)
|
const message = safeErrorMessage(error, '结束视频通话失败')
|
||||||
emit({
|
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||||
source: 'doctor-call',
|
|
||||||
event: 'error',
|
|
||||||
diagnosisId: activeConfig.diagnosisId,
|
|
||||||
message,
|
|
||||||
})
|
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.doctorCall = { start, hangup }
|
async function saveScreenshot(dataUrl: string): Promise<void> {
|
||||||
|
const bridge = window.qtVideoBridge
|
||||||
|
if (!bridge || typeof bridge.saveScreenshot !== 'function') throw new Error('桌面端截图保存通道不可用')
|
||||||
|
bridge.saveScreenshot(dataUrl)
|
||||||
|
notice.value = '正在保存截图到患者舌像资料…'
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenshotResult(ok: boolean, message: string): void {
|
||||||
|
notice.value = message || (ok ? '截图已保存到患者舌像资料' : '截图保存失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(config: DoctorCallConfig): Promise<void> {
|
||||||
|
if (activeConfig) await close()
|
||||||
|
activeConfig = normalizeConfig(config)
|
||||||
|
mode.value = activeConfig.mode
|
||||||
|
patientName.value = activeConfig.patientName
|
||||||
|
messages.value = []
|
||||||
|
nextReqMessageID = ''
|
||||||
|
hasMoreMessages.value = false
|
||||||
|
endNotified = true
|
||||||
|
phase.value = 'ready'
|
||||||
|
notice.value = ''
|
||||||
|
if (activeConfig.mode === 'chat') {
|
||||||
|
try {
|
||||||
|
await loginChat()
|
||||||
|
} catch (error) {
|
||||||
|
const message = safeErrorMessage(error, 'IM 连接失败')
|
||||||
|
phase.value = 'error'
|
||||||
|
statusText.value = message
|
||||||
|
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig.diagnosisId, message })
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await startVideo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close(): Promise<void> {
|
||||||
|
if (!endNotified) await hangup()
|
||||||
|
await logoutChat()
|
||||||
|
activeConfig = null
|
||||||
|
phase.value = 'ended'
|
||||||
|
}
|
||||||
|
|
||||||
|
window.doctorConsultation = {
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
startVideo,
|
||||||
|
hangup,
|
||||||
|
hostCallReady,
|
||||||
|
screenshotResult,
|
||||||
|
}
|
||||||
|
window.doctorCall = { start: open, hangup }
|
||||||
initializeQtWebChannel()
|
initializeQtWebChannel()
|
||||||
|
|
||||||
createApp(App, {
|
createApp(App, {
|
||||||
phase: readonly(phase),
|
phase: readonly(phase),
|
||||||
statusText: readonly(statusText),
|
statusText: readonly(statusText),
|
||||||
|
patientName: readonly(patientName),
|
||||||
|
mode: readonly(mode),
|
||||||
|
messages: readonly(messages),
|
||||||
|
chatReady: readonly(chatReady),
|
||||||
|
chatBusy: readonly(chatBusy),
|
||||||
|
notice: readonly(notice),
|
||||||
|
hasMoreMessages: readonly(hasMoreMessages),
|
||||||
|
onSendText: sendText,
|
||||||
|
onSendAttachment: sendAttachment,
|
||||||
|
onLoadMore: () => loadMessages(true),
|
||||||
|
onReconnectChat: reconnectChat,
|
||||||
|
onStartVideo: startVideo,
|
||||||
|
onHangup: hangup,
|
||||||
|
onSaveScreenshot: saveScreenshot,
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|
||||||
emit({ source: 'doctor-call', event: 'ready' })
|
emit({ source: 'doctor-call', event: 'ready' })
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
|
||||||
color: #f7f8fa;
|
color: #132238;
|
||||||
background: #0b0f14;
|
background: #eef3fb;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* { box-sizing: border-box; }
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@@ -20,23 +18,222 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
textarea,
|
||||||
font: inherit;
|
input { font: inherit; }
|
||||||
|
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled { cursor: not-allowed; opacity: .55; }
|
||||||
|
|
||||||
|
.consultation-shell {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 760px;
|
||||||
|
min-height: 540px;
|
||||||
|
background: #eef3fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.call-stage {
|
.chat-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 74px minmax(0, 1fr) auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #f7f9fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid #d7dfef;
|
||||||
|
background: rgba(255, 255, 255, .96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #fff;
|
||||||
|
background: #5267df;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-heading { min-width: 0; flex: 1; }
|
||||||
|
.chat-heading h1 { margin: 0; font-size: 18px; line-height: 1.4; }
|
||||||
|
.chat-heading p {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: #71809a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #a3adbd;
|
||||||
|
}
|
||||||
|
.connection-dot--online { background: #24a77c; box-shadow: 0 0 0 3px rgba(36, 167, 124, .12); }
|
||||||
|
|
||||||
|
.primary-action,
|
||||||
|
.send-button,
|
||||||
|
.capture-button {
|
||||||
|
border: 1px solid #5166df;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #fff;
|
||||||
|
background: #5267df;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.secondary-action {
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid #c8d2e7;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #43526b;
|
||||||
|
background: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.secondary-action:hover { border-color: #7385e9; color: #4055ca; background: #f5f7ff; }
|
||||||
|
.primary-action { display: flex; gap: 8px; align-items: center; padding: 10px 16px; }
|
||||||
|
.primary-action:hover,
|
||||||
|
.send-button:hover,
|
||||||
|
.capture-button:hover { background: #4055ca; }
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px max(24px, calc((100% - 920px) / 2));
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 16%, rgba(82, 103, 223, .055), transparent 25%),
|
||||||
|
#f3f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #d6deed;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #66758e;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-chat {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
margin-top: min(16vh, 110px);
|
||||||
|
color: #7c89a0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-chat__icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid #d2daeb;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #5267df;
|
||||||
|
background: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.empty-chat h2 { margin: 0; color: #34425a; font-size: 17px; }
|
||||||
|
.empty-chat p { margin: 7px 0; font-size: 13px; }
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.message-row--mine { align-items: flex-end; }
|
||||||
|
.message-meta { margin: 0 8px 5px; color: #8995a9; font-size: 11px; }
|
||||||
|
.message-bubble {
|
||||||
|
max-width: min(72%, 620px);
|
||||||
|
padding: 10px 13px;
|
||||||
|
border: 1px solid #d8e0ed;
|
||||||
|
border-radius: 5px 15px 15px 15px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(29, 47, 80, .05);
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.message-row--mine .message-bubble {
|
||||||
|
border-color: #5267df;
|
||||||
|
border-radius: 15px 5px 15px 15px;
|
||||||
|
color: #fff;
|
||||||
|
background: #5267df;
|
||||||
|
}
|
||||||
|
.message-bubble p { margin: 0; white-space: pre-wrap; }
|
||||||
|
.message-image,
|
||||||
|
.message-video { display: block; max-width: 360px; max-height: 280px; border-radius: 9px; }
|
||||||
|
.message-file { display: flex; align-items: center; gap: 8px; color: inherit; text-decoration: none; }
|
||||||
|
.message-bubble audio { max-width: 320px; }
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
padding: 10px 18px 14px;
|
||||||
|
border-top: 1px solid #d7dfef;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.composer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 30px;
|
||||||
|
color: #8a96a9;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.composer-toolbar button {
|
||||||
|
padding: 5px 9px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #5267df;
|
||||||
|
background: #eef1ff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.composer-row { display: grid; grid-template-columns: minmax(0, 1fr) 86px; gap: 12px; }
|
||||||
|
.composer textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 68px;
|
||||||
|
max-height: 150px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #ced8ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
color: #15233a;
|
||||||
|
background: #fbfcff;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.composer textarea:focus { border-color: #6678ed; box-shadow: 0 0 0 3px rgba(82, 103, 223, .11); }
|
||||||
|
.send-button { align-self: end; height: 40px; }
|
||||||
|
|
||||||
|
.inline-notice {
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: #3f6f62;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.inline-notice--error { color: #c14455; }
|
||||||
|
|
||||||
|
.video-layer {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
color: #f7f8fa;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 35%, rgba(39, 74, 83, 0.22), transparent 38%),
|
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
|
||||||
#0b0f14;
|
#090d14;
|
||||||
}
|
}
|
||||||
|
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
|
||||||
|
|
||||||
.call-kit,
|
.call-kit,
|
||||||
.call-stage :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
@@ -52,55 +249,25 @@ input {
|
|||||||
width: min(520px, calc(100% - 48px));
|
width: min(520px, calc(100% - 48px));
|
||||||
padding: 30px 32px;
|
padding: 30px 32px;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: rgba(19, 25, 32, 0.9);
|
background: rgba(19, 25, 32, .92);
|
||||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
|
box-shadow: 0 24px 70px rgba(0, 0, 0, .32);
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
}
|
}
|
||||||
|
.eyebrow { margin: 0 0 12px; color: #a3afbf; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
|
||||||
.eyebrow {
|
.status-card h2 { margin: 0; font-size: clamp(22px, 3.2vw, 34px); font-weight: 600; line-height: 1.25; }
|
||||||
margin: 0 0 12px;
|
.status-hint { margin: 14px 0 0; color: #9aa5b1; font-size: 14px; }
|
||||||
color: #8f9ba8;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: clamp(22px, 3.2vw, 34px);
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-hint {
|
|
||||||
margin: 14px 0 0;
|
|
||||||
color: #9aa5b1;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #77818c;
|
background: #77818c;
|
||||||
box-shadow: 0 0 0 5px rgba(119, 129, 140, 0.12);
|
box-shadow: 0 0 0 5px rgba(119, 129, 140, .12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot--starting,
|
.status-dot--starting,
|
||||||
.status-dot--live {
|
.status-dot--live { background: #52c99a; box-shadow: 0 0 0 5px rgba(82, 201, 154, .14); }
|
||||||
background: #52c99a;
|
.status-dot--error { background: #f26d6d; box-shadow: 0 0 0 5px rgba(242, 109, 109, .14); }
|
||||||
box-shadow: 0 0 0 5px rgba(82, 201, 154, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot--error {
|
|
||||||
background: #f26d6d;
|
|
||||||
box-shadow: 0 0 0 5px rgba(242, 109, 109, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-status {
|
.live-status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
@@ -111,17 +278,41 @@ input {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(11, 15, 20, 0.76);
|
background: rgba(11, 15, 20, .8);
|
||||||
color: #e8edf2;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
}
|
||||||
|
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
|
||||||
|
|
||||||
.live-status .status-dot {
|
.video-actions {
|
||||||
width: 7px;
|
position: absolute;
|
||||||
height: 7px;
|
z-index: 40;
|
||||||
margin: 0;
|
right: 22px;
|
||||||
box-shadow: none;
|
bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.video-actions button { padding: 10px 15px; border-radius: 10px; font-weight: 600; }
|
||||||
|
.hangup-button { border: 1px solid #b44755; color: #fff; background: rgba(161, 47, 61, .9); }
|
||||||
|
.hangup-button:hover { background: #be394d; }
|
||||||
|
.video-notice {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 40;
|
||||||
|
left: 22px;
|
||||||
|
bottom: 26px;
|
||||||
|
max-width: calc(100% - 420px);
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid rgba(82, 201, 154, .35);
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #d9f8eb;
|
||||||
|
background: rgba(20, 78, 62, .84);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.video-notice--error { border-color: rgba(242, 109, 109, .4); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.consultation-shell { min-width: 620px; }
|
||||||
|
.message-list { padding-inline: 18px; }
|
||||||
|
.message-bubble { max-width: 82%; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'completed' => (int) ($todaySchedule['completed_appointments'] ?? 0),
|
'completed' => (int) ($todaySchedule['completed_appointments'] ?? 0),
|
||||||
'missed' => (int) ($todaySchedule['missed_appointments'] ?? 0),
|
'missed' => (int) ($todaySchedule['missed_appointments'] ?? 0),
|
||||||
'empty_slots' => 0,
|
'empty_slots' => 0,
|
||||||
|
'passed_slots' => 0,
|
||||||
|
'remaining_slots' => 0,
|
||||||
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
|
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -84,7 +86,10 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'booked' => (int) ($todaySchedule['booked_slots'] ?? 0),
|
'booked' => (int) ($todaySchedule['booked_slots'] ?? 0),
|
||||||
'completed' => 0,
|
'completed' => 0,
|
||||||
'missed' => 0,
|
'missed' => 0,
|
||||||
'empty_slots' => (int) ($todaySchedule['empty_slots'] ?? 0),
|
// 空号口径:剩余可预约号源(未过时刻且未被有效挂号占用),过号单独给出
|
||||||
|
'empty_slots' => (int) ($todaySchedule['remaining_slots'] ?? 0),
|
||||||
|
'passed_slots' => (int) ($todaySchedule['passed_slots'] ?? 0),
|
||||||
|
'remaining_slots' => (int) ($todaySchedule['remaining_slots'] ?? 0),
|
||||||
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
|
'doctor_count' => (int) ($todaySchedule['doctor_count'] ?? 0),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -365,15 +370,37 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$nowHm = date('H:i');
|
||||||
|
|
||||||
foreach ($days as $date => &$day) {
|
foreach ($days as $date => &$day) {
|
||||||
$doctorDetails = [];
|
$doctorDetails = [];
|
||||||
$total = 0;
|
$total = 0;
|
||||||
$booked = 0;
|
$booked = 0;
|
||||||
|
$passed = 0;
|
||||||
|
$remaining = 0;
|
||||||
foreach ($doctorSlotSets[$date] ?? [] as $doctorId => $slotSet) {
|
foreach ($doctorSlotSets[$date] ?? [] as $doctorId => $slotSet) {
|
||||||
$doctorTotal = count($slotSet);
|
$doctorTotal = count($slotSet);
|
||||||
$doctorBooked = count($doctorBookedSets[$date][$doctorId] ?? []);
|
$bookedSet = $doctorBookedSets[$date][$doctorId] ?? [];
|
||||||
|
$doctorBooked = count($bookedSet);
|
||||||
|
$doctorPassed = 0;
|
||||||
|
$doctorRemaining = 0;
|
||||||
|
foreach ($slotSet as $time => $_) {
|
||||||
|
if (isset($bookedSet[$time])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 与挂号选号一致:今日已过(含当前分钟)的未约号源计为过号,其余为剩余可约
|
||||||
|
$isPassed = $date < $today || ($date === $today && strcmp((string) $time, $nowHm) <= 0);
|
||||||
|
if ($isPassed) {
|
||||||
|
$doctorPassed++;
|
||||||
|
} else {
|
||||||
|
$doctorRemaining++;
|
||||||
|
}
|
||||||
|
}
|
||||||
$total += $doctorTotal;
|
$total += $doctorTotal;
|
||||||
$booked += $doctorBooked;
|
$booked += $doctorBooked;
|
||||||
|
$passed += $doctorPassed;
|
||||||
|
$remaining += $doctorRemaining;
|
||||||
$scheduleWindows = array_values(array_keys($doctorWindowSets[$date][$doctorId] ?? []));
|
$scheduleWindows = array_values(array_keys($doctorWindowSets[$date][$doctorId] ?? []));
|
||||||
sort($scheduleWindows, SORT_STRING);
|
sort($scheduleWindows, SORT_STRING);
|
||||||
$doctorDetails[] = [
|
$doctorDetails[] = [
|
||||||
@@ -382,17 +409,23 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'schedule_windows' => $scheduleWindows,
|
'schedule_windows' => $scheduleWindows,
|
||||||
'total_slots' => $doctorTotal,
|
'total_slots' => $doctorTotal,
|
||||||
'booked_slots' => $doctorBooked,
|
'booked_slots' => $doctorBooked,
|
||||||
'empty_slots' => max(0, $doctorTotal - $doctorBooked),
|
'passed_slots' => $doctorPassed,
|
||||||
|
'remaining_slots' => $doctorRemaining,
|
||||||
|
// 空号对外展示剩余可约;过号/未挂号细分见 passed/remaining
|
||||||
|
'empty_slots' => $doctorRemaining,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
usort($doctorDetails, static function (array $left, array $right): int {
|
usort($doctorDetails, static function (array $left, array $right): int {
|
||||||
return $right['booked_slots'] <=> $left['booked_slots']
|
return $right['remaining_slots'] <=> $left['remaining_slots']
|
||||||
|
?: $right['booked_slots'] <=> $left['booked_slots']
|
||||||
?: $right['total_slots'] <=> $left['total_slots']
|
?: $right['total_slots'] <=> $left['total_slots']
|
||||||
?: strcmp((string) $left['doctor_name'], (string) $right['doctor_name']);
|
?: strcmp((string) $left['doctor_name'], (string) $right['doctor_name']);
|
||||||
});
|
});
|
||||||
$day['total_slots'] = $total;
|
$day['total_slots'] = $total;
|
||||||
$day['booked_slots'] = $booked;
|
$day['booked_slots'] = $booked;
|
||||||
$day['empty_slots'] = max(0, $total - $booked);
|
$day['passed_slots'] = $passed;
|
||||||
|
$day['remaining_slots'] = $remaining;
|
||||||
|
$day['empty_slots'] = $remaining;
|
||||||
$day['doctor_count'] = count($doctorDetails);
|
$day['doctor_count'] = count($doctorDetails);
|
||||||
$day['doctors'] = $doctorDetails;
|
$day['doctors'] = $doctorDetails;
|
||||||
}
|
}
|
||||||
@@ -589,6 +622,8 @@ class MyPatientProgressLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
'weekday' => $weekdayLabels[(int) date('w', $timestamp)],
|
'weekday' => $weekdayLabels[(int) date('w', $timestamp)],
|
||||||
'total_slots' => 0,
|
'total_slots' => 0,
|
||||||
'booked_slots' => 0,
|
'booked_slots' => 0,
|
||||||
|
'passed_slots' => 0,
|
||||||
|
'remaining_slots' => 0,
|
||||||
'empty_slots' => 0,
|
'empty_slots' => 0,
|
||||||
'doctor_count' => 0,
|
'doctor_count' => 0,
|
||||||
'doctors' => [],
|
'doctors' => [],
|
||||||
|
|||||||
@@ -957,8 +957,13 @@ class ConversionLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate add-contact events by WeCom user once, then project that raw
|
* 区间有效加粉:按企微员工聚合后,再投影到部门/成员/虚拟桶。
|
||||||
* snapshot to departments, members and virtual buckets in PHP.
|
*
|
||||||
|
* 口径(对齐企微客户列表,而非原始回调条数):
|
||||||
|
* - 统计区间内 add_external_contact,按 (user_id, external_userid) 去重;
|
||||||
|
* - 加粉之后、统计结束前须有 msg_audit_approved(排除未完成链路的幽灵事件);
|
||||||
|
* - 加粉之后、统计结束前若出现 del_external_contact(客户从企微侧删除)则不计。
|
||||||
|
* 不扣减 del_follow_user:企微客户列表中改名带「删」的客户往往仍在列表中。
|
||||||
*
|
*
|
||||||
* @param array<string, mixed>|null $mediaChannel
|
* @param array<string, mixed>|null $mediaChannel
|
||||||
* @param int[]|null $adminIds null means all active/unbound WeCom users
|
* @param int[]|null $adminIds null means all active/unbound WeCom users
|
||||||
@@ -981,7 +986,7 @@ class ConversionLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseKey = self::requestRowsCacheKey('fans', [
|
$baseKey = self::requestRowsCacheKey('fans-effective-v2', [
|
||||||
$startTimestamp,
|
$startTimestamp,
|
||||||
$endTimestamp,
|
$endTimestamp,
|
||||||
self::mediaChannelCacheKey($mediaChannel),
|
self::mediaChannelCacheKey($mediaChannel),
|
||||||
@@ -1019,11 +1024,30 @@ class ConversionLogic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
|
||||||
$query = Db::name('qywx_external_contact_event')
|
$query = Db::name('qywx_external_contact_event')
|
||||||
->alias('e')
|
->alias('e')
|
||||||
->where('e.change_type', 'add_external_contact')
|
->where('e.change_type', 'add_external_contact')
|
||||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||||
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
|
->whereRaw(
|
||||||
|
'EXISTS (SELECT 1 FROM `' . $eventTable . '` audit_e'
|
||||||
|
. ' WHERE audit_e.user_id = e.user_id'
|
||||||
|
. ' AND audit_e.external_userid = e.external_userid'
|
||||||
|
. ' AND audit_e.change_type = ?'
|
||||||
|
. ' AND audit_e.event_time >= e.event_time'
|
||||||
|
. ' AND audit_e.event_time <= ?)',
|
||||||
|
['msg_audit_approved', $endTimestamp]
|
||||||
|
)
|
||||||
|
->whereRaw(
|
||||||
|
'NOT EXISTS (SELECT 1 FROM `' . $eventTable . '` del_e'
|
||||||
|
. ' WHERE del_e.user_id = e.user_id'
|
||||||
|
. ' AND del_e.external_userid = e.external_userid'
|
||||||
|
. ' AND del_e.change_type = ?'
|
||||||
|
. ' AND del_e.event_time >= e.event_time'
|
||||||
|
. ' AND del_e.event_time <= ?)',
|
||||||
|
['del_external_contact', $endTimestamp]
|
||||||
|
)
|
||||||
|
->fieldRaw('e.user_id, COUNT(DISTINCT e.external_userid) AS add_fans_count')
|
||||||
->group('e.user_id');
|
->group('e.user_id');
|
||||||
if ($workWechatUserIds !== null) {
|
if ($workWechatUserIds !== null) {
|
||||||
$query->whereIn('e.user_id', $workWechatUserIds);
|
$query->whereIn('e.user_id', $workWechatUserIds);
|
||||||
@@ -1604,6 +1628,8 @@ class ConversionLogic
|
|||||||
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
|
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
|
||||||
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
|
// 面诊接诊率:面诊 / 挂号
|
||||||
|
$entity['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
|
||||||
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||||
$entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
|
$entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
|
||||||
@@ -1837,6 +1863,7 @@ class ConversionLogic
|
|||||||
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
|
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
|
||||||
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
|
$node['interview_paid_rate'] = self::percent($interviewCount, $paidAppointmentCount);
|
||||||
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||||
$node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
|
$node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
|
||||||
@@ -2258,6 +2285,7 @@ class ConversionLogic
|
|||||||
'interview_rate' => 0.0,
|
'interview_rate' => 0.0,
|
||||||
'receive_rate' => 0.0,
|
'receive_rate' => 0.0,
|
||||||
'interview_receive_rate' => 0.0,
|
'interview_receive_rate' => 0.0,
|
||||||
|
'interview_paid_rate' => 0.0,
|
||||||
'open_receive_rate' => 0.0,
|
'open_receive_rate' => 0.0,
|
||||||
'avg_unit_price' => 0.0,
|
'avg_unit_price' => 0.0,
|
||||||
'cash_cost' => 0.0,
|
'cash_cost' => 0.0,
|
||||||
@@ -2324,6 +2352,7 @@ class ConversionLogic
|
|||||||
'member'
|
'member'
|
||||||
),
|
),
|
||||||
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
||||||
|
'interview_paid_rate' => self::percent($interviewCount, $paidAppointmentCount),
|
||||||
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
||||||
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
||||||
'cash_cost' => self::safeDivideMoney($effectiveAccountCost, $addFansCount),
|
'cash_cost' => self::safeDivideMoney($effectiveAccountCost, $addFansCount),
|
||||||
@@ -2500,6 +2529,7 @@ class ConversionLogic
|
|||||||
$dimension
|
$dimension
|
||||||
);
|
);
|
||||||
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
||||||
|
$summary['interview_paid_rate'] = self::percent($summary['interview_count'], $summary['paid_appointment_count']);
|
||||||
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
|
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
|
||||||
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
|
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
|
||||||
$summary['cash_cost'] = self::safeDivideMoney($summary['account_cost'], $summary['add_fans_count']);
|
$summary['cash_cost'] = self::safeDivideMoney($summary['account_cost'], $summary['add_fans_count']);
|
||||||
|
|||||||
Reference in New Issue
Block a user