Compare commits
5
Commits
5711ad946f
...
app-8-12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48a66b611 | ||
|
|
28cd110dae | ||
|
|
6d9fe1bbf8 | ||
|
|
bc9ad1d3cd | ||
|
|
09d3fcbf82 |
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<div ref="paperRef" class="rx-paper">
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
||||
</span>
|
||||
<span class="rx-notice-meta">
|
||||
<span>日期:{{ dateText }}</span>
|
||||
<span>编号:{{ serialText }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rx-info">
|
||||
<div class="rx-info-row">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">姓名</span>
|
||||
<span class="rx-val">{{ data?.patient_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">性别</span>
|
||||
<span class="rx-val">{{ genderLabel }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">年龄</span>
|
||||
<span class="rx-val">{{ ageText }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">电话</span>
|
||||
<span class="rx-val">{{ data?.phone || data?.recipient_phone || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">收件信息</span>
|
||||
<span class="rx-val">{{ recipientText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">临床诊断</span>
|
||||
<span class="rx-val">{{ data?.clinical_diagnosis || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-rp">
|
||||
<div class="rx-watermark">药房联</div>
|
||||
<div class="rx-rp-head">
|
||||
<div class="rx-rp-label">Rp.</div>
|
||||
<div class="rx-rp-cols-head">
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
</div>
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-herbs">
|
||||
<template v-if="mainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in mainHerbs"
|
||||
:key="'main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="auxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in auxHerbs"
|
||||
:key="'aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-text">
|
||||
<p>主方服法:{{ usageText }}</p>
|
||||
<p v-if="auxUsageText">辅方服法:{{ auxUsageText }}</p>
|
||||
<p v-if="adviceText">医嘱:{{ adviceText }}</p>
|
||||
<p v-if="dietaryText">忌口:{{ dietaryText }}</p>
|
||||
<p v-if="remarkText">备注:{{ remarkText }}</p>
|
||||
<p v-if="pharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ pharmacyRemarkText }}
|
||||
</p>
|
||||
<p v-if="outPelletText" class="rx-text-warn">
|
||||
出丸:{{ outPelletText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rx-bottom">
|
||||
<div class="rx-bot-row rx-bot-row-1">
|
||||
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
|
||||
<div class="rx-bot-label">医师</div>
|
||||
<div class="rx-bot-doctor-body">
|
||||
<img
|
||||
v-if="data?.doctor_signature"
|
||||
:src="data.doctor_signature"
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<div v-if="!data?.doctor_signature" class="rx-bot-doctor-name">
|
||||
{{ data?.doctor_name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">类型:</span>
|
||||
<span class="rx-bot-meta-val">{{ typeText }}</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">天数:</span>
|
||||
<span class="rx-bot-meta-val">{{ data?.dose_count || '—' }}剂</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">单剂量:</span>
|
||||
<span class="rx-bot-meta-val">{{ perDoseAmount }}克</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
getAuxHerbs,
|
||||
getMainHerbs,
|
||||
rxAdviceText,
|
||||
rxAuxUsageText,
|
||||
rxDateText,
|
||||
rxHerbTotal,
|
||||
rxOutPelletText,
|
||||
rxPerDoseAmount,
|
||||
rxPharmacyRemarkText,
|
||||
rxRecipientText,
|
||||
rxRemarkText,
|
||||
rxSerialText,
|
||||
rxTypeText,
|
||||
rxUsageText,
|
||||
slipAgeText,
|
||||
slipDietaryText,
|
||||
slipGenderLabel
|
||||
} from './utils'
|
||||
|
||||
defineOptions({ name: 'PrescriptionSlip' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 处方详情 / 列表行(字段与 tcm.prescription/detail 对齐) */
|
||||
data: Record<string, any> | null | undefined
|
||||
}>()
|
||||
|
||||
const paperRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const dateText = computed(() => rxDateText(props.data))
|
||||
const serialText = computed(() => rxSerialText(props.data))
|
||||
const recipientText = computed(() => rxRecipientText(props.data))
|
||||
const genderLabel = computed(() => slipGenderLabel(props.data?.gender))
|
||||
const ageText = computed(() => slipAgeText(props.data?.age))
|
||||
const mainHerbs = computed(() => getMainHerbs(props.data))
|
||||
const auxHerbs = computed(() => getAuxHerbs(props.data))
|
||||
const usageText = computed(() => rxUsageText(props.data))
|
||||
const auxUsageText = computed(() => rxAuxUsageText(props.data))
|
||||
const adviceText = computed(() => rxAdviceText(props.data))
|
||||
const dietaryText = computed(() => slipDietaryText(props.data))
|
||||
const remarkText = computed(() => rxRemarkText(props.data))
|
||||
const pharmacyRemarkText = computed(() => rxPharmacyRemarkText(props.data))
|
||||
const outPelletText = computed(() => rxOutPelletText(props.data))
|
||||
const typeText = computed(() => rxTypeText(props.data))
|
||||
const perDoseAmount = computed(() => rxPerDoseAmount(props.data))
|
||||
|
||||
function herbTotal(dosage: number | string) {
|
||||
return rxHerbTotal(props.data, dosage)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
/** 供打印 / html2canvas 使用的纸张根节点 */
|
||||
paperEl: paperRef,
|
||||
/** 兼容旧用法:直接拿到 DOM */
|
||||
$el: paperRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================================
|
||||
* 处方笺(A4 药房联)样式
|
||||
* 严格按 A4:210mm × 297mm,padding 8mm 10mm
|
||||
* 唯一视觉基准:consumer/prescription/index.vue
|
||||
* ============================================================ */
|
||||
.rx-paper {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
padding: 8mm 10mm;
|
||||
color: #1f1f1f;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-family: 'Microsoft YaHei', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
sans-serif;
|
||||
box-shadow: 0 0 0 1px #d6d6d6, 0 6px 24px rgba(0, 0, 0, 0.06);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.rx-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 6px 10px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
color: #1f1f1f;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.rx-notice-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rx-notice-meta {
|
||||
display: inline-flex;
|
||||
gap: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-info {
|
||||
border: 1px solid #c8c8c8;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rx-info-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
.rx-info-row-full {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rx-info-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
border-right: 1px solid #c8c8c8;
|
||||
min-height: 32px;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.rx-key {
|
||||
color: #1f1f1f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rx-val {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-rp {
|
||||
position: relative;
|
||||
border-left: 1px solid #c8c8c8;
|
||||
border-right: 1px solid #c8c8c8;
|
||||
padding: 8px 10px 16px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rx-watermark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 88px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.06);
|
||||
transform: rotate(-22deg);
|
||||
letter-spacing: 0.18em;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.rx-rp-head {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr;
|
||||
align-items: end;
|
||||
column-gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #d4d4d4;
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rx-rp-label {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f1f1f;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rx-rp-cols-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 24px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rx-rp-col-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
column-gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #1f1f1f;
|
||||
min-width: 0;
|
||||
|
||||
& > span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-herbs {
|
||||
padding-left: 44px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 24px;
|
||||
row-gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rx-herb-section-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rx-herb-section-label--aux {
|
||||
color: #e6a23c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rx-herb-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
column-gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.85;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rx-herb-name {
|
||||
color: #1f1f1f;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rx-herb-total {
|
||||
color: #1f1f1f;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rx-text {
|
||||
border: 1px solid #c8c8c8;
|
||||
border-top: none;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.85;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rx-text-warn {
|
||||
color: #d72424;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-bottom {
|
||||
border: 1px solid #c8c8c8;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.rx-bot-row {
|
||||
display: grid;
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-bot-row-1 {
|
||||
grid-template-columns: 1.4fr 0.6fr 0.6fr 0.6fr 0.6fr 1.7fr 0.95fr 1.25fr;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.rx-bot-cell {
|
||||
border-right: 1px solid #c8c8c8;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #1f1f1f;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 32px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-bot-stack {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.rx-bot-label {
|
||||
font-size: 13px;
|
||||
color: #1f1f1f;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rx-bot-doctor {
|
||||
.rx-bot-doctor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.rx-bot-doctor-name {
|
||||
font-size: 13px;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.rx-bot-sign-img {
|
||||
max-height: 40px;
|
||||
max-width: 110px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-bot-meta {
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.rx-bot-meta-key {
|
||||
color: #1f1f1f;
|
||||
margin-right: 6px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rx-bot-meta-val {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 全局打印样式:body * 选择器不能放进 scoped -->
|
||||
<style>
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.rx-paper,
|
||||
.rx-paper * {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.rx-paper {
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 210mm !important;
|
||||
height: 297mm !important;
|
||||
margin: 0 !important;
|
||||
padding: 8mm 10mm !important;
|
||||
box-shadow: none !important;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* 处方笺(A4 药房联)字段计算
|
||||
* 唯一视觉基准:consumer/prescription/index.vue 查看态
|
||||
*/
|
||||
|
||||
export type FormulaType = '主方' | '辅方'
|
||||
|
||||
export type SlipHerb = {
|
||||
medicine_id?: number
|
||||
name: string
|
||||
dosage: number
|
||||
formula_type?: FormulaType | string
|
||||
}
|
||||
|
||||
export type AuxUsageForm = {
|
||||
dosage_amount: number
|
||||
dosage_bag_count: number
|
||||
need_decoction: boolean
|
||||
bags_per_dose: number
|
||||
times_per_day: number
|
||||
usage_days: number
|
||||
prescription_name?: string
|
||||
}
|
||||
|
||||
export function formatNum(n: number): string {
|
||||
if (!isFinite(n)) return '0'
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
export function slipGenderLabel(g: unknown) {
|
||||
if (g === 1 || g === '1') return '男'
|
||||
if (g === 0 || g === '0') return '女'
|
||||
return '—'
|
||||
}
|
||||
|
||||
export function slipAgeText(age: unknown) {
|
||||
if (age === null || age === undefined || age === '') return '—'
|
||||
return `${age}岁`
|
||||
}
|
||||
|
||||
export function normalizeFormulaType(v: unknown): FormulaType {
|
||||
return v === '辅方' ? '辅方' : '主方'
|
||||
}
|
||||
|
||||
function padN(n: number) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm {
|
||||
if (prescriptionType === '饮片') {
|
||||
return {
|
||||
dosage_amount: 50,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
if (prescriptionType === '浓缩水丸') {
|
||||
return {
|
||||
dosage_amount: 5,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
return {
|
||||
dosage_amount: 1,
|
||||
dosage_bag_count: 1,
|
||||
need_decoction: false,
|
||||
bags_per_dose: 1,
|
||||
times_per_day: 3,
|
||||
usage_days: 7
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAuxUsageForm(raw: unknown, prescriptionType: string): AuxUsageForm {
|
||||
const base = defaultAuxUsage(prescriptionType)
|
||||
if (!raw || typeof raw !== 'object') return { ...base }
|
||||
const o = raw as Record<string, unknown>
|
||||
return {
|
||||
dosage_amount:
|
||||
o.dosage_amount !== null && o.dosage_amount !== undefined && o.dosage_amount !== ''
|
||||
? Number(o.dosage_amount)
|
||||
: base.dosage_amount,
|
||||
dosage_bag_count: o.dosage_bag_count != null ? Number(o.dosage_bag_count) || 1 : base.dosage_bag_count,
|
||||
need_decoction: o.need_decoction === 1 || o.need_decoction === true,
|
||||
bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose,
|
||||
times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day,
|
||||
usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days,
|
||||
prescription_name: String(o.prescription_name ?? '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUsageSegmentText(
|
||||
usage: {
|
||||
prescription_type?: string
|
||||
dosage_amount?: number | null
|
||||
dosage_unit?: string
|
||||
dosage_bag_count?: number
|
||||
times_per_day?: number
|
||||
usage_way?: string
|
||||
usage_time?: string
|
||||
},
|
||||
fallbackWay?: string,
|
||||
fallbackTime?: string
|
||||
): string {
|
||||
const pt = usage.prescription_type || '浓缩水丸'
|
||||
const times = Number(usage.times_per_day) > 0 ? Number(usage.times_per_day) : 3
|
||||
const amount = usage.dosage_amount != null ? Number(usage.dosage_amount) : 10
|
||||
const unit = usage.dosage_unit || (pt === '饮片' ? 'ml' : 'g')
|
||||
const usageWay = usage.usage_way || fallbackWay || '温水送服'
|
||||
const usageTime = usage.usage_time || fallbackTime || ''
|
||||
const seg: string[] = []
|
||||
seg.push(`每天${times}次`)
|
||||
if (pt === '浓缩水丸') {
|
||||
const bags = Number(usage.dosage_bag_count) > 0 ? Number(usage.dosage_bag_count) : 1
|
||||
seg.push(`一次${bags}袋`)
|
||||
seg.push(`每袋${amount}${unit}`)
|
||||
} else {
|
||||
seg.push(`一次${amount}${unit}`)
|
||||
}
|
||||
seg.push(usageWay)
|
||||
if (usageTime) seg.push(usageTime)
|
||||
return seg.join(', ')
|
||||
}
|
||||
|
||||
export function getSlipHerbs(data: Record<string, any> | null | undefined): SlipHerb[] {
|
||||
const h = data?.herbs
|
||||
return Array.isArray(h) ? (h as SlipHerb[]) : []
|
||||
}
|
||||
|
||||
export function getMainHerbs(data: Record<string, any> | null | undefined): SlipHerb[] {
|
||||
return getSlipHerbs(data).filter((h) => normalizeFormulaType(h.formula_type) === '主方')
|
||||
}
|
||||
|
||||
export function getAuxHerbs(data: Record<string, any> | null | undefined): SlipHerb[] {
|
||||
return getSlipHerbs(data).filter((h) => normalizeFormulaType(h.formula_type) === '辅方')
|
||||
}
|
||||
|
||||
export function getDoseCount(data: Record<string, any> | null | undefined): number {
|
||||
const n = Number(data?.dose_count)
|
||||
return Number.isFinite(n) && n > 0 ? n : 1
|
||||
}
|
||||
|
||||
export function rxDateText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return '—'
|
||||
const ts = data.create_time ?? data.update_time ?? null
|
||||
if (ts) {
|
||||
const t = String(ts).length === 10 ? Number(ts) * 1000 : Number(ts)
|
||||
const d = new Date(t)
|
||||
if (!isNaN(d.getTime())) {
|
||||
return `${d.getFullYear()}-${padN(d.getMonth() + 1)}-${padN(d.getDate())} ${padN(d.getHours())}:${padN(d.getMinutes())}`
|
||||
}
|
||||
}
|
||||
return data.prescription_date || '—'
|
||||
}
|
||||
|
||||
export function rxSerialText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return '—'
|
||||
return (
|
||||
data.order_no ||
|
||||
data.serial_no ||
|
||||
data.serial_number ||
|
||||
data.no ||
|
||||
data.prescription_no ||
|
||||
data.sn ||
|
||||
data.visit_no ||
|
||||
(data.id ? `G${data.id}` : '—')
|
||||
)
|
||||
}
|
||||
|
||||
export function rxRecipientText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return '—'
|
||||
if (data.recipient_text) return data.recipient_text
|
||||
const parts: string[] = []
|
||||
if (data.recipient_name || data.patient_name) parts.push(data.recipient_name || data.patient_name)
|
||||
if (data.recipient_phone || data.phone) parts.push(data.recipient_phone || data.phone)
|
||||
let region = data.region
|
||||
if (Array.isArray(region)) region = region.filter(Boolean).join('')
|
||||
if (!region) {
|
||||
region = [data.shipping_province, data.shipping_city, data.shipping_district]
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
}
|
||||
if (region) parts.push(String(region))
|
||||
if (data.shipping_address) parts.push(String(data.shipping_address))
|
||||
else if (data.address) parts.push(String(data.address))
|
||||
return parts.length ? parts.join(',') : '—'
|
||||
}
|
||||
|
||||
export function rxPerDoseAmount(data: Record<string, any> | null | undefined): string {
|
||||
const list = getSlipHerbs(data)
|
||||
const sum = list.reduce((acc, h) => acc + (Number(h?.dosage) || 0), 0)
|
||||
return formatNum(sum)
|
||||
}
|
||||
|
||||
export function rxHerbTotal(data: Record<string, any> | null | undefined, dosage: number | string): string {
|
||||
const d = Number(dosage) || 0
|
||||
return formatNum(d * getDoseCount(data))
|
||||
}
|
||||
|
||||
export function rxTypeText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return '—'
|
||||
const t = data.prescription_type || '浓缩水丸'
|
||||
return /丸|散|膏|片/.test(t) ? `浓缩丸-${t}` : t
|
||||
}
|
||||
|
||||
export function rxUsageText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return '—'
|
||||
if (data.usage_text) return data.usage_text
|
||||
return buildUsageSegmentText(data)
|
||||
}
|
||||
|
||||
export function rxAuxUsageText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data || !getAuxHerbs(data).length) return ''
|
||||
const aux = normalizeAuxUsageForm(data.aux_usage, data.prescription_type || '浓缩水丸')
|
||||
return buildUsageSegmentText(
|
||||
{
|
||||
prescription_type: data.prescription_type,
|
||||
dosage_amount: aux.dosage_amount,
|
||||
dosage_unit: data.dosage_unit,
|
||||
dosage_bag_count: aux.dosage_bag_count,
|
||||
times_per_day: aux.times_per_day,
|
||||
usage_way: data.usage_way,
|
||||
usage_time: data.usage_time
|
||||
},
|
||||
data.usage_way,
|
||||
data.usage_time
|
||||
)
|
||||
}
|
||||
|
||||
export function rxAdviceText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return ''
|
||||
return data.medical_advice || data.doctor_advice || ''
|
||||
}
|
||||
|
||||
export function slipDietaryText(data: Record<string, any> | null | undefined): string {
|
||||
const d = data?.dietary_taboo
|
||||
if (Array.isArray(d)) return d.filter(Boolean).join('、')
|
||||
if (typeof d === 'string' && d.trim()) return d.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
export function rxRemarkText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return ''
|
||||
const parts: string[] = []
|
||||
parts.push(`共${getSlipHerbs(data).length}味药`)
|
||||
if (data.usage_notes) parts.push(String(data.usage_notes))
|
||||
if (data.remark) parts.push(String(data.remark))
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function rxPharmacyRemarkText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return ''
|
||||
return data.pharmacy_remark || data.pharmacy_note || ''
|
||||
}
|
||||
|
||||
function computeOutPellet(data: Record<string, any>): string {
|
||||
if ((data.prescription_type || '浓缩水丸') === '浓缩水丸') {
|
||||
const days = Number(data.medication_days ?? data.usage_days ?? data.dose_count)
|
||||
const times = Number(data.times_per_day)
|
||||
const doseG = Number(data.dosage_amount)
|
||||
const bags = Number(data.dosage_bag_count) > 0 ? Number(data.dosage_bag_count) : 1
|
||||
if (
|
||||
Number.isFinite(days) &&
|
||||
Number.isFinite(times) &&
|
||||
Number.isFinite(doseG) &&
|
||||
days > 0 &&
|
||||
times > 0 &&
|
||||
doseG > 0
|
||||
) {
|
||||
return formatNum(days * times * doseG * bags)
|
||||
}
|
||||
}
|
||||
const explicit = data.out_pellet || data.out_pellet_text || data.total_weight
|
||||
if (explicit && Number(explicit) > 0) return formatNum(Number(explicit))
|
||||
const list = getSlipHerbs(data)
|
||||
if (!list.length) return ''
|
||||
const perDose = list.reduce((acc, h) => acc + (Number(h?.dosage) || 0), 0)
|
||||
return formatNum(perDose * getDoseCount(data))
|
||||
}
|
||||
|
||||
export function rxOutPelletText(data: Record<string, any> | null | undefined): string {
|
||||
if (!data) return ''
|
||||
const out = computeOutPellet(data)
|
||||
return out ? `${out}克` : ''
|
||||
}
|
||||
@@ -534,13 +534,6 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="rx-wrap">
|
||||
<el-tabs
|
||||
v-model="prescriptionTabType"
|
||||
class="rx-slip-tabs"
|
||||
>
|
||||
<el-tab-pane label="药房联" name="internal" />
|
||||
<el-tab-pane label="处方联" name="user" />
|
||||
</el-tabs>
|
||||
<div class="rx-toolbar">
|
||||
<el-tag v-if="savedPrescription.void_status === 1" type="danger" size="small">已作废</el-tag>
|
||||
<el-tag v-else-if="approvedPreviewOnly" type="success" size="small">已通过</el-tag>
|
||||
@@ -565,190 +558,11 @@
|
||||
</div>
|
||||
|
||||
<div class="prescription-preview-content">
|
||||
<div v-if="savedPrescription" ref="prescriptionPrintRef" class="rx-paper">
|
||||
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
{{
|
||||
prescriptionTabType === 'user'
|
||||
? '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
|
||||
: '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
|
||||
}}
|
||||
</span>
|
||||
<span class="rx-notice-meta">
|
||||
<span>日期:{{ rxDateText }}</span>
|
||||
<span>编号:{{ rxSerialText }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rx-info">
|
||||
<div class="rx-info-row">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">姓名</span>
|
||||
<span class="rx-val">{{ savedPrescription.patient_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">性别</span>
|
||||
<span class="rx-val">{{ slipGenderLabel(savedPrescription.gender) }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">年龄</span>
|
||||
<span class="rx-val">{{ slipAgeText(savedPrescription.age) }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">电话</span>
|
||||
<span class="rx-val">{{ savedPrescription.phone || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">门诊号</span>
|
||||
<span class="rx-val">{{ savedPrescription.visit_no || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="savedPrescription.pulse || savedPrescription.tongue" class="rx-info-row rx-info-row-full">
|
||||
<div v-if="savedPrescription.pulse" class="rx-info-cell">
|
||||
<span class="rx-key">面象</span>
|
||||
<span class="rx-val">{{ savedPrescription.pulse }}</span>
|
||||
</div>
|
||||
<div v-if="savedPrescription.tongue" class="rx-info-cell">
|
||||
<span class="rx-key">舌象</span>
|
||||
<span class="rx-val">{{ savedPrescription.tongue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">临床诊断</span>
|
||||
<span class="rx-val">{{ savedPrescription.clinical_diagnosis || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-rp">
|
||||
<div class="rx-watermark">{{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }}</div>
|
||||
<div class="rx-rp-head">
|
||||
<div class="rx-rp-label">Rp.</div>
|
||||
<div class="rx-rp-cols-head">
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
|
||||
</div>
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-herbs">
|
||||
<template v-if="prescriptionTabType === 'internal'">
|
||||
<template v-if="savedMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in savedMainHerbs"
|
||||
:key="'main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="savedAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in savedAuxHerbs"
|
||||
:key="'aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="savedMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in savedMainHerbs"
|
||||
:key="'user-main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }}</span>
|
||||
<span class="rx-herb-total">{{ h.dosage }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="savedAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in savedAuxHerbs"
|
||||
:key="'user-aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }}</span>
|
||||
<span class="rx-herb-total">{{ h.dosage }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-text">
|
||||
<template v-if="prescriptionTabType === 'internal'">
|
||||
<p v-if="savedAuxUsageText">主方服法:{{ rxUsageText }}</p>
|
||||
<p v-else>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="savedAuxUsageText">辅方服法:{{ savedAuxUsageText }}</p>
|
||||
</template>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="savedPrescription.dietary_taboo && savedPrescription.dietary_taboo.length">
|
||||
忌口:{{ Array.isArray(savedPrescription.dietary_taboo) ? savedPrescription.dietary_taboo.join('、') : savedPrescription.dietary_taboo }}
|
||||
</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">药房备注:{{ rxPharmacyRemarkText }}</p>
|
||||
<p
|
||||
v-if="prescriptionTabType === 'internal' && rxOutPelletText && savedPrescription.prescription_type !== '饮片'"
|
||||
class="rx-text-warn"
|
||||
>
|
||||
出丸:{{ rxOutPelletText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rx-bottom">
|
||||
<div
|
||||
class="rx-bot-row rx-bot-row-1"
|
||||
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
|
||||
>
|
||||
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
|
||||
<div class="rx-bot-label">医师</div>
|
||||
<div class="rx-bot-doctor-body">
|
||||
<img
|
||||
v-if="savedPrescription.doctor_signature"
|
||||
:src="savedPrescription.doctor_signature"
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<div v-if="!savedPrescription.doctor_signature" class="rx-bot-doctor-name">
|
||||
{{ savedPrescription.doctor_name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">类型:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">天数:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxSlipMedicationDaysText }}</span>
|
||||
</div>
|
||||
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">剂量:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="prescriptionTabType === 'user'" class="rx-bot-row rx-hospital-row">
|
||||
<div class="rx-hospital-line">成都双流甄养堂互联网医院有限公司 联系方式:4001667339</div>
|
||||
<div class="rx-hospital-line">地址:四川省成都市双流区黄甲街道黄龙大道二段280号</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PrescriptionSlip
|
||||
v-if="savedPrescription"
|
||||
ref="rxSlipRef"
|
||||
:data="savedPrescription"
|
||||
/>
|
||||
|
||||
<!-- 详细病历 - 单独A3纸张 -->
|
||||
<div v-if="savedPrescription.case_record && !hideCaseRecordPreview" ref="caseRecordPrintRef" class="case-record-a3">
|
||||
@@ -1045,6 +859,7 @@ import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-displ
|
||||
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
|
||||
import { medicineLists } from '@/api/medicine'
|
||||
import { getDictData } from '@/api/app'
|
||||
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
@@ -1219,7 +1034,7 @@ const visible = ref(false)
|
||||
const formRef = ref()
|
||||
const saving = ref(false)
|
||||
const voiding = ref(false)
|
||||
const prescriptionPrintRef = ref<HTMLElement>()
|
||||
const rxSlipRef = ref<InstanceType<typeof PrescriptionSlip> | null>(null)
|
||||
const caseRecordPrintRef = ref<HTMLElement>()
|
||||
const signatureCanvasRef = ref<HTMLCanvasElement>()
|
||||
/** 当前一笔的 pointerId(配合 setPointerCapture,移出画布仍跟手) */
|
||||
@@ -1229,7 +1044,7 @@ const SIGNATURE_CSS_W = 560
|
||||
const SIGNATURE_CSS_H = 168
|
||||
const savedPrescription = ref<any>(null)
|
||||
/** 预览联次:药房联 / 处方联(与消费者处方查看一致) */
|
||||
const prescriptionTabType = ref<'internal' | 'user'>('internal')
|
||||
const prescriptionTabType = ref<'internal' | 'user'>('internal') // 保留兼容 openPreview options.slipType
|
||||
/** 诊单列表「查看」仅展示处方联,不展示详细病历 A3 */
|
||||
const hideCaseRecordPreview = ref(false)
|
||||
|
||||
@@ -2397,7 +2212,7 @@ const handleNewPrescription = async () => {
|
||||
}
|
||||
|
||||
const captureElement = async (): Promise<HTMLCanvasElement> => {
|
||||
const el = prescriptionPrintRef.value
|
||||
const el = (rxSlipRef.value as { paperEl?: HTMLElement | null } | null)?.paperEl ?? null
|
||||
if (!el) throw new Error('未找到处方内容')
|
||||
return html2canvas(el, {
|
||||
scale: 2,
|
||||
|
||||
@@ -342,144 +342,8 @@
|
||||
<div v-if="slipView.audit_remark" class="mt-1">驳回意见:{{ slipView.audit_remark }}</div>
|
||||
</div>
|
||||
|
||||
<!-- A4 处方纸 -->
|
||||
<div v-if="slipView" ref="rxPaperRef" class="rx-paper">
|
||||
<!-- 顶部告知栏 -->
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
||||
</span>
|
||||
<span class="rx-notice-meta">
|
||||
<span>日期:{{ rxDateText }}</span>
|
||||
<span>编号:{{ rxSerialText }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 患者信息表 -->
|
||||
<div class="rx-info">
|
||||
<div class="rx-info-row">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">姓名</span>
|
||||
<span class="rx-val">{{ slipView.patient_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">性别</span>
|
||||
<span class="rx-val">{{ slipGenderLabel(slipView.gender) }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">年龄</span>
|
||||
<span class="rx-val">{{ slipAgeText(slipView.age) }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">电话</span>
|
||||
<span class="rx-val">{{ slipView.phone || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">收件信息</span>
|
||||
<span class="rx-val">{{ rxRecipientText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">临床诊断</span>
|
||||
<span class="rx-val">{{ slipView.clinical_diagnosis || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rp 区域(带水印) -->
|
||||
<div class="rx-rp">
|
||||
<div class="rx-watermark">药房联</div>
|
||||
<div class="rx-rp-head">
|
||||
<div class="rx-rp-label">Rp.</div>
|
||||
<div class="rx-rp-cols-head">
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
</div>
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-herbs">
|
||||
<template v-if="slipMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipMainHerbs"
|
||||
:key="'main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="slipAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipAuxHerbs"
|
||||
:key="'aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服法 / 医嘱 / 备注 / 药房备注 / 出丸 -->
|
||||
<div class="rx-text">
|
||||
<p>主方服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAuxUsageText">辅方服法:{{ rxAuxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
</p>
|
||||
<p v-if="rxOutPelletText" class="rx-text-warn">
|
||||
出丸:{{ rxOutPelletText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 底部表:医师/审方/配药/复核/发药 + 类型/剂数/单剂量 -->
|
||||
<div class="rx-bottom">
|
||||
<div class="rx-bot-row rx-bot-row-1">
|
||||
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
|
||||
<div class="rx-bot-label">医师</div>
|
||||
<div class="rx-bot-doctor-body">
|
||||
<img
|
||||
v-if="slipView.doctor_signature"
|
||||
:src="slipView.doctor_signature"
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<div v-if="!slipView.doctor_signature" class="rx-bot-doctor-name">
|
||||
{{ slipView.doctor_name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">类型:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">天数:</span>
|
||||
<span class="rx-bot-meta-val">{{ slipView.dose_count || '—' }}剂</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">单剂量:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- A4 处方纸:统一组件,与全端药房联一比一 -->
|
||||
<PrescriptionSlip v-if="slipView" ref="rxSlipRef" :data="slipView" />
|
||||
|
||||
<!-- 审核痕迹(不进入打印区) -->
|
||||
<div
|
||||
@@ -1719,6 +1583,7 @@ import { hasPermission } from '@/utils/perm'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
|
||||
import { Search, Download, Printer, EditPen } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, reactive, ref, watch, nextTick, defineAsyncComponent } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -2542,9 +2407,14 @@ const slipDietaryText = computed(() => {
|
||||
/* ============================================================
|
||||
* A4 处方笺(药房联)相关计算
|
||||
* ============================================================ */
|
||||
const rxPaperRef = ref<HTMLElement | null>(null)
|
||||
const rxSlipRef = ref<InstanceType<typeof PrescriptionSlip> | null>(null)
|
||||
const downloadingSlip = ref(false)
|
||||
|
||||
function getRxPaperEl(): HTMLElement | null {
|
||||
const exposed = rxSlipRef.value as { paperEl?: HTMLElement | null } | null
|
||||
return exposed?.paperEl ?? null
|
||||
}
|
||||
|
||||
const padN = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const rxDateText = computed(() => {
|
||||
@@ -2752,7 +2622,7 @@ function handlePrintSlip() {
|
||||
|
||||
/* === 下载 PDF(A4) === */
|
||||
async function handleDownloadSlip() {
|
||||
const node = rxPaperRef.value
|
||||
const node = getRxPaperEl()
|
||||
if (!node) {
|
||||
feedback.msgWarning('处方笺尚未渲染完成')
|
||||
return
|
||||
@@ -4155,10 +4025,7 @@ onMounted(async () => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 处方笺(A4 药房联)样式
|
||||
* 严格按 A4:210mm × 297mm,padding 12mm
|
||||
* ============================================================ */
|
||||
/* 查看区外壳;A4 纸张样式见 @/components/prescription-slip */
|
||||
.rx-wrap {
|
||||
min-height: 160px;
|
||||
background: #f5f6f8;
|
||||
@@ -4193,370 +4060,6 @@ onMounted(async () => {
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
/* === A4 纸 === */
|
||||
.rx-paper {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
padding: 8mm 10mm;
|
||||
color: #1f1f1f;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
box-shadow: 0 0 0 1px #d6d6d6, 0 6px 24px rgba(0, 0, 0, 0.06);
|
||||
/* 防止 html2canvas 把内部表格裁切 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* === 顶部告知 === */
|
||||
.rx-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 6px 10px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
color: #1f1f1f;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.rx-notice-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rx-notice-meta {
|
||||
display: inline-flex;
|
||||
gap: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 患者信息表 === */
|
||||
.rx-info {
|
||||
border: 1px solid #c8c8c8;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rx-info-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
}
|
||||
|
||||
.rx-info-row-full {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rx-info-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
border-right: 1px solid #c8c8c8;
|
||||
min-height: 32px;
|
||||
|
||||
&:last-child { border-right: none; }
|
||||
|
||||
.rx-key {
|
||||
color: #1f1f1f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rx-val {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Rp 区域 + 水印 === */
|
||||
.rx-rp {
|
||||
position: relative;
|
||||
border-left: 1px solid #c8c8c8;
|
||||
border-right: 1px solid #c8c8c8;
|
||||
padding: 8px 10px 16px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rx-watermark {
|
||||
position: absolute;
|
||||
/* 不使用 inset 简写:html2canvas 对 inset 解析不稳定,
|
||||
* 会导致水印容器尺寸异常,进而影响内层网格对齐 */
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 88px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.06);
|
||||
transform: rotate(-22deg);
|
||||
letter-spacing: 0.18em;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.rx-rp-head {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr;
|
||||
align-items: end;
|
||||
column-gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #d4d4d4;
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rx-rp-label {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f1f1f;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rx-rp-cols-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 24px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 注意:列内 1fr 64px 与 .rx-herb-cell 完全一致,
|
||||
* 配合 .rx-herbs 与 .rx-rp-cols-head 同样的左侧 44px 缩进,
|
||||
* 即可保证「用药 (单剂)」与药材名、「总量」与总量数字精确对齐。 */
|
||||
.rx-rp-col-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
column-gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #1f1f1f;
|
||||
min-width: 0;
|
||||
|
||||
& > span:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-herbs {
|
||||
/* 与 .rx-rp-head 中 Rp. 列(36px) + column-gap(8px) 等宽缩进,
|
||||
* 这样下方两列的左右边界与表头两列完全对齐。 */
|
||||
padding-left: 44px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 24px;
|
||||
row-gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rx-herb-section-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rx-herb-section-label--aux {
|
||||
color: #e6a23c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.herb-formula-block__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.usage-formula-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 4px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.usage-formula-title--aux {
|
||||
color: #e6a23c;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.rx-herb-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px;
|
||||
column-gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.85;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rx-herb-name {
|
||||
color: #1f1f1f;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rx-herb-total {
|
||||
color: #1f1f1f;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* === 服法 / 备注 文本块 === */
|
||||
.rx-text {
|
||||
border: 1px solid #c8c8c8;
|
||||
border-top: none;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.85;
|
||||
|
||||
p { margin: 0; }
|
||||
|
||||
.rx-text-warn {
|
||||
color: #d72424;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 底部表格 === */
|
||||
.rx-bottom {
|
||||
border: 1px solid #c8c8c8;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.rx-bot-row {
|
||||
display: grid;
|
||||
border-bottom: 1px solid #c8c8c8;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.rx-bot-row-1 {
|
||||
/* 类型/剂数/单剂量 三个 meta 列适当加宽,
|
||||
* 避免"浓缩丸-浓缩水丸"被强制折行后把整行挤错位 */
|
||||
grid-template-columns: 1.4fr 0.6fr 0.6fr 0.6fr 0.6fr 1.7fr 0.95fr 1.25fr;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.rx-bot-row-2 {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
/* 默认:单行内容左对齐、上下居中(适配 类型/剂数/单剂量/费用 等格子)。
|
||||
* 需要"标题置顶 + 签名留白"的格子(医师/审方/配药/复核/发药)请加 .rx-bot-stack。
|
||||
* 这样无需任何 !important 反向覆盖,html2canvas/原生渲染表现一致。 */
|
||||
.rx-bot-cell {
|
||||
border-right: 1px solid #c8c8c8;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #1f1f1f;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-height: 32px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:last-child { border-right: none; }
|
||||
}
|
||||
|
||||
.rx-bot-stack {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.rx-bot-label {
|
||||
font-size: 13px;
|
||||
color: #1f1f1f;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rx-bot-doctor {
|
||||
.rx-bot-doctor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.rx-bot-doctor-name {
|
||||
font-size: 13px;
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.rx-bot-sign-img {
|
||||
max-height: 40px;
|
||||
max-width: 110px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-bot-meta {
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.rx-bot-meta-key {
|
||||
color: #1f1f1f;
|
||||
margin-right: 6px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rx-bot-meta-val {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-fee {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
padding: 8px 10px;
|
||||
|
||||
.rx-fee-key {
|
||||
color: #1f1f1f;
|
||||
margin-right: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rx-fee-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.rx-fee-total {
|
||||
font-weight: 700;
|
||||
background: #fafafa;
|
||||
|
||||
.rx-fee-val { font-weight: 700; }
|
||||
}
|
||||
|
||||
/* === 审核痕迹(不打印) === */
|
||||
.rx-audit {
|
||||
margin-top: 12px;
|
||||
@@ -4569,8 +4072,6 @@ onMounted(async () => {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 打印控制规则放到非 scoped 的全局 style 中,参见底部 <style> */
|
||||
|
||||
/* 创建业务订单弹窗:宽松留白、默认字号、卡片化信息区 */
|
||||
.create-order-dialog {
|
||||
:deep(.el-dialog__header) {
|
||||
@@ -4865,28 +4366,4 @@ onMounted(async () => {
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 全局打印样式(不能 scoped,否则 body * 不生效) -->
|
||||
<style>
|
||||
@media print {
|
||||
body * { visibility: hidden !important; }
|
||||
|
||||
.rx-paper, .rx-paper * { visibility: visible !important; }
|
||||
|
||||
.rx-paper {
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 210mm !important;
|
||||
height: 297mm !important;
|
||||
margin: 0 !important;
|
||||
padding: 8mm 10mm !important;
|
||||
box-shadow: none !important;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1770,12 +1770,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="prescriptionViewLoading" class="rx-wrap">
|
||||
<!-- 处方签签 -->
|
||||
<el-tabs v-model="prescriptionTabType" class="mx-4 mt-2" style="margin-bottom: 0px; z-index: 1; position: relative;" v-if="prescriptionViewData">
|
||||
<el-tab-pane label="药房联" name="internal" />
|
||||
<el-tab-pane label="处方联" name="user" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 状态条(不进入打印/导出范围) -->
|
||||
<div class="rx-statusbar" v-if="prescriptionViewData">
|
||||
<el-tag
|
||||
@@ -1824,205 +1818,11 @@
|
||||
<div v-if="prescriptionViewData.audit_remark" class="mt-1">驳回意见:{{ prescriptionViewData.audit_remark }}</div>
|
||||
</div>
|
||||
|
||||
<!-- A4 处方纸(药房联) -->
|
||||
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
|
||||
<div class="rx-title">{{ prescriptionTabType === 'internal' ? '药房联' : SLIP_TITLE }}</div>
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
{{
|
||||
prescriptionTabType === 'user'
|
||||
? '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
|
||||
: '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
|
||||
}}
|
||||
</span>
|
||||
<div class="rx-notice-meta">
|
||||
<div class="rx-meta-item">
|
||||
<span class="rx-meta-label">日期</span>
|
||||
<span class="rx-meta-value">{{ rxDateText }}</span>
|
||||
</div>
|
||||
<div class="rx-meta-item">
|
||||
<span class="rx-meta-label">处方编号</span>
|
||||
<span class="rx-meta-value">{{ rxPrescriptionSnText }}</span>
|
||||
</div>
|
||||
<div class="rx-meta-item">
|
||||
<span class="rx-meta-label">流转编号(挂号)</span>
|
||||
<span class="rx-meta-value">{{ rxAppointmentFlowText }}</span>
|
||||
</div>
|
||||
<div v-if="rxBusinessOrderNoText" class="rx-meta-item">
|
||||
<span class="rx-meta-label">业务单号</span>
|
||||
<span class="rx-meta-value">{{ rxBusinessOrderNoText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info">
|
||||
<div class="rx-info-row">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">姓名</span>
|
||||
<span class="rx-val">{{ prescriptionViewData.patient_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">性别</span>
|
||||
<span class="rx-val">{{ slipGenderLabel(prescriptionViewData.gender) }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">年龄</span>
|
||||
<span class="rx-val">{{ slipAgeText(prescriptionViewData.age) }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">电话</span>
|
||||
<span class="rx-val">{{ prescriptionViewData.phone || prescriptionViewData.recipient_phone || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">收件信息</span>
|
||||
<span class="rx-val">{{ rxRecipientText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">临床诊断</span>
|
||||
<span class="rx-val">{{ prescriptionViewData.clinical_diagnosis || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-rp">
|
||||
<div class="rx-watermark">{{ prescriptionTabType === 'internal' ? '药房联' : '处方联' }}</div>
|
||||
<div class="rx-rp-head">
|
||||
<div class="rx-rp-label">Rp.</div>
|
||||
<div class="rx-rp-cols-head">
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
|
||||
</div>
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>{{ prescriptionTabType === 'user' ? '用量' : '总量' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-herbs">
|
||||
<template v-if="prescriptionTabType === 'internal'">
|
||||
<template v-if="slipMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipMainHerbs"
|
||||
:key="'main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="slipAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">
|
||||
辅方<span v-if="slipAuxLibraryName" class="rx-herb-library-name">({{ slipAuxLibraryName }})</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(h, i) in slipAuxHerbs"
|
||||
:key="'aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="slipMainHerbs.length">
|
||||
<div class="rx-herb-section-label">主方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipMainHerbs"
|
||||
:key="'user-main-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }}</span>
|
||||
<span class="rx-herb-total">{{ h.dosage }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="slipAuxHerbs.length">
|
||||
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
|
||||
<div
|
||||
v-for="(h, i) in slipAuxHerbs"
|
||||
:key="'user-aux-' + i"
|
||||
class="rx-herb-cell"
|
||||
>
|
||||
<span class="rx-herb-name">{{ h.name }}</span>
|
||||
<span class="rx-herb-total">{{ h.dosage }}克</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-text">
|
||||
<template v-if="prescriptionTabType === 'internal'">
|
||||
<p v-if="rxAuxUsageText">主服法:{{ rxUsageText }}</p>
|
||||
<p v-else>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAuxUsageText">辅服法:{{ rxAuxUsageText }}</p>
|
||||
</template>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="prescriptionTabType === 'internal' && rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
</p>
|
||||
<p
|
||||
v-if="prescriptionTabType === 'internal' && rxOutPelletText && prescriptionViewData?.prescription_type !== '饮片'"
|
||||
class="rx-text-warn"
|
||||
>
|
||||
出丸:{{ rxOutPelletText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rx-bottom">
|
||||
<div
|
||||
class="rx-bot-row rx-bot-row-1"
|
||||
:class="{ 'rx-bot-row-1--user': prescriptionTabType === 'user' }"
|
||||
>
|
||||
<div class="rx-bot-cell rx-bot-doctor">
|
||||
<div class="rx-bot-label rx-bot-label--doctor">医师</div>
|
||||
<div class="rx-bot-doctor-body">
|
||||
<img
|
||||
v-if="prescriptionViewData.doctor_signature"
|
||||
:src="prescriptionViewData.doctor_signature"
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<span
|
||||
v-if="!prescriptionViewData.doctor_signature"
|
||||
class="rx-bot-doctor-name"
|
||||
>{{ prescriptionViewData.doctor_name || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">类型:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">天数:</span>
|
||||
<span class="rx-bot-meta-val">
|
||||
{{
|
||||
detailData?.medication_days != null &&
|
||||
String(detailData.medication_days).trim() !== ''
|
||||
? detailData.medication_days + ' 天'
|
||||
: '—'
|
||||
}} </span>
|
||||
</div>
|
||||
<div v-if="prescriptionTabType === 'internal'" class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">剂量:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="rx-bot-row rx-hospital-row">
|
||||
<div class="rx-hospital-line">成都双流甄养堂互联网医院有限公司 联系方式:4001667339</div>
|
||||
<div class="rx-hospital-line">地址:四川省成都市双流区黄甲街道黄龙大道二段280号</div>
|
||||
</div>
|
||||
</div>
|
||||
<PrescriptionSlip
|
||||
v-if="prescriptionViewData"
|
||||
ref="rxSlipRef"
|
||||
:data="prescriptionViewData"
|
||||
/>
|
||||
|
||||
<!-- 审核痕迹(不进入导出) -->
|
||||
<div
|
||||
@@ -2281,6 +2081,7 @@ import {
|
||||
getDoctors,
|
||||
getAssistants
|
||||
} from '@/api/tcm'
|
||||
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -4665,8 +4466,7 @@ const prescriptionViewLoading = ref(false)
|
||||
const prescriptionViewData = ref<any>(null)
|
||||
/** 药房联辅方标题:处方库中该辅方模板的 prescription_name(导入或药材匹配解析) */
|
||||
const slipAuxLibraryName = ref('')
|
||||
const prescriptionTabType = ref('internal')
|
||||
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
|
||||
const rxSlipRef = ref<InstanceType<typeof PrescriptionSlip> | null>(null)
|
||||
const prescriptionSlipExporting = ref(false)
|
||||
|
||||
function buildSlipUsageSegmentText(
|
||||
@@ -5064,8 +4864,7 @@ async function openPrescriptionView(row: any) {
|
||||
prescriptionViewLoading.value = true
|
||||
prescriptionViewData.value = null
|
||||
slipAuxLibraryName.value = ''
|
||||
prescriptionTabType.value = 'internal'
|
||||
|
||||
|
||||
try {
|
||||
const res: any = await prescriptionDetail({ id: row.prescription_id })
|
||||
const base = res?.data ?? res ?? null
|
||||
@@ -5135,7 +4934,7 @@ async function preloadSlipImagesToDataUrl(el: HTMLElement): Promise<void> {
|
||||
|
||||
async function capturePrescriptionSlipCanvas(): Promise<HTMLCanvasElement> {
|
||||
await nextTick()
|
||||
const el = prescriptionSlipPrintRef.value
|
||||
const el = (rxSlipRef.value as { paperEl?: HTMLElement | null } | null)?.paperEl ?? null
|
||||
if (!el) throw new Error('未找到处方笺内容')
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'nearest' })
|
||||
await preloadSlipImagesToDataUrl(el)
|
||||
|
||||
@@ -2390,145 +2390,11 @@
|
||||
<div v-if="prescriptionViewData.audit_remark" class="mt-1">驳回意见:{{ prescriptionViewData.audit_remark }}</div>
|
||||
</div>
|
||||
|
||||
<!-- A4 处方纸(药房联) -->
|
||||
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="rx-paper">
|
||||
<div class="rx-title">{{ SLIP_TITLE }}</div>
|
||||
<div class="rx-notice">
|
||||
<span class="rx-notice-text">
|
||||
服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点
|
||||
</span>
|
||||
<div class="rx-notice-meta">
|
||||
<div class="rx-meta-item">
|
||||
<span class="rx-meta-label">日期</span>
|
||||
<span class="rx-meta-value">{{ rxDateText }}</span>
|
||||
</div>
|
||||
<div class="rx-meta-item">
|
||||
<span class="rx-meta-label">处方编号</span>
|
||||
<span class="rx-meta-value">{{ rxPrescriptionSnText }}</span>
|
||||
</div>
|
||||
<div class="rx-meta-item">
|
||||
<span class="rx-meta-label">流转编号(挂号)</span>
|
||||
<span class="rx-meta-value">{{ rxAppointmentFlowText }}</span>
|
||||
</div>
|
||||
<div v-if="rxBusinessOrderNoText" class="rx-meta-item">
|
||||
<span class="rx-meta-label">业务单号</span>
|
||||
<span class="rx-meta-value">{{ rxBusinessOrderNoText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info">
|
||||
<div class="rx-info-row">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">姓名</span>
|
||||
<span class="rx-val">{{ prescriptionViewData.patient_name || '—' }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">性别</span>
|
||||
<span class="rx-val">{{ slipGenderLabel(prescriptionViewData.gender) }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">年龄</span>
|
||||
<span class="rx-val">{{ slipAgeText(prescriptionViewData.age) }}</span>
|
||||
</div>
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">电话</span>
|
||||
<span class="rx-val">{{ prescriptionViewData.phone || prescriptionViewData.recipient_phone || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">收件信息</span>
|
||||
<span class="rx-val">{{ rxRecipientText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-info-row rx-info-row-full">
|
||||
<div class="rx-info-cell">
|
||||
<span class="rx-key">临床诊断</span>
|
||||
<span class="rx-val">{{ prescriptionViewData.clinical_diagnosis || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-rp">
|
||||
<div class="rx-watermark">药房联</div>
|
||||
<div class="rx-rp-head">
|
||||
<div class="rx-rp-label">Rp.</div>
|
||||
<div class="rx-rp-cols-head">
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
</div>
|
||||
<div class="rx-rp-col-head">
|
||||
<span>用药 (单剂)</span>
|
||||
<span>总量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rx-herbs">
|
||||
<div v-for="(h, i) in slipHerbsList" :key="i" class="rx-herb-cell">
|
||||
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }}克)</span>
|
||||
<span class="rx-herb-total">{{ rxHerbTotal(h.dosage) }}克</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-text">
|
||||
<p>服法:{{ rxUsageText }}</p>
|
||||
<p v-if="rxAdviceText">医嘱:{{ rxAdviceText }}</p>
|
||||
<p v-if="slipDietaryText">忌口:{{ slipDietaryText }}</p>
|
||||
<p v-if="rxRemarkText">备注:{{ rxRemarkText }}</p>
|
||||
<p v-if="rxPharmacyRemarkText" class="rx-text-warn">
|
||||
药房备注:{{ rxPharmacyRemarkText }}
|
||||
</p>
|
||||
<p v-if="rxOutPelletText" class="rx-text-warn">
|
||||
出丸:{{ rxOutPelletText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rx-bottom">
|
||||
<div class="rx-bot-row rx-bot-row-1">
|
||||
<div class="rx-bot-cell rx-bot-doctor">
|
||||
<div class="rx-bot-label rx-bot-label--doctor">医师</div>
|
||||
<div class="rx-bot-doctor-body">
|
||||
<img
|
||||
v-if="prescriptionViewData.doctor_signature"
|
||||
:src="prescriptionViewData.doctor_signature"
|
||||
alt="医师签名"
|
||||
class="rx-bot-sign-img"
|
||||
/>
|
||||
<span
|
||||
v-if="!prescriptionViewData.doctor_signature"
|
||||
class="rx-bot-doctor-name"
|
||||
>{{ prescriptionViewData.doctor_name || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">类型:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxTypeText }}</span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">天数:</span>
|
||||
<span class="rx-bot-meta-val">
|
||||
{{
|
||||
detailData.medication_days != null &&
|
||||
String(detailData.medication_days).trim() !== ''
|
||||
? detailData.medication_days + ' 天'
|
||||
: '—'
|
||||
}} </span>
|
||||
</div>
|
||||
<div class="rx-bot-cell rx-bot-meta">
|
||||
<span class="rx-bot-meta-key">剂量:</span>
|
||||
<span class="rx-bot-meta-val">{{ rxPerDoseAmount }}克</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="rx-bot-row rx-hospital-row">
|
||||
<div class="rx-hospital-line">成都双流甄养堂互联网医院有限公司 联系方式:4001667339</div>
|
||||
<div class="rx-hospital-line">地址:四川省成都市双流区黄甲街道黄龙大道二段280号</div>
|
||||
</div>
|
||||
</div>
|
||||
<PrescriptionSlip
|
||||
v-if="prescriptionViewData"
|
||||
ref="rxSlipRef"
|
||||
:data="prescriptionViewData"
|
||||
/>
|
||||
|
||||
<!-- 审核痕迹(不进入导出) -->
|
||||
<div
|
||||
@@ -2750,6 +2616,7 @@ import {
|
||||
supplyModeLabel,
|
||||
supplyModeTagType
|
||||
} from './components/prescription-order-utils'
|
||||
import PrescriptionSlip from '@/components/prescription-slip/index.vue'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -5429,7 +5296,7 @@ const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄
|
||||
const prescriptionViewVisible = ref(false)
|
||||
const prescriptionViewLoading = ref(false)
|
||||
const prescriptionViewData = ref<any>(null)
|
||||
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
|
||||
const rxSlipRef = ref<InstanceType<typeof PrescriptionSlip> | null>(null)
|
||||
const prescriptionSlipExporting = ref(false)
|
||||
|
||||
// 处方单辅助函数
|
||||
@@ -5763,7 +5630,7 @@ async function preloadSlipImagesToDataUrl(el: HTMLElement): Promise<void> {
|
||||
|
||||
async function capturePrescriptionSlipCanvas(): Promise<HTMLCanvasElement> {
|
||||
await nextTick()
|
||||
const el = prescriptionSlipPrintRef.value
|
||||
const el = (rxSlipRef.value as { paperEl?: HTMLElement | null } | null)?.paperEl ?? null
|
||||
if (!el) throw new Error('未找到处方笺内容')
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'nearest' })
|
||||
await preloadSlipImagesToDataUrl(el)
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/预约,接诊率=接诊诊单/加粉</p>
|
||||
<p>展开部门可查看人员明细;加粉=总进线=区间有效加粉(按员工+客户去重,须会话同意,剔除已删客户与继承客户);挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/挂号(看挂号后流失),预约率=面诊/预约(看预约后未面诊),面诊接诊率=接诊诊单/面诊,接诊率=接诊诊单/总进线</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
@@ -194,6 +194,9 @@
|
||||
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_paid_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊接诊率" min-width="116" align="right">
|
||||
@@ -327,7 +330,7 @@ const timeOptions = [
|
||||
{ label: '自定义', value: 'custom' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '区间有效加粉:去重,须会话同意,剔除已删客户与继承客户' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
<template>
|
||||
<section class="embedded-panel" v-loading="loading">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h2>医生排班</h2>
|
||||
<p>查看近 7 日出诊医生及可约时段(与医生排班管理同口径)</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button
|
||||
:icon="Refresh"
|
||||
:loading="refreshing"
|
||||
:disabled="!selectedDoctorId || !form.date"
|
||||
@click="handleRefreshSlots"
|
||||
>
|
||||
刷新时段
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" label-width="88px" class="paiban-form">
|
||||
<el-form-item label="选择医生">
|
||||
<div class="doctor-list">
|
||||
<el-radio-group v-model="selectedDoctorId" @change="handleDoctorChange">
|
||||
<el-radio
|
||||
v-for="doctor in doctorList"
|
||||
:key="doctor.id"
|
||||
:value="doctor.id"
|
||||
class="doctor-radio"
|
||||
>
|
||||
{{ doctor.name }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<el-empty
|
||||
v-if="!loading && doctorList.length === 0"
|
||||
description="暂无可用医生"
|
||||
:image-size="64"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排班时间">
|
||||
<el-empty v-if="!selectedDoctorId" description="请先选择医生" :image-size="72" />
|
||||
<el-empty
|
||||
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
|
||||
description="该医生暂无排班"
|
||||
:image-size="72"
|
||||
/>
|
||||
<div v-else class="paiban-time-container">
|
||||
<div class="date-selector">
|
||||
<el-button
|
||||
v-for="dateOption in dateOptions"
|
||||
:key="dateOption.date"
|
||||
:type="form.date === dateOption.date ? 'primary' : ''"
|
||||
class="date-button"
|
||||
@click="selectDate(dateOption.date)"
|
||||
>
|
||||
{{ dateOption.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="form.date" class="time-slots-container">
|
||||
<div class="time-slots-grid">
|
||||
<div
|
||||
v-for="slot in filteredTimeSlots"
|
||||
:key="slot.time"
|
||||
class="time-slot-item"
|
||||
:class="{
|
||||
available: slot.available,
|
||||
unavailable: !slot.available,
|
||||
selected: form.selectedTime === slot.time
|
||||
}"
|
||||
@click="selectTimeSlot(slot)"
|
||||
>
|
||||
<div class="slot-time">{{ slot.time }}</div>
|
||||
<div class="slot-status" :class="{ 'status-available': slot.available }">
|
||||
{{ slot.available ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="filteredTimeSlots.length === 0"
|
||||
description="当前日期暂无可展示时段"
|
||||
:image-size="64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import { getDoctors } from '@/api/tcm'
|
||||
import { getAvailableSlots, rosterLists } from '@/api/doctor'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
|
||||
interface TimeSlot {
|
||||
time: string
|
||||
available: boolean
|
||||
hasAppointment: boolean
|
||||
quota: number
|
||||
}
|
||||
|
||||
interface DateOption {
|
||||
date: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const doctorList = ref<any[]>([])
|
||||
const timeSlots = ref<TimeSlot[]>([])
|
||||
const selectedDoctorId = ref(0)
|
||||
const doctorRosterDates = ref<string[]>([])
|
||||
let autoRefreshTimer: number | null = null
|
||||
let isLoadingSlots = false
|
||||
|
||||
const form = reactive({
|
||||
date: '',
|
||||
selectedTime: ''
|
||||
})
|
||||
|
||||
const dateOptions = computed<DateOption[]>(() => {
|
||||
if (!selectedDoctorId.value || doctorRosterDates.value.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const options: DateOption[] = []
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const today = dayjs().startOf('day')
|
||||
|
||||
for (const date of doctorRosterDates.value) {
|
||||
const dateObj = dayjs(date)
|
||||
if (dateObj.isBefore(today)) {
|
||||
continue
|
||||
}
|
||||
options.push({
|
||||
date,
|
||||
label: `${dateObj.format('MM月DD日')} (${weekDays[dateObj.day()]})`
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const filteredTimeSlots = computed(() => {
|
||||
if (!form.date || timeSlots.value.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
if (form.date !== today) {
|
||||
return timeSlots.value
|
||||
}
|
||||
|
||||
const now = dayjs()
|
||||
return timeSlots.value.map((slot) => {
|
||||
const slotDateTime = dayjs(`${form.date} ${slot.time}`)
|
||||
const isPast = slotDateTime.isBefore(now) || slotDateTime.isSame(now, 'minute')
|
||||
if (isPast) {
|
||||
return { ...slot, available: false }
|
||||
}
|
||||
return slot
|
||||
})
|
||||
})
|
||||
|
||||
async function loadDoctors() {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await getDoctors()
|
||||
doctorList.value = res || []
|
||||
if (doctorList.value.length > 0 && !selectedDoctorId.value) {
|
||||
selectedDoctorId.value = doctorList.value[0].id
|
||||
await handleDoctorChange()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载医生列表失败:', error)
|
||||
feedback.msgError('加载医生列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDoctorChange() {
|
||||
form.selectedTime = ''
|
||||
form.date = ''
|
||||
timeSlots.value = []
|
||||
doctorRosterDates.value = []
|
||||
if (selectedDoctorId.value) {
|
||||
await loadDoctorRoster()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDoctorRoster() {
|
||||
if (!selectedDoctorId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const startDate = dayjs().format('YYYY-MM-DD')
|
||||
const endDate = dayjs().add(6, 'day').format('YYYY-MM-DD')
|
||||
const res = await rosterLists({
|
||||
doctor_id: selectedDoctorId.value,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
status: 1
|
||||
})
|
||||
|
||||
if (res?.lists && res.lists.length > 0) {
|
||||
doctorRosterDates.value = [...new Set(res.lists.map((item: any) => item.date))] as string[]
|
||||
doctorRosterDates.value.sort()
|
||||
await nextTick()
|
||||
if (doctorRosterDates.value.length > 0) {
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
const defaultDate = doctorRosterDates.value.includes(today)
|
||||
? today
|
||||
: doctorRosterDates.value[0]
|
||||
selectDate(defaultDate)
|
||||
}
|
||||
} else {
|
||||
doctorRosterDates.value = []
|
||||
feedback.msgWarning('该医生暂无排班')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载医生排班失败:', error)
|
||||
feedback.msgError('加载医生排班失败')
|
||||
doctorRosterDates.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectDate(date: string) {
|
||||
form.date = date
|
||||
form.selectedTime = ''
|
||||
if (selectedDoctorId.value) {
|
||||
loadTimeSlots()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTimeSlots(silent = false) {
|
||||
if (!selectedDoctorId.value || !form.date) {
|
||||
return
|
||||
}
|
||||
if (isLoadingSlots) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoadingSlots = true
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
const response = await getAvailableSlots({
|
||||
doctor_id: selectedDoctorId.value,
|
||||
appointment_date: form.date,
|
||||
period: 'all'
|
||||
})
|
||||
|
||||
timeSlots.value = (response?.slots || []).map((slot: any) => ({
|
||||
time: slot.time,
|
||||
available: slot.available,
|
||||
hasAppointment: Boolean(slot.has_appointment ?? slot.available === false),
|
||||
quota: slot.available ? 1 : 0
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('加载时间段失败:', error)
|
||||
if (!silent) {
|
||||
feedback.msgError('加载时间段失败')
|
||||
}
|
||||
timeSlots.value = []
|
||||
} finally {
|
||||
isLoadingSlots = false
|
||||
if (!silent) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectTimeSlot(slot: TimeSlot) {
|
||||
if (!slot.available) {
|
||||
feedback.msgWarning('该时间段不可预约')
|
||||
return
|
||||
}
|
||||
form.selectedTime = slot.time
|
||||
}
|
||||
|
||||
async function handleRefreshSlots() {
|
||||
if (!selectedDoctorId.value || !form.date) {
|
||||
return
|
||||
}
|
||||
if (isLoadingSlots) {
|
||||
feedback.msgWarning('正在刷新中,请稍候')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
refreshing.value = true
|
||||
const previousSelection = form.selectedTime
|
||||
form.selectedTime = ''
|
||||
await loadTimeSlots()
|
||||
if (previousSelection) {
|
||||
const slot = timeSlots.value.find((s) => s.time === previousSelection)
|
||||
if (slot?.available) {
|
||||
form.selectedTime = previousSelection
|
||||
}
|
||||
}
|
||||
feedback.msgSuccess('刷新成功')
|
||||
} catch (error) {
|
||||
console.error('刷新失败:', error)
|
||||
feedback.msgError('刷新失败,请重试')
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
autoRefreshTimer = window.setInterval(() => {
|
||||
if (selectedDoctorId.value && form.date) {
|
||||
loadTimeSlots(true)
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (selectedDoctorId.value && form.date) {
|
||||
await loadTimeSlots()
|
||||
return
|
||||
}
|
||||
await loadDoctors()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
loading
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadDoctors()
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.embedded-panel {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1f2a37;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.paiban-form {
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
.doctor-radio {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.paiban-time-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.date-button {
|
||||
min-width: 130px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-slots-container {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.time-slots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #dcdfe6;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background-color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.time-slot-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
border: 2px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background-color: #fff;
|
||||
min-height: 70px;
|
||||
|
||||
.slot-time {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.slot-status {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background-color: #f4f4f5;
|
||||
|
||||
&.status-available {
|
||||
color: #67c23a;
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.available {
|
||||
border-color: #e4e7ed;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
background-color: #ecf5ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&.unavailable {
|
||||
background-color: #f5f7fa;
|
||||
border-color: #e4e7ed;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
.slot-time {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.slot-status {
|
||||
color: #c0c4cc;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #409eff;
|
||||
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
|
||||
.slot-time {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.slot-status {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-radio) {
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
||||
<el-tab-pane label="患者列表" name="patients" />
|
||||
<el-tab-pane label="订单管理" name="orders" />
|
||||
<el-tab-pane label="面诊进度" name="progress" />
|
||||
<el-tab-pane label="医生排班" name="paiban" />
|
||||
</el-tabs>
|
||||
|
||||
<div v-show="activeWorkspace === 'patients'">
|
||||
@@ -207,6 +208,10 @@
|
||||
ref="progressPanelRef"
|
||||
@open-diagnosis="openDiagnosis"
|
||||
/>
|
||||
<paiban-panel
|
||||
v-if="activeWorkspace === 'paiban'"
|
||||
ref="paibanPanelRef"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<edit-popup ref="editRef" @success="refreshPage" />
|
||||
@@ -342,6 +347,7 @@ const EditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.
|
||||
const AppointmentPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/appointment.vue'))
|
||||
const OrderPanel = defineAsyncComponent(() => import('./components/OrderPanel.vue'))
|
||||
const ProgressPanel = defineAsyncComponent(() => import('./components/ProgressPanel.vue'))
|
||||
const PaibanPanel = defineAsyncComponent(() => import('./components/PaibanPanel.vue'))
|
||||
|
||||
type StatusFilter = '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
|
||||
type DateType = 'all' | 'today' | 'tomorrow' | 'day_after' | 'last7' | 'last30' | 'custom'
|
||||
@@ -355,6 +361,7 @@ const editRef = ref<any>()
|
||||
const appointmentRef = ref<any>()
|
||||
const orderPanelRef = ref<any>()
|
||||
const progressPanelRef = ref<any>()
|
||||
const paibanPanelRef = ref<any>()
|
||||
const qrcodeDialogVisible = ref(false)
|
||||
const qrcodeLoading = ref(false)
|
||||
const qrcodeUrl = ref('')
|
||||
@@ -423,6 +430,7 @@ const canFillIdCard = computed(() => hasPermission(['tcm.diagnosis/edit']))
|
||||
const workspaceLoading = computed(() => {
|
||||
if (activeWorkspace.value === 'orders') return Boolean(orderPanelRef.value?.loading)
|
||||
if (activeWorkspace.value === 'progress') return Boolean(progressPanelRef.value?.loading)
|
||||
if (activeWorkspace.value === 'paiban') return Boolean(paibanPanelRef.value?.loading)
|
||||
return pager.loading
|
||||
})
|
||||
|
||||
@@ -437,6 +445,7 @@ function handleFilterChange() {
|
||||
function refreshPage() {
|
||||
if (activeWorkspace.value === 'orders') return orderPanelRef.value?.refresh?.()
|
||||
if (activeWorkspace.value === 'progress') return progressPanelRef.value?.refresh?.()
|
||||
if (activeWorkspace.value === 'paiban') return paibanPanelRef.value?.refresh?.()
|
||||
return getLists()
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项
|
||||
|
||||
## 已实现范围
|
||||
|
||||
- 账号密码登录、token 会话、记住账号(不保存密码)和退出登录。
|
||||
- 账号密码登录、token 会话、通过系统凭据管理器安全记住密码和退出登录。
|
||||
- 登录后读取 `/adminapi/auth.admin/mySelf`,按 `permissions` 动态控制页面和操作按钮;`*` 超级权限兼容现有后台。
|
||||
- 接诊台:今日待接诊/已过号、患者详情、医生备注、通知医助、完成接诊、发起视频面诊。
|
||||
- 挂号列表:对齐管理端 `tcm/appointment/list`,支持状态 Tab、日期/确认/部门筛选、通话、完成、开方与取消挂号。
|
||||
@@ -76,7 +76,7 @@ DOCTOR_VERIFY_SSL=true
|
||||
- 响应 envelope:`code=1` 成功、`0` 业务失败、`-1` 登录失效、`10` 需绑定企业微信。
|
||||
- 权限与数据范围:完全以后端 `/auth.admin/mySelf` 返回为准。
|
||||
|
||||
环境配置不会保存密码、TRTC SecretKey 或腾讯云长期凭据。登录 token 优先存入系统凭据库;无法使用时仅回退到用户配置目录中的受限文件。
|
||||
环境配置不会保存明文密码、TRTC SecretKey 或腾讯云长期凭据。登录密码优先存入系统凭据管理器;Windows 凭据后端不可用时仅保存当前 Windows 用户可解密的 DPAPI 密文。登录 token 优先存入系统凭据库,无法使用时仅回退到用户配置目录中的受限文件。
|
||||
|
||||
## 视频伴随页
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
from contextlib import suppress
|
||||
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.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -43,7 +43,71 @@ from doctor_workstation.ui.widgets import (
|
||||
from doctor_workstation.video import BackendMode, launch_video_call
|
||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
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:
|
||||
@@ -200,11 +264,12 @@ class ApplicationController(QObject):
|
||||
|
||||
def _show_login(self) -> None:
|
||||
if self.login_window is None:
|
||||
self.login_window = LoginWindow(
|
||||
self._base_repository(),
|
||||
self.config,
|
||||
self.demo_repository,
|
||||
)
|
||||
self.login_window = LoginWindow(
|
||||
self._base_repository(),
|
||||
self.config,
|
||||
self.demo_repository,
|
||||
credential_store=self.token_store,
|
||||
)
|
||||
self.login_window.login_succeeded.connect(self._on_login_succeeded)
|
||||
self.login_window.config_changed.connect(self._on_config_changed)
|
||||
self.login_window.demo_mode_changed.connect(self._on_demo_mode_changed)
|
||||
@@ -212,8 +277,9 @@ class ApplicationController(QObject):
|
||||
else:
|
||||
self.login_window.repository = self._base_repository()
|
||||
self.login_window.config = self.config
|
||||
if not self.login_window.demo_check.isChecked():
|
||||
self.login_window.active_repository = self._base_repository()
|
||||
if not self.login_window.demo_check.isChecked():
|
||||
self.login_window.active_repository = self._base_repository()
|
||||
self.login_window.restore_remembered_credentials()
|
||||
self.login_window.show()
|
||||
self.login_window.raise_()
|
||||
self.login_window.activateWindow()
|
||||
@@ -498,20 +564,40 @@ class ApplicationController(QObject):
|
||||
if parent is None or self.current_repository is None:
|
||||
return
|
||||
patient_id = payload.get("patient_id")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
patient_name = str(payload.get("patient_name") or "患者")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
patient_name = str(payload.get("patient_name") or "患者")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||
return
|
||||
|
||||
call_key = str(diagnosis_id)
|
||||
if (
|
||||
call_key in self.video_pending
|
||||
or call_key in self.video_calls
|
||||
or call_key in self.demo_video_dialogs
|
||||
):
|
||||
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
||||
return
|
||||
call_key = str(diagnosis_id)
|
||||
existing_call = self.video_calls.get(call_key)
|
||||
if open_im and existing_call is not None and getattr(existing_call, "open_im", False):
|
||||
qt_window = getattr(existing_call, "qt_window", None)
|
||||
if qt_window is not None:
|
||||
qt_window.show()
|
||||
qt_window.raise_()
|
||||
qt_window.activateWindow()
|
||||
show_toast(parent, "该患者的 IM 会话已经打开。", "info", 3200)
|
||||
return
|
||||
if (
|
||||
call_key in self.video_pending
|
||||
or existing_call is not None
|
||||
or call_key in self.demo_video_dialogs
|
||||
):
|
||||
show_toast(parent, "该问诊的视频正在准备或通话中。", "info", 3600)
|
||||
return
|
||||
|
||||
closed_previous_im = False
|
||||
if open_im:
|
||||
for key, call in tuple(self.video_calls.items()):
|
||||
if key == call_key or not getattr(call, "open_im", False):
|
||||
continue
|
||||
closed_previous_im = True
|
||||
self.video_calls.pop(key, None)
|
||||
with suppress(Exception):
|
||||
call.close()
|
||||
|
||||
if self.current_demo_mode:
|
||||
dialog = DemoVideoDialog(patient_name, parent)
|
||||
@@ -525,7 +611,11 @@ class ApplicationController(QObject):
|
||||
dialog.show()
|
||||
return
|
||||
|
||||
show_toast(parent, "正在获取安全通话凭证…", "info")
|
||||
show_toast(
|
||||
parent,
|
||||
"正在打开患者 IM 会话…" if open_im else "正在获取安全通话凭证…",
|
||||
"info",
|
||||
)
|
||||
repository = self.current_repository
|
||||
marker = object()
|
||||
self.video_pending[call_key] = marker
|
||||
@@ -536,23 +626,35 @@ class ApplicationController(QObject):
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
|
||||
run_async(
|
||||
get_ticket,
|
||||
on_success=lambda ticket: self._launch_video(
|
||||
ticket,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
call_key=call_key,
|
||||
marker=marker,
|
||||
),
|
||||
on_error=lambda error: self._video_ticket_error(
|
||||
call_key,
|
||||
marker,
|
||||
parent,
|
||||
error,
|
||||
),
|
||||
)
|
||||
def request_ticket() -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
run_async(
|
||||
get_ticket,
|
||||
on_success=lambda ticket: self._launch_video(
|
||||
ticket,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
call_key=call_key,
|
||||
marker=marker,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
),
|
||||
on_error=lambda error: self._video_ticket_error(
|
||||
call_key,
|
||||
marker,
|
||||
parent,
|
||||
error,
|
||||
),
|
||||
)
|
||||
|
||||
# Tencent IM may take a brief moment to release the previous browser
|
||||
# connection. The admin version also has only one ChatDialog instance.
|
||||
if closed_previous_im:
|
||||
QTimer.singleShot(400, request_ticket)
|
||||
else:
|
||||
request_ticket()
|
||||
|
||||
def _video_ticket_error(
|
||||
self,
|
||||
@@ -579,9 +681,11 @@ class ApplicationController(QObject):
|
||||
diagnosis_id: Any,
|
||||
patient_id: Any,
|
||||
repository: Any,
|
||||
call_key: str,
|
||||
marker: object,
|
||||
) -> None:
|
||||
call_key: str,
|
||||
marker: object,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
self.video_pending.pop(call_key, None)
|
||||
@@ -604,9 +708,11 @@ class ApplicationController(QObject):
|
||||
patient_id=patient_id,
|
||||
backend_mode=mode,
|
||||
local_dist=video_dist_path(),
|
||||
remote_url=self.config.video_web_url or None,
|
||||
logger=logging.getLogger("doctor_workstation.video"),
|
||||
)
|
||||
remote_url=self.config.video_web_url or None,
|
||||
logger=logging.getLogger("doctor_workstation.video"),
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
except Exception as error:
|
||||
LOGGER.exception("video call could not be launched")
|
||||
show_toast(
|
||||
@@ -682,7 +788,8 @@ def _create_application(argv: list[str]) -> QApplication:
|
||||
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
|
||||
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
||||
)
|
||||
application = QApplication(argv)
|
||||
application = QApplication(argv)
|
||||
_install_chinese_translations(application)
|
||||
application.setApplicationName("甄养堂医生工作站")
|
||||
application.setApplicationDisplayName("甄养堂医生工作站")
|
||||
application.setOrganizationName("ZhenYangTang")
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import struct
|
||||
import zlib
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from datetime import date, datetime, timedelta
|
||||
from os import PathLike
|
||||
@@ -163,8 +163,27 @@ class DemoDoctorRepository:
|
||||
"room_id": "demo-room-501",
|
||||
"status": 2,
|
||||
"status_text": "已结束",
|
||||
"recording_status_text": "录制完成",
|
||||
"recording_urls_list": ["https://media.example.invalid/demo/diagnosis-501.mp4"],
|
||||
"recording_status_text": "录制完成",
|
||||
"recording_urls_list": ["https://media.example.invalid/demo/diagnosis-501.mp4"],
|
||||
"transcription_status": "completed",
|
||||
"transcription_status_text": "文字已生成",
|
||||
"transcript_text": "患者:最近睡眠比上周好一些。\n医生:继续记录睡眠和空腹血糖。",
|
||||
"transcript_segments": [
|
||||
{
|
||||
"segment_id": "demo-1",
|
||||
"speaker_role": "patient",
|
||||
"speaker_user_id": "patient_301",
|
||||
"timestamp": 1,
|
||||
"text": "最近睡眠比上周好一些。",
|
||||
},
|
||||
{
|
||||
"segment_id": "demo-2",
|
||||
"speaker_role": "doctor",
|
||||
"speaker_user_id": "doctor_1001",
|
||||
"timestamp": 2,
|
||||
"text": "继续记录睡眠和空腹血糖。",
|
||||
},
|
||||
],
|
||||
"start_time_text": f"{self._today.isoformat()} 09:10:00",
|
||||
"end_time_text": f"{self._today.isoformat()} 09:22:00",
|
||||
"duration_text": "12分00秒",
|
||||
@@ -593,6 +612,28 @@ class DemoDoctorRepository:
|
||||
safe_name = source.name.replace("\\", "_").replace("/", "_")
|
||||
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(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
@@ -2211,8 +2252,12 @@ class DemoDoctorRepository:
|
||||
"room_id": "manual-upload",
|
||||
"status": 2,
|
||||
"status_text": "已结束",
|
||||
"recording_status_text": "待上传",
|
||||
"recording_urls_list": [],
|
||||
"recording_status_text": "待上传",
|
||||
"recording_urls_list": [],
|
||||
"transcription_status": "not_started",
|
||||
"transcription_status_text": "未生成文字",
|
||||
"transcript_text": "",
|
||||
"transcript_segments": [],
|
||||
"start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
|
||||
"end_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
|
||||
"duration_text": "0秒",
|
||||
@@ -2509,8 +2554,12 @@ class DemoDoctorRepository:
|
||||
replay = {
|
||||
**record,
|
||||
"status_text": "呼叫中",
|
||||
"recording_status_text": "未录制",
|
||||
"recording_urls_list": [],
|
||||
"recording_status_text": "未录制",
|
||||
"recording_urls_list": [],
|
||||
"transcription_status": "not_started",
|
||||
"transcription_status_text": "未生成文字",
|
||||
"transcript_text": "",
|
||||
"transcript_segments": [],
|
||||
"start_time_text": datetime.now().replace(microsecond=0).isoformat(sep=" "),
|
||||
"end_time_text": "",
|
||||
"duration_text": "—",
|
||||
@@ -2545,7 +2594,7 @@ class DemoDoctorRepository:
|
||||
)
|
||||
return deepcopy(record)
|
||||
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> dict[str, Any]:
|
||||
"""Persist the room identifier on the active demo call."""
|
||||
|
||||
if not room_id.strip():
|
||||
@@ -2572,11 +2621,133 @@ class DemoDoctorRepository:
|
||||
"status_text": "通话中",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"room_id": room_id.strip(),
|
||||
"cloud_recording": {"started": False, "message": "演示模式不录制"},
|
||||
}
|
||||
return {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"room_id": room_id.strip(),
|
||||
"cloud_recording": {"started": False, "message": "演示模式不录制"},
|
||||
}
|
||||
|
||||
def _demo_call_record(
|
||||
self, diagnosis_id: int, call_record_id: int | str
|
||||
) -> dict[str, Any]:
|
||||
record = next(
|
||||
(
|
||||
row
|
||||
for row in self._call_records.get(diagnosis_id, [])
|
||||
if str(row.get("id") or row.get("call_record_id") or "")
|
||||
== str(call_record_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if record is None:
|
||||
raise RepositoryNotFoundError(f"call record {call_record_id} not found")
|
||||
return record
|
||||
|
||||
def start_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
*,
|
||||
language: str = "zh-CN",
|
||||
) -> dict[str, Any]:
|
||||
"""Start an in-memory transcript attached to one demo call record."""
|
||||
|
||||
clean_session = transcription_session_id.strip()
|
||||
if not clean_session:
|
||||
raise ValueError("transcription_session_id is required")
|
||||
with self._lock:
|
||||
self._find_consultation(diagnosis_id)
|
||||
record = self._demo_call_record(diagnosis_id, call_record_id)
|
||||
existing = str(record.get("transcription_session_id") or "")
|
||||
if existing and existing != clean_session:
|
||||
raise ValueError("another transcription session already exists")
|
||||
record.update(
|
||||
{
|
||||
"transcription_session_id": clean_session,
|
||||
"transcription_language": language.strip() or "zh-CN",
|
||||
"transcription_status": "running",
|
||||
"transcription_status_text": "录音转写中",
|
||||
"transcript_text": "",
|
||||
"transcript_segments": [],
|
||||
}
|
||||
)
|
||||
return deepcopy(record)
|
||||
|
||||
def upsert_call_transcript_segments(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
segments: Sequence[Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Idempotently append/update completed segments in demo mode."""
|
||||
|
||||
with self._lock:
|
||||
record = self._demo_call_record(diagnosis_id, call_record_id)
|
||||
if record.get("transcription_session_id") != transcription_session_id:
|
||||
raise ValueError("transcription session does not match the call record")
|
||||
stored = record.setdefault("transcript_segments", [])
|
||||
for incoming in segments:
|
||||
segment_id = str(incoming.get("segment_id") or "").strip()
|
||||
text = str(incoming.get("text") or "").strip()
|
||||
if not segment_id or not text:
|
||||
raise ValueError("transcript segment_id and text are required")
|
||||
normalized = {
|
||||
"segment_id": segment_id,
|
||||
"speaker_role": str(incoming.get("speaker_role") or "unknown"),
|
||||
"speaker_user_id": str(incoming.get("speaker_user_id") or ""),
|
||||
"timestamp": int(incoming.get("timestamp") or 0),
|
||||
"text": text,
|
||||
}
|
||||
current = next(
|
||||
(row for row in stored if row.get("segment_id") == segment_id), None
|
||||
)
|
||||
if current is None:
|
||||
stored.append(normalized)
|
||||
else:
|
||||
current.update(normalized)
|
||||
stored.sort(key=lambda row: (int(row.get("timestamp") or 0), row["segment_id"]))
|
||||
labels = {"doctor": "医生", "patient": "患者", "unknown": "未知说话人"}
|
||||
record["transcript_text"] = "\n".join(
|
||||
f"{labels.get(str(row.get('speaker_role')), '未知说话人')}:{row['text']}"
|
||||
for row in stored
|
||||
)
|
||||
return deepcopy(record)
|
||||
|
||||
def finish_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
expected_segment_count: int,
|
||||
*,
|
||||
status: str = "completed",
|
||||
) -> dict[str, Any]:
|
||||
"""Finalize demo transcript fields on the same call record."""
|
||||
|
||||
clean_status = status.strip().lower()
|
||||
if clean_status not in {"completed", "partial", "failed"}:
|
||||
raise ValueError("transcription status is invalid")
|
||||
with self._lock:
|
||||
record = self._demo_call_record(diagnosis_id, call_record_id)
|
||||
if record.get("transcription_session_id") != transcription_session_id:
|
||||
raise ValueError("transcription session does not match the call record")
|
||||
actual_count = len(record.get("transcript_segments") or [])
|
||||
final_status = clean_status
|
||||
if clean_status == "completed" and actual_count < expected_segment_count:
|
||||
final_status = "partial"
|
||||
record["transcription_status"] = final_status
|
||||
record["transcription_status_text"] = {
|
||||
"completed": "文字已生成",
|
||||
"partial": "文字部分保存",
|
||||
"failed": "文字生成失败",
|
||||
}[final_status]
|
||||
record["transcription_segment_count"] = actual_count
|
||||
record["transcription_finished_at"] = datetime.now().replace(
|
||||
microsecond=0
|
||||
).isoformat(sep=" ")
|
||||
return deepcopy(record)
|
||||
|
||||
def my_self(self) -> Session:
|
||||
"""Compatibility alias for :meth:`get_session`."""
|
||||
|
||||
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from datetime import date
|
||||
from io import BytesIO
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Literal, Protocol
|
||||
@@ -109,6 +110,15 @@ class DoctorRepository(Protocol):
|
||||
) -> str:
|
||||
"""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:
|
||||
"""Return one prescription-library record."""
|
||||
|
||||
@@ -609,8 +619,38 @@ class DoctorRepository(Protocol):
|
||||
def end_call(self, diagnosis_id: int) -> Any:
|
||||
"""End the active diagnosis call."""
|
||||
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
|
||||
"""Bind a TRTC room to the active call."""
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
|
||||
"""Bind a TRTC room to the active call."""
|
||||
|
||||
def start_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
*,
|
||||
language: str = "zh-CN",
|
||||
) -> Any:
|
||||
"""Start a transcript stored on one exact call record."""
|
||||
|
||||
def upsert_call_transcript_segments(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
segments: Sequence[Mapping[str, Any]],
|
||||
) -> Any:
|
||||
"""Idempotently persist completed transcript segments."""
|
||||
|
||||
def finish_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
expected_segment_count: int,
|
||||
*,
|
||||
status: str = "completed",
|
||||
) -> Any:
|
||||
"""Finalize and materialize the transcript text on a call record."""
|
||||
|
||||
def my_self(self) -> Session:
|
||||
"""Compatibility alias for :meth:`get_session`."""
|
||||
@@ -960,6 +1000,34 @@ class RemoteDoctorRepository:
|
||||
)
|
||||
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:
|
||||
"""Ask the server to notify the assigned medical assistant."""
|
||||
|
||||
@@ -2133,32 +2201,157 @@ class RemoteDoctorRepository:
|
||||
ticket.diagnosis_id = diagnosis_id
|
||||
return ticket
|
||||
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
|
||||
"""Create the server-side call record before ringing participants."""
|
||||
|
||||
return self.client.post(
|
||||
"tcm.diagnosis/startCall",
|
||||
{
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_id": patient_id,
|
||||
"call_type": call_type,
|
||||
},
|
||||
)
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> Any:
|
||||
"""Create the server-side call record before ringing participants."""
|
||||
|
||||
payload = self.client.post(
|
||||
"tcm.diagnosis/startCall",
|
||||
{
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_id": patient_id,
|
||||
"call_type": call_type,
|
||||
},
|
||||
)
|
||||
if not isinstance(payload, Mapping):
|
||||
raise ApiProtocolError(
|
||||
"tcm.diagnosis/startCall returned no valid call_record_id object", data=payload
|
||||
)
|
||||
raw_id = next(
|
||||
(
|
||||
payload[key]
|
||||
for key in ("call_record_id", "callRecordId", "id")
|
||||
if key in payload
|
||||
),
|
||||
None,
|
||||
)
|
||||
try:
|
||||
if isinstance(raw_id, bool):
|
||||
raise ValueError
|
||||
call_record_id = int(raw_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ApiProtocolError(
|
||||
"tcm.diagnosis/startCall returned no valid call_record_id",
|
||||
data=dict(payload),
|
||||
) from exc
|
||||
if call_record_id <= 0:
|
||||
raise ApiProtocolError(
|
||||
"tcm.diagnosis/startCall returned no valid call_record_id",
|
||||
data=dict(payload),
|
||||
)
|
||||
return {"call_record_id": call_record_id}
|
||||
|
||||
def end_call(self, diagnosis_id: int) -> Any:
|
||||
"""End the active call/recording associated with a diagnosis."""
|
||||
|
||||
return self.client.post("tcm.diagnosis/endCall", {"diagnosis_id": diagnosis_id})
|
||||
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
|
||||
"""Bind the actual TRTC room to the active call record."""
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> Any:
|
||||
"""Bind the actual TRTC room to the active call record."""
|
||||
|
||||
if not room_id.strip():
|
||||
raise ValueError("room_id is required")
|
||||
return self.client.post(
|
||||
"tcm.diagnosis/bindCallRoom",
|
||||
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
|
||||
)
|
||||
return self.client.post(
|
||||
"tcm.diagnosis/bindCallRoom",
|
||||
{"diagnosis_id": diagnosis_id, "room_id": room_id.strip()},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _transcription_identity(
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
if diagnosis_id <= 0:
|
||||
raise ValueError("diagnosis_id must be positive")
|
||||
if isinstance(call_record_id, bool) or not str(call_record_id).strip():
|
||||
raise ValueError("call_record_id is required")
|
||||
clean_session = transcription_session_id.strip()
|
||||
if not clean_session or len(clean_session) > 128:
|
||||
raise ValueError("transcription_session_id must contain 1 to 128 characters")
|
||||
return {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"call_record_id": call_record_id,
|
||||
"transcription_session_id": clean_session,
|
||||
}
|
||||
|
||||
def start_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
*,
|
||||
language: str = "zh-CN",
|
||||
) -> Any:
|
||||
"""Create the server transcript session for one call record."""
|
||||
|
||||
body = self._transcription_identity(
|
||||
diagnosis_id, call_record_id, transcription_session_id
|
||||
)
|
||||
body["language"] = language.strip()[:32] or "zh-CN"
|
||||
return self.client.post("tcm.diagnosis/startCallTranscription", body)
|
||||
|
||||
def upsert_call_transcript_segments(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
segments: Sequence[Mapping[str, Any]],
|
||||
) -> Any:
|
||||
"""Upsert a bounded batch using each segment_id as the idempotency key."""
|
||||
|
||||
body = self._transcription_identity(
|
||||
diagnosis_id, call_record_id, transcription_session_id
|
||||
)
|
||||
normalized: list[dict[str, Any]] = []
|
||||
if len(segments) > 50:
|
||||
raise ValueError("at most 50 transcript segments may be submitted at once")
|
||||
for segment in segments:
|
||||
segment_id = str(segment.get("segment_id") or "").strip()
|
||||
text = str(segment.get("text") or "").strip()
|
||||
if not segment_id or len(segment_id) > 160:
|
||||
raise ValueError("transcript segment_id is invalid")
|
||||
if not text or len(text) > 4_000:
|
||||
raise ValueError("transcript text is invalid")
|
||||
normalized.append(
|
||||
{
|
||||
"segment_id": segment_id,
|
||||
"speaker_user_id": str(segment.get("speaker_user_id") or "")[:160],
|
||||
"speaker_role": str(segment.get("speaker_role") or "unknown")[:20],
|
||||
"timestamp": max(int(segment.get("timestamp") or 0), 0),
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
if not normalized:
|
||||
raise ValueError("at least one transcript segment is required")
|
||||
body["segments"] = normalized
|
||||
return self.client.post("tcm.diagnosis/upsertCallTranscriptSegments", body)
|
||||
|
||||
def finish_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int | str,
|
||||
transcription_session_id: str,
|
||||
expected_segment_count: int,
|
||||
*,
|
||||
status: str = "completed",
|
||||
) -> Any:
|
||||
"""Finalize a transcript; repeated requests use the same session identity."""
|
||||
|
||||
body = self._transcription_identity(
|
||||
diagnosis_id, call_record_id, transcription_session_id
|
||||
)
|
||||
clean_status = status.strip().lower()
|
||||
if clean_status not in {"completed", "partial", "failed"}:
|
||||
raise ValueError("transcription status is invalid")
|
||||
if expected_segment_count < 0:
|
||||
raise ValueError("expected_segment_count must not be negative")
|
||||
body.update(
|
||||
{
|
||||
"expected_segment_count": expected_segment_count,
|
||||
"status": clean_status,
|
||||
}
|
||||
)
|
||||
return self.client.post("tcm.diagnosis/finishCallTranscription", body)
|
||||
|
||||
# Compatibility aliases keep UI naming independent from endpoint history.
|
||||
def my_self(self) -> Session:
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
@@ -28,12 +31,13 @@ _AUTO_KEYRING = object()
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
owner-only ``0600`` permissions.
|
||||
non-secret API scope. Login passwords use the keyring first; on Windows,
|
||||
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__(
|
||||
@@ -103,7 +107,7 @@ class TokenStore:
|
||||
"""Persist a token plus optional account and API-scope metadata.
|
||||
|
||||
Passing an empty ``account`` explicitly forgets a previously remembered
|
||||
account. Passwords are never accepted or persisted.
|
||||
account and its keyring password.
|
||||
"""
|
||||
|
||||
cleaned = token.strip()
|
||||
@@ -115,6 +119,17 @@ class TokenStore:
|
||||
data = self._read_file()
|
||||
if account is not None:
|
||||
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:
|
||||
data["account"] = account_value
|
||||
else:
|
||||
@@ -137,7 +152,7 @@ class TokenStore:
|
||||
self._write_file(data)
|
||||
|
||||
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:
|
||||
try:
|
||||
@@ -146,11 +161,12 @@ class TokenStore:
|
||||
self._uses_keyring = False
|
||||
data = self._read_file()
|
||||
data.pop("token", None)
|
||||
data.pop("scope", None)
|
||||
if not data.get("account"):
|
||||
data.pop("scope", None)
|
||||
self._write_file(data)
|
||||
|
||||
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")
|
||||
return str(value) if isinstance(value, str) and value else None
|
||||
@@ -167,13 +183,104 @@ class TokenStore:
|
||||
self._write_file(data)
|
||||
|
||||
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("")
|
||||
|
||||
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:
|
||||
"""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:
|
||||
try:
|
||||
self._keyring.delete_password(self.service_name, self.token_name)
|
||||
@@ -225,6 +332,120 @@ class TokenStore:
|
||||
|
||||
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]:
|
||||
try:
|
||||
content = self.path.read_text(encoding="utf-8")
|
||||
@@ -234,10 +455,18 @@ class TokenStore:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
# 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:
|
||||
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:
|
||||
with suppress(OSError):
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
@@ -1411,7 +1412,7 @@ class DiagnosisDialog(QDialog):
|
||||
layout.setContentsMargins(16, 14, 16, 18)
|
||||
layout.setSpacing(12)
|
||||
toolbar = QHBoxLayout()
|
||||
hint = QLabel("每条通话记录可包含多个回放地址,并可追加指定记录的视频。")
|
||||
hint = QLabel("每条通话记录可包含多个回放地址;录音转写完成后可查看本次面诊对话文字。")
|
||||
hint.setObjectName("DiagnosisDialogGuidance")
|
||||
hint.setWordWrap(True)
|
||||
toolbar.addWidget(hint, 1)
|
||||
@@ -1553,7 +1554,7 @@ class DiagnosisDialog(QDialog):
|
||||
("房间号", 180),
|
||||
("时长", 110),
|
||||
("状态", 90),
|
||||
("录制", 100),
|
||||
("录制 / 文字", 120),
|
||||
("操作", 130),
|
||||
),
|
||||
"assign": (
|
||||
@@ -3365,11 +3366,71 @@ class DiagnosisDialog(QDialog):
|
||||
cell.stop()
|
||||
self._inline_recording_cells.clear()
|
||||
|
||||
@staticmethod
|
||||
def _call_transcript_text(row: Any) -> str:
|
||||
raw = first_value(
|
||||
row,
|
||||
"transcript_text",
|
||||
"call_transcript",
|
||||
"conversation_text",
|
||||
"transcript",
|
||||
default="",
|
||||
)
|
||||
if isinstance(raw, Mapping):
|
||||
raw = first_value(raw, "text", "full_text", "content", default="")
|
||||
text = str(raw or "").strip()
|
||||
if text:
|
||||
return text
|
||||
segments = first_value(row, "transcript_segments", "segments", default=[]) or []
|
||||
if isinstance(segments, Sequence) and not isinstance(
|
||||
segments, (str, bytes, bytearray)
|
||||
):
|
||||
labels = {"doctor": "医生", "patient": "患者", "unknown": "未知说话人"}
|
||||
lines: list[str] = []
|
||||
for segment in segments:
|
||||
content = str(first_value(segment, "text", "sourceText", default="") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
role = str(
|
||||
first_value(segment, "speaker_role", "speakerRole", default="unknown")
|
||||
or "unknown"
|
||||
)
|
||||
lines.append(f"{labels.get(role, '未知说话人')}:{content}")
|
||||
return "\n".join(lines)
|
||||
return ""
|
||||
|
||||
def _view_call_transcript(self, call_record_id: int, transcript: str) -> None:
|
||||
dialog = QDialog(self)
|
||||
dialog.setObjectName("DiagnosisCallTranscriptDialog")
|
||||
dialog.setWindowTitle(f"面诊对话文字 · 通话记录 #{call_record_id}")
|
||||
dialog.resize(680, 520)
|
||||
layout = QVBoxLayout(dialog)
|
||||
layout.setContentsMargins(20, 18, 20, 18)
|
||||
layout.setSpacing(12)
|
||||
title = QLabel("录音转写对话")
|
||||
title.setObjectName("DiagnosisDailySectionTitle")
|
||||
layout.addWidget(title)
|
||||
hint = QLabel("以下内容由语音自动转写,仅作为面诊记录辅助,请由医生核对。")
|
||||
hint.setObjectName("DiagnosisDialogGuidance")
|
||||
hint.setWordWrap(True)
|
||||
layout.addWidget(hint)
|
||||
content = QPlainTextEdit(dialog)
|
||||
content.setObjectName("DiagnosisCallTranscriptText")
|
||||
content.setReadOnly(True)
|
||||
content.setPlainText(transcript)
|
||||
layout.addWidget(content, 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 _fill_video(self, rows: Sequence[Any]) -> None:
|
||||
self._stop_inline_recordings()
|
||||
matrix: list[tuple[Any, ...]] = []
|
||||
row_urls: list[list[str]] = []
|
||||
record_ids: list[int] = []
|
||||
transcripts: list[str] = []
|
||||
for row in rows:
|
||||
raw_urls = first_value(row, "recording_urls_list", "recording_urls", default=[]) or []
|
||||
if isinstance(raw_urls, str) or not isinstance(raw_urls, Sequence):
|
||||
@@ -3382,6 +3443,8 @@ class DiagnosisDialog(QDialog):
|
||||
normalized.append(url)
|
||||
row_urls.append(normalized)
|
||||
record_ids.append(_int(first_value(row, "id", "call_record_id"), 0))
|
||||
transcript = self._call_transcript_text(row)
|
||||
transcripts.append(transcript)
|
||||
call_type = _raw_value(row, "call_type_text")
|
||||
if call_type is _MISSING:
|
||||
raw_call_type = _raw_value(row, "call_type")
|
||||
@@ -3398,6 +3461,15 @@ class DiagnosisDialog(QDialog):
|
||||
3: "未接听",
|
||||
4: "已取消",
|
||||
}.get(_int(raw_status, -1), "—")
|
||||
recording_status = first_value(
|
||||
row, "recording_status_text", "record_status_text", "record_status"
|
||||
)
|
||||
transcript_status = first_value(
|
||||
row,
|
||||
"transcription_status_text",
|
||||
"transcript_status_text",
|
||||
default="文字已生成" if transcript else "未生成文字",
|
||||
)
|
||||
matrix.append(
|
||||
(
|
||||
"" if normalized else "暂无录制回放",
|
||||
@@ -3407,9 +3479,7 @@ class DiagnosisDialog(QDialog):
|
||||
first_value(row, "room_id", "room_no"),
|
||||
first_value(row, "duration_text", "duration"),
|
||||
status,
|
||||
first_value(
|
||||
row, "recording_status_text", "record_status_text", "record_status"
|
||||
),
|
||||
f"{display_text(recording_status)}\n{display_text(transcript_status)}",
|
||||
"",
|
||||
)
|
||||
)
|
||||
@@ -3434,6 +3504,20 @@ class DiagnosisDialog(QDialog):
|
||||
if item is not None:
|
||||
item.setText("")
|
||||
call_record_id = record_ids[row_index]
|
||||
actions: list[QPushButton] = []
|
||||
transcript = transcripts[row_index]
|
||||
if transcript and call_record_id > 0:
|
||||
view_transcript = self._action_button(
|
||||
"查看文字", f"查看通话记录 #{call_record_id} 的录音转写"
|
||||
)
|
||||
view_transcript.setObjectName("DiagnosisVideoTranscriptView")
|
||||
view_transcript.setProperty("callRecordId", call_record_id)
|
||||
view_transcript.clicked.connect(
|
||||
lambda _checked=False, selected=call_record_id, text=transcript: self._view_call_transcript(
|
||||
selected, text
|
||||
)
|
||||
)
|
||||
actions.append(view_transcript)
|
||||
if self._editable and self._can_video_upload and call_record_id > 0:
|
||||
upload = self._action_button(
|
||||
"追加回放", f"上传并绑定通话记录 #{call_record_id}"
|
||||
@@ -3452,12 +3536,16 @@ class DiagnosisDialog(QDialog):
|
||||
selected
|
||||
)
|
||||
)
|
||||
upload_host = QWidget()
|
||||
upload_host.setObjectName("DiagnosisVideoRowUploadCell")
|
||||
upload_layout = QVBoxLayout(upload_host)
|
||||
upload_layout.setContentsMargins(0, 0, 0, 0)
|
||||
upload_layout.addWidget(upload, 0, Qt.AlignmentFlag.AlignCenter)
|
||||
table.setCellWidget(row_index, 8, upload_host)
|
||||
actions.append(upload)
|
||||
if actions:
|
||||
action_host = QWidget()
|
||||
action_host.setObjectName("DiagnosisVideoRowUploadCell")
|
||||
action_layout = QVBoxLayout(action_host)
|
||||
action_layout.setContentsMargins(0, 0, 0, 0)
|
||||
action_layout.setSpacing(2)
|
||||
for action in actions:
|
||||
action_layout.addWidget(action, 0, Qt.AlignmentFlag.AlignCenter)
|
||||
table.setCellWidget(row_index, 8, action_host)
|
||||
upload_item = table.item(row_index, 8)
|
||||
if upload_item is not None:
|
||||
upload_item.setText("")
|
||||
|
||||
@@ -16,9 +16,20 @@ from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QDate, QIODevice, QPoint, Qt, QTimer, Signal
|
||||
from PySide6.QtCore import (
|
||||
QBuffer,
|
||||
QByteArray,
|
||||
QDate,
|
||||
QIODevice,
|
||||
QPoint,
|
||||
QRectF,
|
||||
Qt,
|
||||
QTimer,
|
||||
Signal,
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QImage,
|
||||
QMouseEvent,
|
||||
QPageSize,
|
||||
@@ -2437,7 +2448,14 @@ def _status_text(value: Any) -> str:
|
||||
|
||||
|
||||
def render_case_record_html(prescription: Any) -> str:
|
||||
"""Render the immutable diagnosis snapshot stored with a prescription."""
|
||||
"""Render the immutable diagnosis snapshot as the admin A3 case sheet.
|
||||
|
||||
``QTextDocument`` only implements a deliberately small HTML/CSS subset. In
|
||||
particular, CSS grid and ``display: table-row`` silently degrade into a
|
||||
vertical text stream. The admin preview uses a three-column grid, so this
|
||||
version expresses that layout with real HTML tables which Qt renders
|
||||
consistently on screen, printers and PDF devices.
|
||||
"""
|
||||
|
||||
source = _mapping(prescription)
|
||||
snapshot = _mapping(source.get("case_record"))
|
||||
@@ -2452,11 +2470,35 @@ def render_case_record_html(prescription: Any) -> str:
|
||||
value = json.dumps(value, ensure_ascii=False, default=str)
|
||||
return html.escape(display_text(value, default))
|
||||
|
||||
def value(*keys: str, default: Any = None) -> Any:
|
||||
for key in keys:
|
||||
translated = case.get(f"{key}_text")
|
||||
if translated not in (None, ""):
|
||||
return translated
|
||||
candidate = case.get(key)
|
||||
if candidate not in (None, ""):
|
||||
return candidate
|
||||
return default
|
||||
|
||||
def present_date(raw: Any) -> Any:
|
||||
return None if raw in (None, "", 0, "0", "0000-00-00") else raw
|
||||
|
||||
gender = case.get("gender", source.get("gender"))
|
||||
gender_label = "男" if gender in (1, "1") else "女" if gender in (0, "0", 2, "2") else "—"
|
||||
marital = {0: "未婚", 1: "已婚", 2: "离异"}.get(_int(case.get("marital_status"), -1), "—")
|
||||
gender_label = (
|
||||
case.get("gender_text")
|
||||
or case.get("gender_desc")
|
||||
or ("男" if gender in (1, "1") else "女" if gender in (0, "0", 2, "2") else "—")
|
||||
)
|
||||
marital = case.get("marital_status_text") or {0: "未婚", 1: "已婚", 2: "离异"}.get(
|
||||
_int(case.get("marital_status"), -1), "—"
|
||||
)
|
||||
age = case.get("age") or source.get("age")
|
||||
age_text = f"{age}岁" if age not in (None, "") and not str(age).endswith("岁") else age
|
||||
diabetes_history = value("diabetes_discovery_year")
|
||||
if diabetes_history not in (None, "") and str(diabetes_history).strip().isdigit():
|
||||
diabetes_history = f"{str(diabetes_history).strip()}年"
|
||||
blood_pressure = "—"
|
||||
if case.get("systolic_pressure") not in (None, "") or case.get("diastolic_pressure") not in (
|
||||
if case.get("systolic_pressure") not in (None, "") and case.get("diastolic_pressure") not in (
|
||||
None,
|
||||
"",
|
||||
):
|
||||
@@ -2465,102 +2507,173 @@ def render_case_record_html(prescription: Any) -> str:
|
||||
f"{display_text(case.get('diastolic_pressure'))} mmHg"
|
||||
)
|
||||
|
||||
def section(title: str, fields: Sequence[tuple[str, Any]]) -> str:
|
||||
rows = "".join(
|
||||
f'<div class="item"><span class="label">{html.escape(label)}</span>'
|
||||
f'<span class="value">{esc(value)}</span></div>'
|
||||
for label, value in fields
|
||||
diagnosis_type = value("diagnosis_type_desc", "diagnosis_type")
|
||||
diagnosis_type = _DIAGNOSIS_TYPE_LABELS.get(str(diagnosis_type or "").strip(), diagnosis_type)
|
||||
|
||||
def field_cell(label: str, field_value: Any, *, colspan: int = 1) -> str:
|
||||
return (
|
||||
f'<td class="cr-item" colspan="{colspan}">'
|
||||
'<table class="cr-kv" width="100%" cellspacing="0" cellpadding="0"><tr>'
|
||||
f'<td class="cr-label">{html.escape(label)}</td>'
|
||||
f'<td class="cr-value">{esc(field_value)}</td>'
|
||||
"</tr></table></td>"
|
||||
)
|
||||
|
||||
def section(
|
||||
title: str,
|
||||
fields: Sequence[tuple[str, Any, bool]],
|
||||
) -> str:
|
||||
rows: list[str] = []
|
||||
pending: list[str] = []
|
||||
for label, field_value, full_width in fields:
|
||||
if full_width:
|
||||
if pending:
|
||||
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
|
||||
rows.append(f'<tr>{"".join(pending)}</tr>')
|
||||
pending = []
|
||||
rows.append(f'<tr>{field_cell(label, field_value, colspan=3)}</tr>')
|
||||
continue
|
||||
pending.append(field_cell(label, field_value))
|
||||
if len(pending) == 3:
|
||||
rows.append(f'<tr>{"".join(pending)}</tr>')
|
||||
pending = []
|
||||
if pending:
|
||||
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
|
||||
rows.append(f'<tr>{"".join(pending)}</tr>')
|
||||
return (
|
||||
'<table class="cr-section" width="100%" cellspacing="0" cellpadding="0">'
|
||||
f'<tr><td class="cr-section-title">{html.escape(title)}</td></tr>'
|
||||
'<tr><td><table class="cr-grid" width="100%" cellspacing="0" cellpadding="0">'
|
||||
f'{"".join(rows)}</table></td></tr>'
|
||||
'<tr><td class="cr-rule"></td></tr></table>'
|
||||
)
|
||||
return f'<section><h2>{html.escape(title)}</h2><div class="grid">{rows}</div></section>'
|
||||
|
||||
sections = [
|
||||
section(
|
||||
"基本信息",
|
||||
(
|
||||
("诊单ID", case.get("id") or case.get("diagnosis_id") or source.get("diagnosis_id")),
|
||||
("姓名", case.get("patient_name") or source.get("patient_name")),
|
||||
("身份证号", case.get("id_card")),
|
||||
("手机号", case.get("phone") or source.get("phone")),
|
||||
("性别", gender_label),
|
||||
("年龄", case.get("age") or source.get("age")),
|
||||
("婚姻状态", marital),
|
||||
("身高", f"{case.get('height')} cm" if case.get("height") not in (None, "") else None),
|
||||
("体重", f"{case.get('weight')} kg" if case.get("weight") not in (None, "") else None),
|
||||
("地区", case.get("region")),
|
||||
(
|
||||
"诊单ID",
|
||||
case.get("id") or case.get("diagnosis_id") or source.get("diagnosis_id"),
|
||||
False,
|
||||
),
|
||||
("姓名", case.get("patient_name") or source.get("patient_name"), False),
|
||||
("身份证号", case.get("id_card"), False),
|
||||
("手机号", case.get("phone") or source.get("phone"), False),
|
||||
("性别", gender_label, False),
|
||||
("年龄", age_text, False),
|
||||
("婚姻状态", marital, False),
|
||||
(
|
||||
"身高",
|
||||
f"{case.get('height')} cm" if case.get("height") not in (None, "") else None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"体重",
|
||||
f"{case.get('weight')} kg" if case.get("weight") not in (None, "") else None,
|
||||
False,
|
||||
),
|
||||
("地区", case.get("region"), False),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"生命体征",
|
||||
(
|
||||
("血压", blood_pressure),
|
||||
("血压", blood_pressure, False),
|
||||
(
|
||||
"空腹血糖",
|
||||
f"{case.get('fasting_blood_sugar')} mmol/L"
|
||||
if case.get("fasting_blood_sugar") not in (None, "")
|
||||
else None,
|
||||
False,
|
||||
),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"主诉与诊断",
|
||||
"主诉",
|
||||
(
|
||||
("诊断日期", case.get("diagnosis_date")),
|
||||
("诊断类型", case.get("diagnosis_type_desc") or case.get("diagnosis_type")),
|
||||
("证型", case.get("syndrome_type_desc") or case.get("syndrome_type")),
|
||||
("糖尿病期数", case.get("diabetes_type_desc") or case.get("diabetes_type")),
|
||||
("糖尿病病史", case.get("diabetes_discovery_year")),
|
||||
("当地医院诊断", case.get("local_hospital_diagnosis")),
|
||||
("当地就诊医院", case.get("local_hospital_name")),
|
||||
("症状补充", case.get("symptoms") or case.get("clinical_diagnosis")),
|
||||
("诊断日期", present_date(value("diagnosis_date")), False),
|
||||
("诊断类型", diagnosis_type, False),
|
||||
("证型", value("syndrome_type_desc", "syndrome_type"), False),
|
||||
("糖尿病期数", value("diabetes_type_desc", "diabetes_type"), False),
|
||||
("发现糖尿病患病史", diabetes_history, False),
|
||||
("当地医院诊断", value("local_hospital_diagnosis"), True),
|
||||
("当地就诊医院", value("local_hospital_name"), True),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"现病史",
|
||||
(
|
||||
("口腔感觉", case.get("appetite")),
|
||||
("每日饮水量", case.get("water_intake")),
|
||||
("体重变化", case.get("weight_change")),
|
||||
("脂肪肝程度", case.get("fatty_liver_degree")),
|
||||
("饮食情况", case.get("diet_condition")),
|
||||
("肢体感觉", case.get("body_feeling")),
|
||||
("睡眠情况", case.get("sleep_condition")),
|
||||
("眼睛情况", case.get("eye_condition")),
|
||||
("头部感觉", case.get("head_feeling")),
|
||||
("出汗情况", case.get("sweat_condition")),
|
||||
("皮肤情况", case.get("skin_condition")),
|
||||
("小便情况", case.get("urine_condition")),
|
||||
("大便情况", case.get("stool_condition")),
|
||||
("腰肾情况", case.get("kidney_condition")),
|
||||
("口腔感觉", value("appetite"), True),
|
||||
("每日饮水量", value("water_intake"), True),
|
||||
("体重变化", value("weight_change"), True),
|
||||
("脂肪肝程度", value("fatty_liver_degree"), True),
|
||||
("饮食情况", value("diet_condition"), True),
|
||||
("肢体感觉", value("body_feeling"), True),
|
||||
("睡眠情况", value("sleep_condition"), True),
|
||||
("眼睛情况", value("eye_condition"), True),
|
||||
("头部感觉", value("head_feeling"), True),
|
||||
("出汗情况", value("sweat_condition"), True),
|
||||
("皮肤情况", value("skin_condition"), True),
|
||||
("小便情况", value("urine_condition"), True),
|
||||
("大便情况", value("stool_condition"), True),
|
||||
("腰肾情况", value("kidney_condition"), True),
|
||||
("其他补充", case.get("symptoms") or case.get("clinical_diagnosis"), True),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"病史与中医诊断",
|
||||
"既往史",
|
||||
(
|
||||
("既往史", case.get("past_history")),
|
||||
("外伤史", "有" if _bool(case.get("trauma_history")) else "无"),
|
||||
("手术史", "有" if _bool(case.get("surgery_history")) else "无"),
|
||||
("过敏史", "有" if _bool(case.get("allergy_history")) else "无"),
|
||||
("家族病史", "有" if _bool(case.get("family_history")) else "无"),
|
||||
("妊娠哺乳史", "有" if _bool(case.get("pregnancy_history")) else "无"),
|
||||
("舌象", case.get("tongue_coating") or case.get("tongue")),
|
||||
("脉象", case.get("pulse")),
|
||||
("治则", case.get("treatment_principle")),
|
||||
("医嘱", case.get("doctor_advice")),
|
||||
("", value("past_history"), True),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"其他病史",
|
||||
(
|
||||
("外伤史", "有" if _bool(case.get("trauma_history")) else "无", False),
|
||||
("手术史", "有" if _bool(case.get("surgery_history")) else "无", False),
|
||||
("过敏史", "有" if _bool(case.get("allergy_history")) else "无", False),
|
||||
("家族病史", "有" if _bool(case.get("family_history")) else "无", False),
|
||||
(
|
||||
"妊娠哺乳史",
|
||||
"有" if _bool(case.get("pregnancy_history")) else "无",
|
||||
False,
|
||||
),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"诊断信息",
|
||||
(
|
||||
("舌象", case.get("tongue_coating") or case.get("tongue"), True),
|
||||
("脉象", case.get("pulse"), True),
|
||||
("治则", case.get("treatment_principle"), True),
|
||||
("医嘱", case.get("doctor_advice"), True),
|
||||
),
|
||||
),
|
||||
]
|
||||
return f"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body {{ font-family:"Microsoft YaHei","PingFang SC",sans-serif; color:#17231d; margin:18px; }}
|
||||
h1 {{ text-align:center; font-size:22px; margin:0 0 18px; }}
|
||||
section {{ margin:0 0 16px; border:1px solid #bdc7c2; }}
|
||||
h2 {{ font-size:15px; margin:0; padding:8px 10px; background:#edf2ef; }}
|
||||
.grid {{ display:table; width:100%; }}
|
||||
.item {{ display:table-row; }}
|
||||
.label,.value {{ display:table-cell; padding:7px 10px; border-top:1px solid #d7dfdb; }}
|
||||
.label {{ width:120px; color:#65736c; font-weight:600; }}
|
||||
.value {{ line-height:1.6; }}
|
||||
</style></head><body><h1>详细病历</h1>{''.join(sections)}</body></html>
|
||||
body {{ margin:0; padding:12px; background:#eef1f5; color:#303133;
|
||||
font-family:"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;
|
||||
font-size:13px; }}
|
||||
.paper {{ width:100%; margin:0 auto; background:#ffffff; border:1px solid #dcdfe6; }}
|
||||
.paper-content {{ padding:26px 30px 34px; vertical-align:top; }}
|
||||
h1 {{ text-align:center; font-size:20px; font-weight:600; margin:0;
|
||||
padding:0 0 14px; border-bottom:2px solid #303133; }}
|
||||
.cr-section {{ margin-top:14px; border-collapse:collapse; }}
|
||||
.cr-section-title {{ padding:0 0 8px; color:#303133; font-size:15px; font-weight:600; }}
|
||||
.cr-grid {{ table-layout:fixed; border-collapse:collapse; }}
|
||||
.cr-item {{ width:33.33%; padding:4px 10px 5px 0; vertical-align:top; }}
|
||||
.cr-item.empty {{ padding:0; }}
|
||||
.cr-kv {{ border-collapse:collapse; table-layout:fixed; }}
|
||||
.cr-label {{ width:88px; padding:0 7px 0 0; color:#909399; white-space:nowrap;
|
||||
vertical-align:top; line-height:1.65; }}
|
||||
.cr-value {{ padding:0; color:#303133; vertical-align:top; line-height:1.65; }}
|
||||
.cr-rule {{ height:1px; border-bottom:1px solid #eeeeee; }}
|
||||
</style></head><body>
|
||||
<table class="paper" width="100%" cellspacing="0" cellpadding="0"><tr>
|
||||
<td class="paper-content"><h1>甄养堂 详细病历</h1>{''.join(sections)}</td>
|
||||
</tr></table></body></html>
|
||||
"""
|
||||
|
||||
|
||||
@@ -2568,18 +2681,20 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
|
||||
"""Build the pharmacy-copy A4 slip used by preview, print and PDF export."""
|
||||
|
||||
source = _mapping(prescription)
|
||||
paper_width = "100%" if print_layout else "820px"
|
||||
paper_dimensions = 'width="100%"' if print_layout else 'width="820" height="1050"'
|
||||
body_padding = "0" if print_layout else "10px"
|
||||
body_background = "#ffffff" if print_layout else "#eef1f5"
|
||||
# consumer/prescription/index.vue uses a strict 210 x 297 mm sheet. At
|
||||
# Qt's 96 logical DPI this is 794 x 1123 px; keeping the HTML width fixed
|
||||
# prevents QTextDocument from stretching the medicine columns with the
|
||||
# containing dialog.
|
||||
paper_width = "100%" if print_layout else "794px"
|
||||
paper_dimensions = 'width="100%"' if print_layout else 'width="794" height="1123"'
|
||||
body_padding = "0" if print_layout else "12px"
|
||||
body_background = "#ffffff" if print_layout else "#f5f6f8"
|
||||
paper_border = "0" if print_layout else "1px solid #d6d6d6"
|
||||
base_font_size = "10px" if print_layout else "13px"
|
||||
notice_font_size = "8.5px" if print_layout else "11px"
|
||||
notice_font_size = "8.5px" if print_layout else "12px"
|
||||
rp_font_size = "12px" if print_layout else "16px"
|
||||
section_font_size = "9px" if print_layout else "12px"
|
||||
watermark_height = "58px" if print_layout else "76px"
|
||||
watermark_font_size = "41px" if print_layout else "54px"
|
||||
bottom_height = "44px" if print_layout else "58px"
|
||||
bottom_height = "44px" if print_layout else "70px"
|
||||
herbs = _herb_rows(prescription)
|
||||
dose_count = max(1, _int(source.get("dose_count"), 1))
|
||||
main = [row for row in herbs if _formula(row.get("formula_type")) == "主方"]
|
||||
@@ -2662,13 +2777,15 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
|
||||
if not rows:
|
||||
return ""
|
||||
output = [
|
||||
f'<tr><td class="rp-indent"></td><td class="section {kind}" colspan="4">'
|
||||
f'<tr><td class="rp-indent"></td><td class="section {kind}" colspan="5">'
|
||||
f"{html.escape(title)}</td></tr>"
|
||||
]
|
||||
for index in range(0, len(rows), 2):
|
||||
pair = rows[index : index + 2]
|
||||
cells: list[str] = []
|
||||
for row in pair:
|
||||
for pair_index, row in enumerate(pair):
|
||||
if pair_index == 1:
|
||||
cells.append('<td class="rp-gap"></td>')
|
||||
dosage = number_text(row.get("dosage"))
|
||||
total = number_text(_float(row.get("dosage")) * dose_count)
|
||||
cells.extend(
|
||||
@@ -2678,7 +2795,13 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
|
||||
)
|
||||
)
|
||||
if len(pair) == 1:
|
||||
cells.extend(('<td class="herb-name"> </td>', '<td class="herb-total"></td>'))
|
||||
cells.extend(
|
||||
(
|
||||
'<td class="rp-gap"></td>',
|
||||
'<td class="herb-name"> </td>',
|
||||
'<td class="herb-total"></td>',
|
||||
)
|
||||
)
|
||||
output.append(f'<tr><td class="rp-indent"></td>{"".join(cells)}</tr>')
|
||||
return "".join(output)
|
||||
|
||||
@@ -2740,7 +2863,6 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
|
||||
pharmacy_remark = (
|
||||
source.get("pharmacy_remark")
|
||||
or source.get("pharmacy_note")
|
||||
or source.get("remark_extra")
|
||||
)
|
||||
|
||||
explicit_out = (
|
||||
@@ -2783,7 +2905,7 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
|
||||
herb_html = herb_group(main, "主方", "main") + herb_group(aux, "辅方", "aux")
|
||||
if not herb_html:
|
||||
herb_html = (
|
||||
'<tr><td class="rp-indent"></td><td class="empty-herbs" colspan="4">'
|
||||
'<tr><td class="rp-indent"></td><td class="empty-herbs" colspan="5">'
|
||||
"暂无药材明细</td></tr>"
|
||||
)
|
||||
text_rows = [f"<p>主方服法:{esc(main_usage_text)}</p>"]
|
||||
@@ -2815,55 +2937,58 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
|
||||
return f"""
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#151515;
|
||||
font-family:"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;
|
||||
font-size:{base_font_size}; }}
|
||||
body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1f1f1f;
|
||||
font-family:"Microsoft YaHei","PingFang SC",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
font-size:{base_font_size}; line-height:1.5; }}
|
||||
.paper {{ width:{paper_width}; margin:0 auto; background:#ffffff;
|
||||
border:{paper_border}; border-collapse:separate; border-spacing:0; }}
|
||||
border:{paper_border}; border-collapse:separate; border-spacing:0;
|
||||
box-shadow:{"none" if print_layout else "0 0 0 1px #d6d6d6"}; }}
|
||||
.paper-content {{ padding:8mm 10mm; vertical-align:top; }}
|
||||
.notice {{ width:100%; border:1px solid #e5e7eb; border-collapse:collapse;
|
||||
table-layout:fixed; background:#f3f4f6; margin:0 0 6px;
|
||||
font-size:{notice_font_size}; }}
|
||||
font-size:{notice_font_size}; color:#1f1f1f; }}
|
||||
.notice td {{ border:0; padding:6px 10px; }}
|
||||
.notice-text {{ width:49%; }}
|
||||
.notice-meta {{ width:51%; text-align:right; }}
|
||||
.notice-meta {{ width:51%; text-align:right; white-space:nowrap; }}
|
||||
.info {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
|
||||
.info td {{ border:1px solid #c8c8c8; padding:6px 10px; vertical-align:middle; }}
|
||||
.info td {{ border:1px solid #c8c8c8; padding:6px 10px; vertical-align:middle; font-size:13px; }}
|
||||
.info .full {{ border-top:0; }}
|
||||
.key {{ white-space:nowrap; }}
|
||||
.rp {{ width:100%; border-collapse:collapse; table-layout:fixed;
|
||||
border-left:1px solid #c8c8c8; border-right:1px solid #c8c8c8; }}
|
||||
.rp td {{ border:0; padding:3px 6px; vertical-align:middle; }}
|
||||
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding-top:9px; padding-bottom:5px; }}
|
||||
.rp-indent {{ width:34px; }}
|
||||
.rp-mark {{ width:34px; font-size:{rp_font_size}; font-weight:700; }}
|
||||
.key {{ white-space:nowrap; color:#1f1f1f; }}
|
||||
.rp-frame {{ width:100%; border-collapse:collapse; table-layout:fixed;
|
||||
border-left:1px solid #c8c8c8; border-right:1px solid #c8c8c8; }}
|
||||
.rp-padding {{ padding:8px 10px 16px; }}
|
||||
.rp {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
|
||||
.rp td {{ border:0; padding:3px 0; vertical-align:middle; }}
|
||||
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding-top:4px; padding-bottom:4px; }}
|
||||
.rp-indent {{ width:44px; }}
|
||||
.rp-mark {{ width:44px; padding-right:8px !important;
|
||||
font-size:{rp_font_size}; font-weight:700; color:#1f1f1f; }}
|
||||
.drug-head {{ color:#1f1f1f; }}
|
||||
.total-head, .herb-total {{ width:62px; text-align:right; white-space:nowrap; }}
|
||||
.total-head, .herb-total {{ width:64px; text-align:right; white-space:nowrap;
|
||||
font-variant-numeric:tabular-nums; }}
|
||||
.rp-gap {{ width:24px; padding:0 !important; }}
|
||||
.section {{ padding-top:7px !important; padding-bottom:1px !important;
|
||||
font-size:{section_font_size}; font-weight:600; }}
|
||||
.section.main {{ color:#409eff; }}
|
||||
.section.aux {{ color:#e6a23c; }}
|
||||
.herb-name {{ line-height:1.65; white-space:nowrap; }}
|
||||
.herb-name {{ line-height:1.85; white-space:nowrap; color:#1f1f1f; }}
|
||||
.empty-herbs {{ color:#8a8f98; padding:18px 6px !important; text-align:center; }}
|
||||
.watermark {{ height:{watermark_height}; color:#eeeeee; text-align:center;
|
||||
font-size:{watermark_font_size};
|
||||
font-weight:700; letter-spacing:18px; vertical-align:middle !important; }}
|
||||
.rx-text {{ border:1px solid #c8c8c8; border-top:0; padding:9px 12px; line-height:1.65; }}
|
||||
.rx-text p {{ margin:1px 0; padding:0; }}
|
||||
.rx-text {{ border:1px solid #c8c8c8; border-top:0; padding:10px 12px; line-height:1.85; font-size:13px; }}
|
||||
.rx-text p {{ margin:0; padding:0; }}
|
||||
.warning {{ color:#d72424; font-weight:600; }}
|
||||
.bottom {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
|
||||
.bottom td {{ border:1px solid #c8c8c8; border-top:0; padding:7px 10px;
|
||||
height:{bottom_height}; vertical-align:middle; }}
|
||||
.bottom .doctor {{ width:22%; vertical-align:top; }}
|
||||
.doctor-title {{ display:block; margin-bottom:4px; }}
|
||||
.doctor-name {{ display:block; margin-top:8px; }}
|
||||
.signature {{ max-width:130px; max-height:42px; vertical-align:middle; }}
|
||||
.meta-key {{ white-space:nowrap; }}
|
||||
.audit {{ width:{paper_width}; margin:8px auto 0; padding:8px 4px; color:#7b8492;
|
||||
border-top:1px dashed #cfd5dd; font-size:12px; line-height:1.7; }}
|
||||
.bottom td {{ border:1px solid #c8c8c8; border-top:0; padding:6px 10px;
|
||||
height:{bottom_height}; vertical-align:middle; font-size:12px; color:#1f1f1f; }}
|
||||
.bottom .doctor {{ width:28%; vertical-align:top; }}
|
||||
.doctor-title {{ display:block; margin-bottom:4px; font-size:13px; }}
|
||||
.doctor-name {{ display:block; margin-top:8px; font-size:13px; }}
|
||||
.signature {{ max-width:110px; max-height:40px; vertical-align:middle; object-fit:contain; }}
|
||||
.meta-key {{ white-space:nowrap; margin-right:6px; }}
|
||||
.audit {{ width:{paper_width}; margin:12px auto 0; padding:8px 12px; color:#6b7280;
|
||||
border:1px dashed #d4d4d4; background:#fafafa; border-radius:4px;
|
||||
font-size:12px; line-height:1.6; }}
|
||||
</style></head><body>
|
||||
<table class="paper" {paper_dimensions}><tr><td class="paper-content" valign="top">
|
||||
<table width="100%"><tr><td height="14"></td></tr></table>
|
||||
<table align="center" class="paper" {paper_dimensions}><tr><td class="paper-content" valign="top">
|
||||
<table class="notice" width="100%"><tr>
|
||||
<td class="notice-text">服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点</td>
|
||||
<td class="notice-meta">日期:{esc(date_text())} 编号:{esc(serial)}</td>
|
||||
@@ -2876,13 +3001,15 @@ body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1
|
||||
<tr><td class="full" colspan="4"><span class="key">收件信息</span> {esc(recipient_text)}</td></tr>
|
||||
<tr><td class="full" colspan="4"><span class="key">临床诊断</span> {esc(source.get("clinical_diagnosis"))}</td></tr>
|
||||
</table>
|
||||
<table class="rp-frame" title="药房联" width="100%"><tr><td class="rp-padding">
|
||||
<table class="rp" width="100%">
|
||||
<col width="34"/><col/><col width="62"/><col/><col width="62"/>
|
||||
<col width="44"/><col/><col width="64"/><col width="24"/><col/><col width="64"/>
|
||||
<tr class="rp-head"><td class="rp-mark">Rp.</td><td class="drug-head">用药 (单剂)</td>
|
||||
<td class="total-head">总量</td><td class="drug-head">用药 (单剂)</td><td class="total-head">总量</td></tr>
|
||||
<td class="total-head">总量</td><td class="rp-gap"></td>
|
||||
<td class="drug-head">用药 (单剂)</td><td class="total-head">总量</td></tr>
|
||||
{herb_html}
|
||||
<tr><td class="rp-indent"></td><td class="watermark" colspan="4">药房联</td></tr>
|
||||
</table>
|
||||
</td></tr></table>
|
||||
<div class="rx-text">{"".join(text_rows)}</div>
|
||||
<table class="bottom" width="100%"><tr>
|
||||
<td class="doctor"><span class="doctor-title">医师</span>{signature_html}</td>
|
||||
@@ -2896,6 +3023,57 @@ body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1
|
||||
"""
|
||||
|
||||
|
||||
class _PrescriptionPaperPreview(QTextBrowser):
|
||||
"""A4 prescription preview with the consumer page's floating watermark.
|
||||
|
||||
Qt rich text cannot paint CSS absolute-positioned or transformed elements,
|
||||
so placing the watermark in the HTML makes it consume a table row and
|
||||
deforms the paper. Paint it over the viewport at the corresponding
|
||||
document position instead.
|
||||
"""
|
||||
|
||||
_PAPER_WIDTH = 794
|
||||
|
||||
def __init__(self, prescription: Any, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
herbs = _herb_rows(prescription)
|
||||
main_count = sum(
|
||||
1 for row in herbs if _formula(row.get("formula_type")) == "主方"
|
||||
)
|
||||
aux_count = len(herbs) - main_count
|
||||
medicine_rows = (main_count + 1) // 2 + (aux_count + 1) // 2
|
||||
section_count = int(main_count > 0) + int(aux_count > 0)
|
||||
rp_height = 65 + medicine_rows * 30 + section_count * 22
|
||||
self._watermark_document_y = 150 + rp_height // 2
|
||||
|
||||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||||
super().paintEvent(event)
|
||||
viewport = self.viewport()
|
||||
center_y = self._watermark_document_y - self.verticalScrollBar().value()
|
||||
if center_y < -90 or center_y > viewport.height() + 90:
|
||||
return
|
||||
paper_width = min(self._PAPER_WIDTH, viewport.width())
|
||||
paper_left = max(0.0, (viewport.width() - paper_width) / 2)
|
||||
painter = QPainter(viewport)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.translate(paper_left + paper_width / 2, center_y)
|
||||
painter.rotate(-22)
|
||||
painter.setPen(QColor(31, 31, 31, 15))
|
||||
font = QFont(
|
||||
"Microsoft YaHei UI",
|
||||
-1,
|
||||
QFont.Weight.Bold,
|
||||
)
|
||||
font.setPixelSize(84)
|
||||
painter.setFont(font)
|
||||
painter.drawText(
|
||||
QRectF(-230, -70, 460, 140),
|
||||
Qt.AlignmentFlag.AlignCenter,
|
||||
"药房联",
|
||||
)
|
||||
painter.end()
|
||||
|
||||
|
||||
class PrescriptionDetailDialog(QDialog):
|
||||
diagnosis_requested = Signal(int)
|
||||
orders_requested = Signal(int)
|
||||
@@ -2937,12 +3115,12 @@ class PrescriptionDetailDialog(QDialog):
|
||||
pdf_button.clicked.connect(self.choose_pdf_path)
|
||||
actions.addWidget(pdf_button)
|
||||
root.addLayout(actions)
|
||||
self.preview = QTextBrowser()
|
||||
self.preview = _PrescriptionPaperPreview(prescription)
|
||||
self.preview.setObjectName("PrescriptionPaperPreview")
|
||||
self.preview.setOpenExternalLinks(False)
|
||||
self.preview.setStyleSheet(
|
||||
"QTextBrowser#PrescriptionPaperPreview {"
|
||||
"background-color:#EEF1F5; border:1px solid #D8DEE8; padding:0;}"
|
||||
"background-color:#F5F6F8; border:1px solid #D8DEE8; padding:0;}"
|
||||
)
|
||||
self.preview.setDocument(self.document)
|
||||
self.tabs = QTabWidget()
|
||||
@@ -2952,8 +3130,15 @@ class PrescriptionDetailDialog(QDialog):
|
||||
self.case_preview: QTextBrowser | None = None
|
||||
if case_record:
|
||||
self.case_document = QTextDocument(self)
|
||||
self.case_document.setDocumentMargin(0)
|
||||
self.case_document.setHtml(render_case_record_html(prescription))
|
||||
self.case_preview = QTextBrowser()
|
||||
self.case_preview.setObjectName("CaseRecordPaperPreview")
|
||||
self.case_preview.setOpenExternalLinks(False)
|
||||
self.case_preview.setStyleSheet(
|
||||
"QTextBrowser#CaseRecordPaperPreview {"
|
||||
"background-color:#EEF1F5; border:1px solid #D8DEE8; padding:0;}"
|
||||
)
|
||||
self.case_preview.setDocument(self.case_document)
|
||||
case_index = self.tabs.addTab(self.case_preview, "详细病历")
|
||||
if initial_tab == "case":
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QPoint, QSettings, Qt, QTimer, Signal
|
||||
@@ -70,8 +71,9 @@ class LoginWindow(QMainWindow):
|
||||
``login_succeeded`` emits a dictionary containing ``user``, ``session``,
|
||||
``repository`` and ``demo_mode``. Keeping the selected repository in the
|
||||
payload lets the composition root construct the shell without guessing.
|
||||
``remember_account`` is captured before the worker starts and is forwarded
|
||||
to the repository so every account metadata store follows the same choice.
|
||||
The remember-password choice is captured before the worker starts. The
|
||||
repository receives the corresponding account-metadata flag, while this
|
||||
window stores the password only after authentication succeeds.
|
||||
"""
|
||||
|
||||
login_succeeded = Signal(object)
|
||||
@@ -87,6 +89,7 @@ class LoginWindow(QMainWindow):
|
||||
config: Any | None = None,
|
||||
demo_repository: Any | None = None,
|
||||
settings: QSettings | None = None,
|
||||
credential_store: Any | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -96,9 +99,12 @@ class LoginWindow(QMainWindow):
|
||||
demo_repository = getattr(config, "demo_repository", None)
|
||||
self.demo_repository = demo_repository
|
||||
self.settings = settings or QSettings("ZhenYangTang", "DoctorWorkstation")
|
||||
self.credential_store = credential_store
|
||||
self.active_repository = repository
|
||||
self.authenticated_user: Any = None
|
||||
self._loading = False
|
||||
self._restored_account = ""
|
||||
self._restored_scope = ""
|
||||
|
||||
self.setWindowTitle("甄养堂 · 医生工作站")
|
||||
self.setMinimumSize(860, 590)
|
||||
@@ -214,7 +220,7 @@ class LoginWindow(QMainWindow):
|
||||
layout.addLayout(brand_row)
|
||||
layout.addStretch(2)
|
||||
|
||||
eyebrow = QLabel("DOCTOR WORKSTATION")
|
||||
eyebrow = QLabel("医生工作站")
|
||||
eyebrow.setStyleSheet(
|
||||
"color:#4F63D9; font-size:11px; font-weight:700; letter-spacing:1px;"
|
||||
)
|
||||
@@ -300,8 +306,8 @@ class LoginWindow(QMainWindow):
|
||||
card_layout.addLayout(password_row)
|
||||
|
||||
choices = QHBoxLayout()
|
||||
self.remember_check = _VisibleCheckBox("记住账号")
|
||||
self.remember_check.setToolTip("仅保存账号,不保存密码")
|
||||
self.remember_check = _VisibleCheckBox("记住密码")
|
||||
self.remember_check.setToolTip("密码使用系统安全凭据或 Windows DPAPI 加密,不保存明文")
|
||||
choices.addWidget(self.remember_check)
|
||||
choices.addStretch(1)
|
||||
self.demo_check = _VisibleCheckBox("演示模式")
|
||||
@@ -394,8 +400,6 @@ class LoginWindow(QMainWindow):
|
||||
def _restore_settings(self) -> None:
|
||||
configured_account = getattr(self.config, "remembered_account", "")
|
||||
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(
|
||||
self.config, "api_base_url", ""
|
||||
)
|
||||
@@ -418,11 +422,49 @@ class LoginWindow(QMainWindow):
|
||||
self.allow_self_signed_check.setChecked(not verify_ssl)
|
||||
if self.demo_repository is not None and bool(getattr(self.config, "demo_mode", False)):
|
||||
self.demo_check.setChecked(True)
|
||||
self.account_edit.setText(remembered)
|
||||
self.restore_remembered_credentials()
|
||||
if remembered:
|
||||
self.password_edit.setFocus()
|
||||
else:
|
||||
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:
|
||||
self.password_edit.setEchoMode(
|
||||
QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password
|
||||
@@ -557,6 +599,7 @@ class LoginWindow(QMainWindow):
|
||||
payload,
|
||||
account,
|
||||
remember_account,
|
||||
password,
|
||||
),
|
||||
on_error=self._on_login_error,
|
||||
on_finished=lambda: self._set_loading(False),
|
||||
@@ -604,15 +647,39 @@ class LoginWindow(QMainWindow):
|
||||
payload: dict[str, Any],
|
||||
account: str,
|
||||
remember_account: bool | None = None,
|
||||
password: str | None = None,
|
||||
) -> None:
|
||||
if remember_account is None:
|
||||
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:
|
||||
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:
|
||||
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._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.authenticated_user = payload.get("user")
|
||||
self.login_succeeded.emit(payload)
|
||||
|
||||
@@ -843,6 +843,7 @@ class AppointmentsPage(QWidget):
|
||||
"patient_id": patient_id,
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"patient_name": first_value(row, "patient_name", default="患者"),
|
||||
"mode": "im",
|
||||
"record": row,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,9 +8,10 @@ from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from PySide6.QtCore import QSize, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QPixmap, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
@@ -22,6 +23,7 @@ from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QStackedWidget,
|
||||
QTabBar,
|
||||
@@ -118,6 +120,13 @@ def _attachment_name(value: object) -> str:
|
||||
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:
|
||||
"""Reject local filesystem references before a note JSON is constructed."""
|
||||
|
||||
@@ -130,6 +139,73 @@ def _is_local_material_reference(value: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
class _NoteAttachmentPreview(QPushButton):
|
||||
"""Responsive inline thumbnail that remains clickable for a full preview."""
|
||||
|
||||
_THUMBNAIL_HEIGHT = 132
|
||||
|
||||
def __init__(self, caption: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__("正在加载图片…", parent)
|
||||
self._source_pixmap = QPixmap()
|
||||
self.setObjectName("NoteAttachmentPreview")
|
||||
self.setProperty("variant", "secondary")
|
||||
self.setProperty("loadState", "loading")
|
||||
self.setAccessibleName(f"查看{caption}大图")
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setMinimumWidth(168)
|
||||
self.setFixedHeight(self._THUMBNAIL_HEIGHT)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
def _refresh_preview(self) -> None:
|
||||
if self._source_pixmap.isNull():
|
||||
return
|
||||
available = QSize(max(120, self.width() - 12), self._THUMBNAIL_HEIGHT - 12)
|
||||
rendered = self._source_pixmap.scaled(
|
||||
available,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
self.setIcon(QIcon(rendered))
|
||||
self.setIconSize(rendered.size())
|
||||
|
||||
def show_loading(self) -> None:
|
||||
self._source_pixmap = QPixmap()
|
||||
self.setIcon(QIcon())
|
||||
self.setText("正在加载图片…")
|
||||
self.setProperty("loadState", "loading")
|
||||
self.setEnabled(False)
|
||||
|
||||
def show_preview(self, pixmap: QPixmap) -> None:
|
||||
"""Render the decoded image directly in the note card."""
|
||||
|
||||
self._source_pixmap = pixmap.scaled(
|
||||
QSize(960, 720),
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
self.setText("")
|
||||
self._refresh_preview()
|
||||
self.setProperty("loadState", "ready")
|
||||
self.setEnabled(True)
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
|
||||
def show_failure(self) -> None:
|
||||
"""Keep a failed attachment actionable so the user can retry it."""
|
||||
|
||||
self._source_pixmap = QPixmap()
|
||||
self.setIcon(QIcon())
|
||||
self.setText("图片加载失败\n点击重试")
|
||||
self.setProperty("loadState", "failed")
|
||||
self.setEnabled(True)
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
|
||||
def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override
|
||||
super().resizeEvent(event)
|
||||
self._refresh_preview()
|
||||
|
||||
|
||||
class QueueRow(QWidget):
|
||||
"""Compact appointment summary rendered inside the queue list."""
|
||||
|
||||
@@ -139,12 +215,14 @@ class QueueRow(QWidget):
|
||||
layout.setContentsMargins(12, 9, 12, 9)
|
||||
layout.setSpacing(5)
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(8)
|
||||
name = QLabel(
|
||||
display_text(first_value(record, "patient_name", "name", default="未命名患者"))
|
||||
)
|
||||
name.setStyleSheet("font-size:14px; font-weight:700; color:#172033;")
|
||||
top.addWidget(name)
|
||||
top.addStretch(1)
|
||||
name.setMinimumWidth(0)
|
||||
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
|
||||
badge = StatusBadge(
|
||||
display_text(
|
||||
@@ -157,7 +235,12 @@ class QueueRow(QWidget):
|
||||
),
|
||||
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)
|
||||
time = first_value(
|
||||
record, "appointment_time_text", "appointment_time", "time", default="时间待确认"
|
||||
@@ -168,6 +251,10 @@ class QueueRow(QWidget):
|
||||
f"{display_text(first_value(record, 'age'))}岁"
|
||||
)
|
||||
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)
|
||||
assistant = first_value(record, "assistant_name", default=None)
|
||||
if assistant:
|
||||
@@ -211,6 +298,8 @@ class ReceptionPage(QWidget):
|
||||
self._pending_tongue_images: list[str] = []
|
||||
self._pending_report_files: list[str] = []
|
||||
self._note_busy = False
|
||||
self._attachment_preview_generation = 0
|
||||
self._attachment_render_generation = 0
|
||||
|
||||
self._can_complete = has_permission(self.permissions, "doctor.appointment/complete")
|
||||
self._can_note = has_permission(self.permissions, "doctor.appointment/addDoctorNote")
|
||||
@@ -335,7 +424,8 @@ class ReceptionPage(QWidget):
|
||||
self.notify_button = QPushButton("通知医助")
|
||||
self.notify_button.clicked.connect(self._notify_assistant)
|
||||
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.clicked.connect(self._request_video)
|
||||
action_row.addWidget(self.video_button)
|
||||
@@ -1216,6 +1306,8 @@ class ReceptionPage(QWidget):
|
||||
self.daily_text.setText("\n".join(lines) if lines else "近 30 日暂无日常记录。")
|
||||
|
||||
def _render_notes(self, notes: list[Any]) -> None:
|
||||
self._attachment_render_generation += 1
|
||||
render_generation = self._attachment_render_generation
|
||||
clear_layout(self.notes_layout)
|
||||
self.notes_count.setText(f"{len(notes)} 条")
|
||||
if not notes:
|
||||
@@ -1240,32 +1332,264 @@ class ReceptionPage(QWidget):
|
||||
meta.setProperty("role", "muted")
|
||||
layout.addWidget(meta)
|
||||
note_id = _as_int(first_value(note, "id", "note_id", default=None))
|
||||
image_attachments: list[tuple[str, str, str]] = []
|
||||
file_attachments: list[tuple[str, str, str]] = []
|
||||
for image_type, caption in (
|
||||
("tongue_images", "舌苔图"),
|
||||
("report_files", "检查报告"),
|
||||
):
|
||||
for path in _sequence(first_value(note, image_type, default=[])):
|
||||
attachment = QWidget()
|
||||
attachment_layout = QHBoxLayout(attachment)
|
||||
attachment_layout.setContentsMargins(0, 0, 0, 0)
|
||||
label = QLabel(f"{caption}:{_attachment_name(path)}")
|
||||
label.setToolTip(str(path))
|
||||
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
attachment_layout.addWidget(label, 1)
|
||||
path_text = str(path).strip()
|
||||
attachment = (image_type, caption, path_text)
|
||||
if _is_image_attachment(path_text):
|
||||
image_attachments.append(attachment)
|
||||
else:
|
||||
file_attachments.append(attachment)
|
||||
|
||||
if image_attachments:
|
||||
image_title = QLabel("图片附件 · 点击图片查看大图")
|
||||
image_title.setProperty("role", "muted")
|
||||
layout.addWidget(image_title)
|
||||
image_host = QWidget()
|
||||
image_grid = QGridLayout(image_host)
|
||||
image_grid.setContentsMargins(0, 0, 0, 0)
|
||||
image_grid.setHorizontalSpacing(10)
|
||||
image_grid.setVerticalSpacing(10)
|
||||
for index, (image_type, caption, path_text) in enumerate(
|
||||
image_attachments
|
||||
):
|
||||
tile = QFrame()
|
||||
tile.setObjectName("NoteAttachmentTile")
|
||||
tile_layout = QVBoxLayout(tile)
|
||||
tile_layout.setContentsMargins(8, 8, 8, 8)
|
||||
tile_layout.setSpacing(6)
|
||||
preview = _NoteAttachmentPreview(caption)
|
||||
preview.setToolTip(f"{caption}:{path_text}\n点击查看大图")
|
||||
preview.clicked.connect(
|
||||
lambda _checked=False, current_path=path_text, button=preview: (
|
||||
self._preview_note_attachment(current_path, button)
|
||||
)
|
||||
)
|
||||
tile_layout.addWidget(preview)
|
||||
footer = QHBoxLayout()
|
||||
footer.setSpacing(6)
|
||||
label = QLabel(f"{caption} · {_attachment_name(path_text)}")
|
||||
label.setObjectName("NoteAttachmentName")
|
||||
label.setToolTip(path_text)
|
||||
label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
label.setMinimumWidth(0)
|
||||
label.setSizePolicy(
|
||||
QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred
|
||||
)
|
||||
footer.addWidget(label, 1)
|
||||
if self._can_note and note_id is not None:
|
||||
delete_button = QPushButton("删除")
|
||||
delete_button.setProperty("variant", "secondary")
|
||||
delete_button.clicked.connect(
|
||||
lambda _checked=False, current_note_id=note_id, current_type=image_type, current_path=str(path), button=delete_button: (
|
||||
lambda _checked=False, current_note_id=note_id, current_type=image_type, current_path=path_text, button=delete_button: (
|
||||
self._delete_note_attachment(
|
||||
current_note_id, current_type, current_path, button
|
||||
)
|
||||
)
|
||||
)
|
||||
attachment_layout.addWidget(delete_button)
|
||||
layout.addWidget(attachment)
|
||||
footer.addWidget(delete_button)
|
||||
tile_layout.addLayout(footer)
|
||||
row, column = divmod(index, 3)
|
||||
image_grid.addWidget(tile, row, column)
|
||||
self._load_note_thumbnail(path_text, preview, render_generation)
|
||||
for column in range(3):
|
||||
image_grid.setColumnStretch(column, 1)
|
||||
layout.addWidget(image_host)
|
||||
|
||||
for image_type, caption, path_text in file_attachments:
|
||||
attachment = QWidget()
|
||||
attachment_layout = QHBoxLayout(attachment)
|
||||
attachment_layout.setContentsMargins(0, 0, 0, 0)
|
||||
label = QLabel(f"{caption}:{_attachment_name(path_text)}")
|
||||
label.setToolTip(path_text)
|
||||
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
attachment_layout.addWidget(label, 1)
|
||||
open_button = QPushButton("打开")
|
||||
open_button.setProperty("variant", "secondary")
|
||||
open_button.setEnabled(bool(path_text))
|
||||
open_button.clicked.connect(
|
||||
lambda _checked=False, current_path=path_text, button=open_button: (
|
||||
self._preview_note_attachment(current_path, button)
|
||||
)
|
||||
)
|
||||
attachment_layout.addWidget(open_button)
|
||||
if self._can_note and note_id is not None:
|
||||
delete_button = QPushButton("删除")
|
||||
delete_button.setProperty("variant", "secondary")
|
||||
delete_button.clicked.connect(
|
||||
lambda _checked=False, current_note_id=note_id, current_type=image_type, current_path=path_text, button=delete_button: (
|
||||
self._delete_note_attachment(
|
||||
current_note_id, current_type, current_path, button
|
||||
)
|
||||
)
|
||||
)
|
||||
attachment_layout.addWidget(delete_button)
|
||||
layout.addWidget(attachment)
|
||||
self.notes_layout.addWidget(card)
|
||||
|
||||
def _load_note_thumbnail(
|
||||
self,
|
||||
path: str,
|
||||
preview: _NoteAttachmentPreview,
|
||||
generation: int,
|
||||
) -> None:
|
||||
"""Download and paint a note image without opening another window."""
|
||||
|
||||
download = getattr(self.repository, "download_public_image", None)
|
||||
if not callable(download):
|
||||
preview.show_failure()
|
||||
return
|
||||
run_async(
|
||||
lambda: invoke(self.repository, "download_public_image", url=path),
|
||||
on_success=lambda payload: self._apply_note_thumbnail(
|
||||
payload, preview, generation
|
||||
),
|
||||
on_error=lambda _error: self._fail_note_thumbnail(preview, generation),
|
||||
)
|
||||
|
||||
def _apply_note_thumbnail(
|
||||
self,
|
||||
payload: Any,
|
||||
preview: _NoteAttachmentPreview,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != self._attachment_render_generation:
|
||||
return
|
||||
content = bytes(payload or b"")
|
||||
pixmap = QPixmap()
|
||||
if (
|
||||
not content
|
||||
or len(content) > 10 * 1024 * 1024
|
||||
or not pixmap.loadFromData(content)
|
||||
or pixmap.isNull()
|
||||
):
|
||||
self._fail_note_thumbnail(preview, generation)
|
||||
return
|
||||
with suppress(RuntimeError):
|
||||
preview.show_preview(pixmap)
|
||||
|
||||
def _fail_note_thumbnail(
|
||||
self,
|
||||
preview: _NoteAttachmentPreview,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != self._attachment_render_generation:
|
||||
return
|
||||
with suppress(RuntimeError):
|
||||
preview.show_failure()
|
||||
|
||||
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
|
||||
if isinstance(button, _NoteAttachmentPreview):
|
||||
button.show_loading()
|
||||
else:
|
||||
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
|
||||
content = bytes(payload or b"")
|
||||
pixmap = QPixmap()
|
||||
if not content or len(content) > 10 * 1024 * 1024 or not pixmap.loadFromData(content):
|
||||
if isinstance(button, _NoteAttachmentPreview):
|
||||
button.show_failure()
|
||||
else:
|
||||
button.setEnabled(True)
|
||||
button.setText("预览")
|
||||
show_toast(self, "服务器返回的图片无法预览。", "danger", 4600)
|
||||
return
|
||||
if isinstance(button, _NoteAttachmentPreview):
|
||||
button.show_preview(pixmap)
|
||||
else:
|
||||
button.setEnabled(True)
|
||||
button.setText("预览")
|
||||
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
|
||||
if isinstance(button, _NoteAttachmentPreview):
|
||||
button.show_failure()
|
||||
else:
|
||||
button.setEnabled(True)
|
||||
button.setText("预览")
|
||||
show_toast(self, f"图片预览失败:{friendly_error(error)}", "danger", 5200)
|
||||
|
||||
def _reset_detail_content(self, seed: Any = None) -> None:
|
||||
self.patient_name_label.setText(
|
||||
display_text(first_value(seed, "patient_name", "name", default="—"))
|
||||
@@ -1753,6 +2077,7 @@ class ReceptionPage(QWidget):
|
||||
"patient_name",
|
||||
default=first_value(diagnosis, "patient_name", default="患者"),
|
||||
),
|
||||
"mode": "im",
|
||||
"record": self._selected_record,
|
||||
}
|
||||
self.video_requested.emit(payload)
|
||||
|
||||
@@ -205,6 +205,33 @@ QPushButton[variant="secondary"]:checked {
|
||||
border-color: $indigo_pressed;
|
||||
}
|
||||
|
||||
QFrame#NoteAttachmentTile {
|
||||
background-color: $surface;
|
||||
border: 1px solid $line;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QPushButton#NoteAttachmentPreview {
|
||||
min-height: 132px;
|
||||
padding: 6px;
|
||||
color: $muted;
|
||||
background-color: $surface_alt;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
QPushButton#NoteAttachmentPreview:hover {
|
||||
background-color: $indigo_pale;
|
||||
border-color: $indigo_hover;
|
||||
}
|
||||
QPushButton#NoteAttachmentPreview[loadState="failed"] {
|
||||
color: $danger;
|
||||
background-color: $danger_pale;
|
||||
}
|
||||
QLabel#NoteAttachmentName {
|
||||
color: $text_soft;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QPushButton[variant="danger"] {
|
||||
color: $danger;
|
||||
background-color: $danger_pale;
|
||||
|
||||
@@ -25,7 +25,13 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.core.errors import AuthenticationExpiredError
|
||||
from doctor_workstation.core.errors import (
|
||||
ApiHttpError,
|
||||
ApiProtocolError,
|
||||
ApiTimeoutError,
|
||||
ApiTransportError,
|
||||
AuthenticationExpiredError,
|
||||
)
|
||||
|
||||
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
|
||||
_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):
|
||||
@@ -379,6 +423,10 @@ class StatusBadge(QLabel):
|
||||
self.setObjectName("StatusBadge")
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
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)
|
||||
|
||||
def set_kind(self, kind: str) -> None:
|
||||
|
||||
@@ -130,6 +130,7 @@ def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
|
||||
"patientUserId": "patient_user_id",
|
||||
"diagnosisId": "diagnosis_id",
|
||||
"patientId": "patient_id",
|
||||
"callRecordId": "call_record_id",
|
||||
}
|
||||
adapted = {
|
||||
json_name: getattr(ticket, attribute_name)
|
||||
@@ -196,6 +197,7 @@ class VideoCallRequest:
|
||||
target_user_id: str
|
||||
diagnosis_id: Identifier
|
||||
patient_id: Identifier | None = None
|
||||
call_record_id: Identifier | None = None
|
||||
backend_mode: BackendMode = BackendMode.EMBEDDED
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -218,6 +220,12 @@ class VideoCallRequest:
|
||||
"patient_id",
|
||||
_identifier(self.patient_id, "patientId"),
|
||||
)
|
||||
if self.call_record_id is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"call_record_id",
|
||||
_identifier(self.call_record_id, "callRecordId"),
|
||||
)
|
||||
object.__setattr__(self, "backend_mode", BackendMode.parse(self.backend_mode))
|
||||
|
||||
@classmethod
|
||||
@@ -257,6 +265,7 @@ class VideoCallRequest:
|
||||
return {
|
||||
"diagnosis_id": self.diagnosis_id,
|
||||
"patient_id": self.patient_id,
|
||||
"call_record_id": self.call_record_id,
|
||||
"backend_mode": self.backend_mode.value,
|
||||
}
|
||||
|
||||
@@ -285,6 +294,13 @@ def normalize_backend_ticket(
|
||||
_identifier,
|
||||
required=False,
|
||||
)
|
||||
payload_call_record = _read_aliases(
|
||||
payload,
|
||||
("callRecordId", "call_record_id"),
|
||||
"callRecordId",
|
||||
_identifier,
|
||||
required=False,
|
||||
)
|
||||
|
||||
normalized_diagnosis = _merge_identifier(
|
||||
payload_diagnosis,
|
||||
@@ -323,6 +339,7 @@ def normalize_backend_ticket(
|
||||
),
|
||||
diagnosis_id=normalized_diagnosis,
|
||||
patient_id=normalized_patient,
|
||||
call_record_id=payload_call_record,
|
||||
backend_mode=BackendMode.parse(backend_mode),
|
||||
)
|
||||
|
||||
@@ -359,6 +376,8 @@ class VideoCallLauncher:
|
||||
*,
|
||||
diagnosis_id: Any = None,
|
||||
patient_id: Any = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
) -> Any:
|
||||
request = self.prepare(
|
||||
ticket,
|
||||
@@ -374,6 +393,8 @@ class VideoCallLauncher:
|
||||
remote_url=self.remote_url,
|
||||
logger=self.logger,
|
||||
browser_opener=self.browser_opener,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -388,6 +409,8 @@ def launch_video_call(
|
||||
remote_url: str | None = None,
|
||||
logger: Any = None,
|
||||
browser_opener: Callable[[str], bool] | None = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
) -> Any:
|
||||
"""Normalize a ticket and open a call with the requested backend."""
|
||||
|
||||
@@ -402,4 +425,6 @@ def launch_video_call(
|
||||
ticket,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
|
||||
@@ -74,6 +74,71 @@ def _call_repository_method(method: Callable[..., Any], payload: Mapping[str, An
|
||||
return _resolve_result(result)
|
||||
|
||||
|
||||
def _mapping_candidate(value: Any) -> Mapping[str, Any] | None:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
for attribute in ("raw", "data"):
|
||||
candidate = getattr(value, attribute, None)
|
||||
if isinstance(candidate, Mapping):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _extract_call_record_id(result: Any) -> int | str | None:
|
||||
"""Read a positive call-record identity from common backend envelopes."""
|
||||
|
||||
pending = [result]
|
||||
visited: set[int] = set()
|
||||
while pending:
|
||||
candidate = pending.pop(0)
|
||||
mapping = _mapping_candidate(candidate)
|
||||
if mapping is None or id(mapping) in visited:
|
||||
continue
|
||||
visited.add(id(mapping))
|
||||
for key in ("call_record_id", "callRecordId", "id"):
|
||||
value = mapping.get(key)
|
||||
if isinstance(value, bool) or value is None:
|
||||
continue
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
for key in ("data", "result", "record", "call_record", "callRecord"):
|
||||
nested = mapping.get(key)
|
||||
if isinstance(nested, Mapping):
|
||||
pending.append(nested)
|
||||
return None
|
||||
|
||||
|
||||
def _clean_transcript_segment(segment: Mapping[str, Any], session_id: str) -> dict[str, Any]:
|
||||
segment_id = str(segment.get("segment_id", segment.get("segmentId", ""))).strip()
|
||||
text = str(segment.get("text", segment.get("sourceText", ""))).strip()
|
||||
speaker_user_id = str(
|
||||
segment.get("speaker_user_id", segment.get("speakerUserId", ""))
|
||||
).strip()
|
||||
speaker_role = str(segment.get("speaker_role", segment.get("speakerRole", "unknown"))).strip()
|
||||
if not segment_id or len(segment_id) > 160:
|
||||
raise ValueError("transcript segment_id must contain 1 to 160 characters")
|
||||
if not text or len(text) > 4_000:
|
||||
raise ValueError("transcript text must contain 1 to 4000 characters")
|
||||
if len(speaker_user_id) > 160:
|
||||
raise ValueError("transcript speaker_user_id is too long")
|
||||
if speaker_role not in {"doctor", "patient", "unknown"}:
|
||||
speaker_role = "unknown"
|
||||
try:
|
||||
timestamp = int(segment.get("timestamp") or 0)
|
||||
except (TypeError, ValueError):
|
||||
timestamp = 0
|
||||
return {
|
||||
"segment_id": segment_id,
|
||||
"transcription_session_id": session_id,
|
||||
"speaker_user_id": speaker_user_id,
|
||||
"speaker_role": speaker_role,
|
||||
"timestamp": max(timestamp, 0),
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _WorkItem:
|
||||
operation: str
|
||||
@@ -169,11 +234,17 @@ class OrderedCallLifecycle:
|
||||
self.logger = logger
|
||||
self.started = False
|
||||
self.ended = False
|
||||
self.call_record_id: int | str | None = request.call_record_id
|
||||
self.bound_room_id: str | None = None
|
||||
self._claimed_room_id: str | None = None
|
||||
self._start_future: Future[bool] | None = None
|
||||
self._bind_future: Future[bool] | None = None
|
||||
self._end_future: Future[bool] | None = None
|
||||
self._transcription_start_future: Future[bool] | None = None
|
||||
self._transcription_finish_future: Future[bool] | None = None
|
||||
self._transcription_session_id: str | None = None
|
||||
self._transcription_active = False
|
||||
self._segment_futures: dict[str, Future[bool]] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._worker = _OrderedDaemonWorker(logger, request.safe_log_context())
|
||||
|
||||
@@ -181,6 +252,10 @@ class OrderedCallLifecycle:
|
||||
def worker_is_daemon(self) -> bool:
|
||||
return self._worker.is_daemon
|
||||
|
||||
@property
|
||||
def transcription_session_id(self) -> str | None:
|
||||
return self._transcription_session_id
|
||||
|
||||
def start(self) -> Future[bool]:
|
||||
with self._lock:
|
||||
if self._start_future is not None:
|
||||
@@ -196,9 +271,13 @@ class OrderedCallLifecycle:
|
||||
payload["patient_id"] = self.request.patient_id
|
||||
|
||||
def operation() -> bool:
|
||||
_call_repository_method(method, payload)
|
||||
result = _call_repository_method(method, payload)
|
||||
record_id = _extract_call_record_id(result)
|
||||
if record_id is None:
|
||||
raise ValueError("startCall response did not include the current call_record_id")
|
||||
with self._lock:
|
||||
self.started = True
|
||||
self.call_record_id = record_id
|
||||
self.logger.info(
|
||||
"video call record started",
|
||||
extra={"video_call": self.request.safe_log_context()},
|
||||
@@ -262,10 +341,214 @@ class OrderedCallLifecycle:
|
||||
self._bind_future = self._worker.submit("bind", operation)
|
||||
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 start_transcription(self, session_id: str, *, language: str = "zh-CN") -> Future[bool]:
|
||||
"""Start persisted realtime transcription for this exact call record."""
|
||||
|
||||
clean_session = str(session_id or "").strip()
|
||||
clean_language = str(language or "zh-CN").strip()[:32] or "zh-CN"
|
||||
if not clean_session or len(clean_session) > 128:
|
||||
raise ValueError("transcription session id must contain 1 to 128 characters")
|
||||
with self._lock:
|
||||
if self._end_future is not None:
|
||||
raise RuntimeError("video call has already ended")
|
||||
if self._transcription_start_future is not None:
|
||||
if self._transcription_session_id != clean_session:
|
||||
raise RuntimeError("another transcription session already exists")
|
||||
return self._transcription_start_future
|
||||
if self._start_future is None:
|
||||
self.start()
|
||||
method = getattr(self.repository, "start_call_transcription", None)
|
||||
if not callable(method):
|
||||
raise ValueError("video repository does not implement call transcription storage")
|
||||
self._transcription_session_id = clean_session
|
||||
|
||||
def operation() -> bool:
|
||||
with self._lock:
|
||||
started = self.started
|
||||
record_id = self.call_record_id
|
||||
if not started:
|
||||
return False
|
||||
if record_id is None:
|
||||
raise ValueError("server did not return the current call_record_id")
|
||||
_call_repository_method(
|
||||
method,
|
||||
{
|
||||
"diagnosis_id": self.request.diagnosis_id,
|
||||
"call_record_id": record_id,
|
||||
"transcription_session_id": clean_session,
|
||||
"language": clean_language,
|
||||
},
|
||||
)
|
||||
with self._lock:
|
||||
self._transcription_active = True
|
||||
self.logger.info(
|
||||
"video call transcription started",
|
||||
extra={"video_call": self.request.safe_log_context()},
|
||||
)
|
||||
return True
|
||||
|
||||
self._transcription_start_future = self._worker.submit(
|
||||
"transcription-start", operation
|
||||
)
|
||||
return self._transcription_start_future
|
||||
|
||||
def save_transcript_segment(self, segment: Mapping[str, Any]) -> Future[bool]:
|
||||
"""Upsert one completed, bounded transcript segment without logging its text."""
|
||||
|
||||
with self._lock:
|
||||
session_id = self._transcription_session_id
|
||||
if not session_id or self._transcription_start_future is None:
|
||||
raise RuntimeError("call transcription has not started")
|
||||
if self._transcription_finish_future is not None:
|
||||
raise RuntimeError("call transcription has already stopped")
|
||||
cleaned = _clean_transcript_segment(segment, session_id)
|
||||
segment_id = cleaned["segment_id"]
|
||||
existing = self._segment_futures.get(segment_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
method = getattr(self.repository, "upsert_call_transcript_segments", None)
|
||||
if not callable(method):
|
||||
raise ValueError("video repository does not implement transcript segment storage")
|
||||
|
||||
def operation() -> bool:
|
||||
with self._lock:
|
||||
active = self._transcription_active
|
||||
record_id = self.call_record_id
|
||||
if not active:
|
||||
return False
|
||||
if record_id is None:
|
||||
raise ValueError("server did not return the current call_record_id")
|
||||
_call_repository_method(
|
||||
method,
|
||||
{
|
||||
"diagnosis_id": self.request.diagnosis_id,
|
||||
"call_record_id": record_id,
|
||||
"transcription_session_id": session_id,
|
||||
"segments": [cleaned],
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
future = self._worker.submit("transcription-segment", operation)
|
||||
self._segment_futures[segment_id] = future
|
||||
|
||||
def release_failed(completed: Future[bool]) -> None:
|
||||
if completed.cancelled() or completed.exception() is not None:
|
||||
with self._lock:
|
||||
if self._segment_futures.get(segment_id) is completed:
|
||||
self._segment_futures.pop(segment_id, None)
|
||||
|
||||
future.add_done_callback(release_failed)
|
||||
return future
|
||||
|
||||
def finish_transcription(self, *, status: str = "completed") -> Future[bool]:
|
||||
"""Flush and finalize the current transcript exactly once."""
|
||||
|
||||
clean_status = str(status or "completed").strip().lower()
|
||||
if clean_status not in {"completed", "partial", "failed"}:
|
||||
raise ValueError("transcription status is invalid")
|
||||
with self._lock:
|
||||
if self._transcription_finish_future is not None:
|
||||
return self._transcription_finish_future
|
||||
if self._transcription_start_future is None or not self._transcription_session_id:
|
||||
return _settled_future(False)
|
||||
method = getattr(self.repository, "finish_call_transcription", None)
|
||||
if not callable(method):
|
||||
raise ValueError("video repository does not implement transcription finalization")
|
||||
session_id = self._transcription_session_id
|
||||
|
||||
def operation() -> bool:
|
||||
with self._lock:
|
||||
active = self._transcription_active
|
||||
record_id = self.call_record_id
|
||||
expected_count = len(self._segment_futures)
|
||||
if not active:
|
||||
return False
|
||||
if record_id is None:
|
||||
raise ValueError("server did not return the current call_record_id")
|
||||
_call_repository_method(
|
||||
method,
|
||||
{
|
||||
"diagnosis_id": self.request.diagnosis_id,
|
||||
"call_record_id": record_id,
|
||||
"transcription_session_id": session_id,
|
||||
"expected_segment_count": expected_count,
|
||||
"status": clean_status,
|
||||
},
|
||||
)
|
||||
with self._lock:
|
||||
self._transcription_active = False
|
||||
self.logger.info(
|
||||
"video call transcription finalized",
|
||||
extra={
|
||||
"video_call": self.request.safe_log_context(),
|
||||
"transcription_status": clean_status,
|
||||
"segment_count": expected_count,
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
self._transcription_finish_future = self._worker.submit(
|
||||
"transcription-finish", operation
|
||||
)
|
||||
return self._transcription_finish_future
|
||||
|
||||
def end(self, reason: str) -> Future[bool]:
|
||||
with self._lock:
|
||||
if self._end_future is not None:
|
||||
return self._end_future
|
||||
if (
|
||||
self._transcription_start_future is not None
|
||||
and self._transcription_finish_future is None
|
||||
):
|
||||
self.finish_transcription(status="partial")
|
||||
method = getattr(self.repository, "end_call", None)
|
||||
|
||||
def operation() -> bool:
|
||||
|
||||
@@ -7,6 +7,8 @@ actual call requires an isolated QtWebEngine profile and an active QApplication.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
@@ -181,11 +183,34 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
|
||||
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]
|
||||
status_changed = Signal(str) # type: ignore[misc]
|
||||
call_ended = Signal(str) # type: ignore[misc]
|
||||
call_error = Signal(str) # type: ignore[misc]
|
||||
_start_completed = Signal(bool) # type: ignore[misc]
|
||||
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
|
||||
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -194,12 +219,19 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
lifecycle: OrderedCallLifecycle,
|
||||
*,
|
||||
logger: logging.Logger,
|
||||
lifecycle_factory: Callable[[], OrderedCallLifecycle],
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.request = request
|
||||
self.location = location
|
||||
self.lifecycle = lifecycle
|
||||
self._lifecycle_factory = lifecycle_factory
|
||||
self._lifecycles = [lifecycle]
|
||||
self.logger = logger
|
||||
self.open_im = bool(open_im)
|
||||
self.patient_name = str(patient_name or "患者").strip() or "患者"
|
||||
try:
|
||||
self._policy = TrustedDocumentPolicy.from_url(
|
||||
location.url,
|
||||
@@ -213,10 +245,14 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self._companion_ended = False
|
||||
self._released = False
|
||||
self._close_reason = "window-closed"
|
||||
self._call_cycle_closed = False
|
||||
self._start_requested = False
|
||||
self._legacy_grants: list[tuple[Any, 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.resize(1120, 760)
|
||||
self.setMinimumSize(760, 520)
|
||||
@@ -248,6 +284,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self._connect_permissions()
|
||||
|
||||
self._start_completed.connect(self._on_lifecycle_started)
|
||||
self._screenshot_completed.connect(self._on_screenshot_completed)
|
||||
self._transcription_completed.connect(self._on_transcription_completed)
|
||||
self.web_view.loadFinished.connect(self._on_load_finished)
|
||||
self.web_view.setUrl(QUrl(self.location.url))
|
||||
|
||||
@@ -326,17 +364,24 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self.close()
|
||||
return
|
||||
|
||||
try:
|
||||
start_future = self.lifecycle.start()
|
||||
except Exception:
|
||||
self.logger.error(
|
||||
"video call record could not be queued",
|
||||
extra={"video_call": self.request.safe_log_context()},
|
||||
)
|
||||
self._close_reason = "record-start-queue-failed"
|
||||
self.close()
|
||||
return
|
||||
start_future.add_done_callback(self._notify_start_completed)
|
||||
self._media_active = True
|
||||
config = {
|
||||
**self.request.to_web_config(),
|
||||
"patientName": self.patient_name,
|
||||
"mode": "chat" if self.open_im else "video",
|
||||
}
|
||||
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
|
||||
script = f"""
|
||||
(() => {{
|
||||
if (!window.doctorConsultation
|
||||
|| 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:
|
||||
try:
|
||||
@@ -350,25 +395,16 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
if self._closing:
|
||||
return
|
||||
if not succeeded:
|
||||
self._close_reason = "record-start-failed"
|
||||
self.close()
|
||||
self._start_requested = False
|
||||
self._call_cycle_closed = True
|
||||
self._page.runJavaScript(
|
||||
"window.doctorConsultation?.hostCallReady?.(false, "
|
||||
'"通话记录创建失败,请稍后重试。");'
|
||||
)
|
||||
return
|
||||
self._media_active = True
|
||||
config_json = json.dumps(
|
||||
self.request.to_web_config(),
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
self._page.runJavaScript(
|
||||
"window.doctorConsultation?.hostCallReady?.(true, '');"
|
||||
)
|
||||
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:
|
||||
self._injected = result is not False
|
||||
@@ -380,6 +416,33 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
if self._closing:
|
||||
return
|
||||
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
|
||||
if event == "transcription-start-request":
|
||||
self._start_transcription(
|
||||
str(message.get("sessionId") or ""),
|
||||
str(message.get("language") or "zh-CN"),
|
||||
)
|
||||
return
|
||||
if event == "transcription-segment":
|
||||
self._save_transcript_segment(message)
|
||||
return
|
||||
if event == "transcription-stop":
|
||||
self._finish_transcription(
|
||||
str(message.get("sessionId") or ""),
|
||||
str(message.get("status") or "completed"),
|
||||
)
|
||||
return
|
||||
room_id = message.get("roomId", message.get("room_id"))
|
||||
if room_id not in (None, ""):
|
||||
self.lifecycle.bind_room(room_id)
|
||||
@@ -388,16 +451,181 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
if event == "status":
|
||||
status = str(message.get("status", "unknown"))[:80]
|
||||
self.status_changed.emit(status)
|
||||
if status == "idle":
|
||||
self._close_from_companion("remote-idle")
|
||||
elif event == "hangup":
|
||||
status = str(message.get("status", "ended"))[:80]
|
||||
self.call_ended.emit(status)
|
||||
self._close_from_companion("companion-hangup")
|
||||
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")
|
||||
elif event == "error":
|
||||
message_text = str(message.get("message", "视频通话错误"))[:400]
|
||||
self.call_error.emit(message_text)
|
||||
self._close_from_companion("companion-error")
|
||||
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")
|
||||
|
||||
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 _notify_transcription_completed(
|
||||
self,
|
||||
operation: str,
|
||||
session_id: str,
|
||||
segment_id: str,
|
||||
future: Future[bool],
|
||||
) -> None:
|
||||
try:
|
||||
succeeded = bool(future.result())
|
||||
except Exception as error:
|
||||
succeeded = False
|
||||
message = str(error)[:200] or "录音文字保存失败。"
|
||||
else:
|
||||
message = {
|
||||
"start": "录音文字存储已准备。",
|
||||
"segment": "",
|
||||
"stop": "本次面诊对话文字已保存。",
|
||||
}.get(operation, "")
|
||||
with suppress(RuntimeError):
|
||||
self._transcription_completed.emit(
|
||||
operation, session_id, segment_id, succeeded, message
|
||||
)
|
||||
|
||||
def _on_transcription_completed(
|
||||
self,
|
||||
operation: str,
|
||||
session_id: str,
|
||||
segment_id: str,
|
||||
succeeded: bool,
|
||||
message: str,
|
||||
) -> None:
|
||||
payload = json.dumps(str(message)[:200], ensure_ascii=True)
|
||||
state = "true" if succeeded else "false"
|
||||
self._page.runJavaScript(
|
||||
"window.doctorConsultation?.transcriptionResult?.("
|
||||
f"{json.dumps(operation)}, {json.dumps(session_id)}, "
|
||||
f"{json.dumps(segment_id)}, {state}, {payload});"
|
||||
)
|
||||
|
||||
def _start_transcription(self, session_id: str, language: str) -> None:
|
||||
try:
|
||||
future = self.lifecycle.start_transcription(session_id, language=language)
|
||||
except Exception as error:
|
||||
self._on_transcription_completed(
|
||||
"start", session_id, "", False, str(error)[:200]
|
||||
)
|
||||
return
|
||||
future.add_done_callback(
|
||||
lambda completed: self._notify_transcription_completed(
|
||||
"start", session_id, "", completed
|
||||
)
|
||||
)
|
||||
|
||||
def _save_transcript_segment(self, message: Mapping[str, Any]) -> None:
|
||||
session_id = str(message.get("sessionId") or "").strip()
|
||||
segment = message.get("segment")
|
||||
segment_id = (
|
||||
str(segment.get("segment_id") or segment.get("segmentId") or "").strip()
|
||||
if isinstance(segment, Mapping)
|
||||
else ""
|
||||
)
|
||||
if not isinstance(segment, Mapping):
|
||||
self._on_transcription_completed(
|
||||
"segment", session_id, segment_id, False, "录音文字片段无效。"
|
||||
)
|
||||
return
|
||||
if session_id != str(self.lifecycle.transcription_session_id or ""):
|
||||
self._on_transcription_completed(
|
||||
"segment", session_id, segment_id, False, "录音会话标识不匹配。"
|
||||
)
|
||||
return
|
||||
try:
|
||||
future = self.lifecycle.save_transcript_segment(segment)
|
||||
except Exception as error:
|
||||
self._on_transcription_completed(
|
||||
"segment", session_id, segment_id, False, str(error)[:200]
|
||||
)
|
||||
return
|
||||
future.add_done_callback(
|
||||
lambda completed: self._notify_transcription_completed(
|
||||
"segment", session_id, segment_id, completed
|
||||
)
|
||||
)
|
||||
|
||||
def _finish_transcription(self, session_id: str, status: str) -> None:
|
||||
if session_id.strip() != str(self.lifecycle.transcription_session_id or ""):
|
||||
self._on_transcription_completed(
|
||||
"stop", session_id, "", False, "录音会话标识不匹配。"
|
||||
)
|
||||
return
|
||||
try:
|
||||
future = self.lifecycle.finish_transcription(status=status)
|
||||
except Exception as error:
|
||||
self._on_transcription_completed(
|
||||
"stop", session_id, "", False, str(error)[:200]
|
||||
)
|
||||
return
|
||||
future.add_done_callback(
|
||||
lambda completed: self._notify_transcription_completed(
|
||||
"stop", session_id, "", completed
|
||||
)
|
||||
)
|
||||
|
||||
def _close_from_companion(self, reason: str) -> None:
|
||||
self._companion_ended = True
|
||||
@@ -415,11 +643,15 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
|
||||
self._media_active = False
|
||||
if self._injected and not self._companion_ended and not self._released:
|
||||
self._page.runJavaScript(
|
||||
"void window.doctorCall?.hangup?.().catch(() => undefined)"
|
||||
"void window.doctorConsultation?.close?.().catch(() => undefined)"
|
||||
)
|
||||
self.lifecycle.end(self._close_reason)
|
||||
if self._start_requested and not self._call_cycle_closed:
|
||||
self.lifecycle.end(self._close_reason)
|
||||
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:
|
||||
if self._released:
|
||||
return
|
||||
@@ -486,6 +718,8 @@ class VideoCallWindow:
|
||||
remote_url: str | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
browser_opener: Callable[[str], bool] | None = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
) -> None:
|
||||
del browser_opener # Reserved for a future authenticated handoff implementation.
|
||||
try:
|
||||
@@ -500,14 +734,23 @@ class VideoCallWindow:
|
||||
raise VideoWindowError("embedded video requires an active QApplication")
|
||||
|
||||
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.location = resolve_companion_location(
|
||||
local_dist=local_dist,
|
||||
remote_url=remote_url,
|
||||
)
|
||||
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
|
||||
self._lifecycles = [self.lifecycle]
|
||||
self._session: Any = None
|
||||
|
||||
def _new_lifecycle(self) -> OrderedCallLifecycle:
|
||||
lifecycle = OrderedCallLifecycle(self.request, self.repository, self.logger)
|
||||
self._lifecycles.append(lifecycle)
|
||||
return lifecycle
|
||||
|
||||
@property
|
||||
def qt_window(self) -> Any:
|
||||
return self._session
|
||||
@@ -519,6 +762,9 @@ class VideoCallWindow:
|
||||
self.location,
|
||||
self.lifecycle,
|
||||
logger=self.logger,
|
||||
lifecycle_factory=self._new_lifecycle,
|
||||
open_im=self.open_im,
|
||||
patient_name=self.patient_name,
|
||||
)
|
||||
except Exception:
|
||||
self.lifecycle.end("window-open-failed")
|
||||
@@ -545,7 +791,9 @@ class VideoCallWindow:
|
||||
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
|
||||
"""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
|
||||
|
||||
@@ -558,6 +806,8 @@ def open_video_call(
|
||||
remote_url: str | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
browser_opener: Callable[[str], bool] | None = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
) -> VideoCallWindow:
|
||||
"""Create and immediately open a trusted embedded video window."""
|
||||
|
||||
@@ -570,6 +820,8 @@ def open_video_call(
|
||||
remote_url=remote_url,
|
||||
logger=logger,
|
||||
browser_opener=browser_opener,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
).open()
|
||||
|
||||
|
||||
|
||||
@@ -215,21 +215,43 @@ def test_invalid_envelope_raises_protocol_error() -> None:
|
||||
client.get("broken")
|
||||
|
||||
|
||||
def test_token_store_file_fallback_never_persists_password(tmp_path: Path) -> None:
|
||||
"""The fallback contains only an access token and an optional account name."""
|
||||
def test_token_store_file_fallback_never_persists_plaintext_password(tmp_path: Path) -> None:
|
||||
"""The fallback may contain a Windows DPAPI blob, but never plaintext."""
|
||||
|
||||
path = tmp_path / "credentials.json"
|
||||
store = TokenStore(path, keyring_backend=None)
|
||||
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_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"))
|
||||
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 "must-not-reach-disk" not in path.read_text(encoding="utf-8")
|
||||
store.clear_token()
|
||||
assert store.load_token() is None
|
||||
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:
|
||||
@@ -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"}
|
||||
|
||||
|
||||
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:
|
||||
"""Automatic restore never returns a token issued for another API base."""
|
||||
|
||||
|
||||
@@ -232,6 +232,69 @@ def test_demo_call_lifecycle_mutates_record(repository: DemoDoctorRepository) ->
|
||||
assert ended["room_id"] == "room-501"
|
||||
|
||||
|
||||
def test_demo_transcript_upsert_and_finish_round_trip_in_call_records(
|
||||
repository: DemoDoctorRepository,
|
||||
) -> None:
|
||||
"""Demo replay reads expose one finalized segment for a repeated segment ID."""
|
||||
|
||||
started = repository.start_call(501, 301)
|
||||
call_record_id = started["id"]
|
||||
repository.start_call_transcription(501, call_record_id, "session-1")
|
||||
repository.upsert_call_transcript_segments(
|
||||
501,
|
||||
call_record_id,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "draft words",
|
||||
}
|
||||
],
|
||||
)
|
||||
repository.upsert_call_transcript_segments(
|
||||
501,
|
||||
call_record_id,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "final words",
|
||||
}
|
||||
],
|
||||
)
|
||||
repository.finish_call_transcription(
|
||||
501,
|
||||
call_record_id,
|
||||
"session-1",
|
||||
expected_segment_count=1,
|
||||
status="completed",
|
||||
)
|
||||
repository.end_call(501)
|
||||
|
||||
record = next(
|
||||
row for row in repository.list_call_records(501) if row["id"] == call_record_id
|
||||
)
|
||||
assert record["status"] == 2
|
||||
assert record["transcription_status"] == "completed"
|
||||
assert record["transcription_segment_count"] == 1
|
||||
assert record["transcript_segments"] == [
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_role": "patient",
|
||||
"speaker_user_id": "patient_301",
|
||||
"timestamp": 1200,
|
||||
"text": "final words",
|
||||
}
|
||||
]
|
||||
assert "final words" in record["transcript_text"]
|
||||
|
||||
|
||||
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
|
||||
"""List parsing handles nullable fields, aliases and non-object rows safely."""
|
||||
|
||||
@@ -315,6 +378,8 @@ class _StubApiClient:
|
||||
"userSig": "short-lived",
|
||||
"patientUserId": "patient_2",
|
||||
}
|
||||
if endpoint == "tcm.diagnosis/startCall":
|
||||
return {"call_record_id": 901}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -423,6 +423,8 @@ def test_order_payload_and_a4_print_document(
|
||||
assert "黄芪" in rendered
|
||||
assert "酸枣仁" in rendered
|
||||
assert "服药前请核对姓名、电话、医生等信息" in rendered
|
||||
assert '<table align="center" class="paper" width="794" height="1123"' in rendered
|
||||
assert '<col width="44"/><col/><col width="64"/><col width="24"/>' in rendered
|
||||
assert "Rp." in rendered
|
||||
assert "药房联" in rendered
|
||||
assert "主方" in rendered
|
||||
@@ -433,8 +435,9 @@ def test_order_payload_and_a4_print_document(
|
||||
assert "每天2次, 一次2袋, 每袋5g, 温水送服, 饭后" in rendered
|
||||
viewer = PrescriptionDetailDialog(prescription)
|
||||
document_html = viewer.document.toHtml()
|
||||
assert "药房联" in document_html
|
||||
assert "用药 (单剂)" in document_html
|
||||
assert viewer.document.idealWidth() == pytest.approx(794.0)
|
||||
assert viewer.preview.__class__.__name__ == "_PrescriptionPaperPreview"
|
||||
viewer.close()
|
||||
order.close()
|
||||
application.processEvents()
|
||||
@@ -450,6 +453,7 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
|
||||
"case_record": {
|
||||
"patient_name": "林晓岚",
|
||||
"phone": "13800138000",
|
||||
"diagnosis_type": "follow_up",
|
||||
"local_hospital_name": "市中医院",
|
||||
"symptoms": "口渴乏力",
|
||||
"tongue_coating": "舌红少苔",
|
||||
@@ -461,8 +465,15 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
|
||||
assert viewer.tabs.tabText(viewer.tabs.currentIndex()) == "详细病历"
|
||||
assert viewer.case_document is not None
|
||||
case_html = viewer.case_document.toHtml()
|
||||
source_html = dialog_module.render_case_record_html(prescription)
|
||||
assert "甄养堂 详细病历" in case_html
|
||||
assert '<table class="cr-grid"' in source_html
|
||||
assert "follow_up" not in case_html
|
||||
assert "复诊" in case_html
|
||||
assert "市中医院" in case_html
|
||||
assert "口渴乏力" in case_html
|
||||
assert viewer.case_preview is not None
|
||||
assert viewer.case_preview.objectName() == "CaseRecordPaperPreview"
|
||||
viewer.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@@ -10,13 +10,20 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
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.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")
|
||||
@@ -50,6 +57,77 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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_expand_images_and_keep_file_open(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
) -> 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",
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
previews = page.notes_container.findChildren(
|
||||
QPushButton, "NoteAttachmentPreview"
|
||||
)
|
||||
assert len(previews) == 2
|
||||
assert all(button.text() == "" for button in previews)
|
||||
assert all(not button.icon().isNull() for button in previews)
|
||||
labels = [button.text() for button in page.notes_container.findChildren(QPushButton)]
|
||||
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(
|
||||
appointment_id: int,
|
||||
*,
|
||||
@@ -361,6 +439,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
||||
"patient_id": 141,
|
||||
"diagnosis_id": 241,
|
||||
"patient_name": "视频患者",
|
||||
"mode": "im",
|
||||
"record": detail["appointment"],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import ApiProtocolError
|
||||
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.services.repository import (
|
||||
@@ -76,6 +77,8 @@ class RecordingClient:
|
||||
self.post_calls.append((endpoint, body))
|
||||
if endpoint in {"tcm.prescription/add", "tcm.prescriptionOrder/create"}:
|
||||
return {"id": 88}
|
||||
if endpoint == "tcm.diagnosis/startCall":
|
||||
return {"call_record_id": 901}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -253,6 +256,106 @@ def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
|
||||
} <= get_endpoints
|
||||
|
||||
|
||||
def test_remote_transcription_endpoints_use_exact_normalized_dtos() -> None:
|
||||
"""Realtime transcript persistence stays within the three audited POST DTOs."""
|
||||
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
repository.start_call_transcription(501, 901, " session-1 ", language=" zh-CN ")
|
||||
repository.upsert_call_transcript_segments(
|
||||
501,
|
||||
901,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": "1200",
|
||||
"text": " patient words ",
|
||||
}
|
||||
],
|
||||
)
|
||||
repository.finish_call_transcription(
|
||||
501,
|
||||
901,
|
||||
"session-1",
|
||||
expected_segment_count=1,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/startCallTranscription",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"language": "zh-CN",
|
||||
},
|
||||
),
|
||||
(
|
||||
"tcm.diagnosis/upsertCallTranscriptSegments",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"segments": [
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_301",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "patient words",
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
(
|
||||
"tcm.diagnosis/finishCallTranscription",
|
||||
{
|
||||
"diagnosis_id": 501,
|
||||
"call_record_id": 901,
|
||||
"transcription_session_id": "session-1",
|
||||
"expected_segment_count": 1,
|
||||
"status": "completed",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_remote_start_call_requires_and_normalizes_current_record_id() -> None:
|
||||
client = RecordingClient()
|
||||
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
||||
|
||||
assert repository.start_call(501, 301) == {"call_record_id": 901}
|
||||
assert client.post_calls == [
|
||||
(
|
||||
"tcm.diagnosis/startCall",
|
||||
{"diagnosis_id": 501, "patient_id": 301, "call_type": 2},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[None, {}, {"ok": True}, {"call_record_id": 0}, {"callRecordId": -1}, {"id": True}],
|
||||
)
|
||||
def test_remote_start_call_rejects_missing_or_invalid_record_id(response: Any) -> None:
|
||||
class StartCallClient(RecordingClient):
|
||||
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
if endpoint == "tcm.diagnosis/startCall":
|
||||
self.post_calls.append((endpoint, dict(payload or {})))
|
||||
return response
|
||||
return super().post(endpoint, payload)
|
||||
|
||||
repository = RemoteDoctorRepository(StartCallClient()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ApiProtocolError, match="call_record"):
|
||||
repository.start_call(501, 301)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe_reference",
|
||||
[r"C:\records\tongue.jpg", r"\\server\share\report.pdf", "file:///tmp/a.jpg"],
|
||||
|
||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
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.app import ApplicationController
|
||||
@@ -232,11 +232,28 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
|
||||
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([])
|
||||
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(
|
||||
api_base_url="",
|
||||
api_base_url="https://example.test/adminapi",
|
||||
request_timeout=30,
|
||||
verify_ssl=True,
|
||||
demo_mode=False,
|
||||
@@ -246,8 +263,9 @@ def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||
object(),
|
||||
config=config,
|
||||
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()
|
||||
application.processEvents()
|
||||
|
||||
@@ -255,12 +273,28 @@ def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||
object(),
|
||||
config=config,
|
||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||
credential_store=credentials,
|
||||
)
|
||||
assert restored.account_edit.text() == "admin"
|
||||
assert restored.password_edit.text() == "secret-value"
|
||||
assert restored.remember_check.text() == "记住密码"
|
||||
assert restored.remember_check.isChecked()
|
||||
|
||||
restored._on_login_success({}, "admin", False, "secret-value")
|
||||
restored.close()
|
||||
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(
|
||||
tmp_path: Any,
|
||||
@@ -386,6 +420,37 @@ def test_certificate_error_explains_self_signed_server_setting() -> None:
|
||||
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:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings = QSettings(str(tmp_path / "certificate-error.ini"), QSettings.Format.IniFormat)
|
||||
|
||||
@@ -188,10 +188,13 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
|
||||
release_start = threading.Event()
|
||||
|
||||
class Repository:
|
||||
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int) -> None:
|
||||
def start_call(
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
||||
) -> dict[str, int]:
|
||||
start_entered.set()
|
||||
assert release_start.wait(2)
|
||||
events.append(("start", diagnosis_id, patient_id, call_type))
|
||||
return {"call_record_id": 900}
|
||||
|
||||
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
|
||||
events.append(("bind", diagnosis_id, room_id))
|
||||
@@ -237,6 +240,208 @@ def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_transcription_lifecycle_uses_start_record_id_and_remains_fifo() -> None:
|
||||
events: list[tuple[object, ...]] = []
|
||||
|
||||
class Repository:
|
||||
def start_call(
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
||||
) -> dict[str, object]:
|
||||
events.append(("start", diagnosis_id, patient_id, call_type))
|
||||
return {"data": {"callRecordId": 901}}
|
||||
|
||||
def start_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
*,
|
||||
language: str,
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"transcription-start",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
language,
|
||||
)
|
||||
)
|
||||
|
||||
def upsert_call_transcript_segments(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
segments: list[dict[str, object]],
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"segment",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
segments,
|
||||
)
|
||||
)
|
||||
|
||||
def finish_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
expected_segment_count: int,
|
||||
*,
|
||||
status: str,
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"finish",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
expected_segment_count,
|
||||
status,
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
patient_id=8,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
start = lifecycle.start()
|
||||
transcription_start = lifecycle.start_transcription("session-1")
|
||||
segment = lifecycle.save_transcript_segment(
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"speaker_user_id": "patient_8",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "patient words",
|
||||
}
|
||||
)
|
||||
duplicate = lifecycle.save_transcript_segment(
|
||||
{"segment_id": "seg-1", "text": "must not produce another write"}
|
||||
)
|
||||
finish = lifecycle.finish_transcription(status="completed")
|
||||
end = lifecycle.end("test")
|
||||
|
||||
assert duplicate is segment
|
||||
assert start.result(timeout=2) is True
|
||||
assert transcription_start.result(timeout=2) is True
|
||||
assert segment.result(timeout=2) is True
|
||||
assert finish.result(timeout=2) is True
|
||||
assert end.result(timeout=2) is True
|
||||
assert lifecycle.wait(1) is True
|
||||
assert lifecycle.call_record_id == 901
|
||||
assert events == [
|
||||
("start", 123, 8, 2),
|
||||
("transcription-start", 123, 901, "session-1", "zh-CN"),
|
||||
(
|
||||
"segment",
|
||||
123,
|
||||
901,
|
||||
"session-1",
|
||||
[
|
||||
{
|
||||
"segment_id": "seg-1",
|
||||
"transcription_session_id": "session-1",
|
||||
"speaker_user_id": "patient_8",
|
||||
"speaker_role": "patient",
|
||||
"timestamp": 1200,
|
||||
"text": "patient words",
|
||||
}
|
||||
],
|
||||
),
|
||||
("finish", 123, 901, "session-1", 1, "completed"),
|
||||
("end", 123),
|
||||
]
|
||||
|
||||
|
||||
def test_end_auto_finishes_active_transcription_as_partial() -> None:
|
||||
events: list[tuple[object, ...]] = []
|
||||
|
||||
class Repository:
|
||||
def start_call(
|
||||
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
||||
) -> dict[str, int]:
|
||||
events.append(("start", diagnosis_id, patient_id, call_type))
|
||||
return {"call_record_id": 902}
|
||||
|
||||
def start_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
*,
|
||||
language: str,
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"transcription-start",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
language,
|
||||
)
|
||||
)
|
||||
|
||||
def finish_call_transcription(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
transcription_session_id: str,
|
||||
expected_segment_count: int,
|
||||
*,
|
||||
status: str,
|
||||
) -> None:
|
||||
events.append(
|
||||
(
|
||||
"finish",
|
||||
diagnosis_id,
|
||||
call_record_id,
|
||||
transcription_session_id,
|
||||
expected_segment_count,
|
||||
status,
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
patient_id=8,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
lifecycle.start()
|
||||
lifecycle.start_transcription("session-auto-partial")
|
||||
ended = lifecycle.end("window-closed")
|
||||
|
||||
assert ended.result(timeout=2) is True
|
||||
assert lifecycle.wait(1) is True
|
||||
assert events == [
|
||||
("start", 123, 8, 2),
|
||||
("transcription-start", 123, 902, "session-auto-partial", "zh-CN"),
|
||||
("finish", 123, 902, "session-auto-partial", 0, "partial"),
|
||||
("end", 123),
|
||||
]
|
||||
|
||||
|
||||
def test_failed_start_prevents_bind_and_end_writes() -> None:
|
||||
events: list[str] = []
|
||||
|
||||
@@ -274,6 +479,80 @@ def test_failed_start_prevents_bind_and_end_writes() -> None:
|
||||
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) -> dict[str, int]:
|
||||
events.append(("start", diagnosis_id, call_type))
|
||||
return {"call_record_id": 903}
|
||||
|
||||
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_start_rejects_missing_record_id_without_using_ticket_fallback() -> None:
|
||||
class Repository:
|
||||
def start_call(self, diagnosis_id: int, *, call_type: int) -> dict[str, object]:
|
||||
del diagnosis_id, call_type
|
||||
return {}
|
||||
|
||||
request = VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor_42",
|
||||
user_sig="short-lived-ticket",
|
||||
target_user_id="patient_8",
|
||||
diagnosis_id=123,
|
||||
call_record_id=77,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
with pytest.raises(ValueError, match="startCall response did not include"):
|
||||
lifecycle.start().result(timeout=2)
|
||||
|
||||
assert lifecycle.started is False
|
||||
|
||||
|
||||
def test_https_document_policy_is_exact_and_origin_scoped() -> None:
|
||||
policy = TrustedDocumentPolicy.from_url(
|
||||
"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}
|
||||
File diff suppressed because one or more lines are too long
+632
File diff suppressed because one or more lines are too long
-532
File diff suppressed because one or more lines are too long
Vendored
+3
-3
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<script type="module" crossorigin src="./assets/index-le5ZH3pL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BuO_uDya.css">
|
||||
<script type="module" crossorigin src="./assets/index-R5GzqA8s.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CED5X2W4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+8
@@ -8,7 +8,9 @@
|
||||
"name": "doctor-video-companion",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tencentcloud/lite-chat": "1.6.18",
|
||||
"@trtc/calls-uikit-vue": "4.4.6",
|
||||
"tim-upload-plugin": "1.4.3",
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1432,6 +1434,12 @@
|
||||
"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": {
|
||||
"version": "2.10.19",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tencentcloud/lite-chat": "1.6.18",
|
||||
"@trtc/calls-uikit-vue": "4.4.6",
|
||||
"tim-upload-plugin": "1.4.3",
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+270
-17
@@ -1,33 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import { TUICallKit } from '@trtc/calls-uikit-vue'
|
||||
import { computed, nextTick, ref, watch } 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>>
|
||||
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>>
|
||||
transcriptionState: Readonly<Ref<string>>
|
||||
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')
|
||||
const transcriptionActive = computed(() => props.transcriptionState.value === 'recording')
|
||||
const transcriptionFailed = computed(() => props.transcriptionState.value === 'error')
|
||||
const transcriptionStatusText = computed(() => {
|
||||
if (props.transcriptionState.value === 'starting') return '自动录音启动中…'
|
||||
if (props.transcriptionState.value === 'recording') return '自动录音并转文字中'
|
||||
if (props.transcriptionState.value === 'stopping') return '正在保存录音文字…'
|
||||
if (props.transcriptionState.value === 'error') return '自动录音转文字失败'
|
||||
return '自动录音已结束'
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<main class="call-stage">
|
||||
<TUICallKit
|
||||
class="call-kit"
|
||||
:allowed-minimized="false"
|
||||
:allowed-full-screen="true"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
<div>
|
||||
<p class="eyebrow">中医视频面诊</p>
|
||||
<h1>{{ statusText.value }}</h1>
|
||||
<p v-if="phase.value === 'ready'" class="status-hint">通话凭证仅由业务后端签发</p>
|
||||
<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>
|
||||
|
||||
<div v-else class="live-status" role="status">
|
||||
<span class="status-dot status-dot--live" aria-hidden="true" />
|
||||
{{ statusText.value }}
|
||||
</div>
|
||||
<section v-if="videoVisible" class="video-layer" :class="{ 'video-layer--overlay': isChat }">
|
||||
<TUICallKit
|
||||
class="call-kit"
|
||||
:allowed-minimized="false"
|
||||
:allowed-full-screen="true"
|
||||
/>
|
||||
|
||||
<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" />
|
||||
<div>
|
||||
<p class="eyebrow">中医视频问诊</p>
|
||||
<h2>{{ statusText.value }}</h2>
|
||||
<p class="status-hint">视频通话凭证仅由业务服务器签发</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-else class="live-status" role="status">
|
||||
<span class="status-dot status-dot--live" aria-hidden="true" />
|
||||
{{ statusText.value }}
|
||||
</div>
|
||||
|
||||
<div v-if="isCalling" class="video-actions">
|
||||
<div
|
||||
v-if="canCapture"
|
||||
class="recording-status"
|
||||
:class="{
|
||||
'recording-status--active': transcriptionActive,
|
||||
'recording-status--error': transcriptionFailed,
|
||||
}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="recording-indicator" aria-hidden="true" />
|
||||
{{ transcriptionStatusText }}
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
Vendored
+25
@@ -1,5 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'tim-upload-plugin' {
|
||||
const plugin: unknown
|
||||
export default plugin
|
||||
}
|
||||
|
||||
interface DoctorCallConfig {
|
||||
SDKAppID?: number | string
|
||||
sdkAppId?: number | string
|
||||
@@ -9,6 +14,8 @@ interface DoctorCallConfig {
|
||||
targetUserId?: string
|
||||
patientUserId?: string
|
||||
diagnosisId: number | string
|
||||
patientName?: string
|
||||
mode?: 'chat' | 'video'
|
||||
}
|
||||
|
||||
interface DoctorCallApi {
|
||||
@@ -16,12 +23,30 @@ interface DoctorCallApi {
|
||||
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
|
||||
transcriptionResult(
|
||||
operation: 'start' | 'segment' | 'stop',
|
||||
sessionId: string,
|
||||
segmentId: string,
|
||||
ok: boolean,
|
||||
message: string,
|
||||
): void
|
||||
}
|
||||
|
||||
interface QtVideoBridge {
|
||||
notify?: (payload: string) => void
|
||||
saveScreenshot?: (dataUrl: string) => void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
doctorCall: DoctorCallApi
|
||||
doctorConsultation: DoctorConsultationApi
|
||||
qtVideoBridge?: QtVideoBridge
|
||||
qt?: { webChannelTransport?: unknown }
|
||||
QWebChannel?: new (
|
||||
|
||||
+957
-103
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,12 @@
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #f7f8fa;
|
||||
background: #0b0f14;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif;
|
||||
color: #132238;
|
||||
background: #eef3fb;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html,
|
||||
body,
|
||||
@@ -20,23 +18,222 @@ body,
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
textarea,
|
||||
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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
color: #f7f8fa;
|
||||
background:
|
||||
radial-gradient(circle at 50% 35%, rgba(39, 74, 83, 0.22), transparent 38%),
|
||||
#0b0f14;
|
||||
radial-gradient(circle at 50% 35%, rgba(60, 86, 130, .28), transparent 38%),
|
||||
#090d14;
|
||||
}
|
||||
.video-layer--overlay { position: fixed; z-index: 1000; inset: 0; }
|
||||
|
||||
.call-kit,
|
||||
.call-stage :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
||||
.video-layer :is(.TUICallKit-desktop, .TUICallKit-mobile, #tuicallkit-id) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
@@ -52,55 +249,25 @@ input {
|
||||
width: min(520px, calc(100% - 48px));
|
||||
padding: 30px 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 20px;
|
||||
background: rgba(19, 25, 32, 0.9);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.32);
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(19, 25, 32, .92);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, .32);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
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;
|
||||
}
|
||||
|
||||
.eyebrow { margin: 0 0 12px; color: #a3afbf; font-size: 12px; font-weight: 700; letter-spacing: .12em; }
|
||||
.status-card h2 { 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 rgba(119, 129, 140, 0.12);
|
||||
box-shadow: 0 0 0 5px rgba(119, 129, 140, .12);
|
||||
}
|
||||
|
||||
.status-dot--starting,
|
||||
.status-dot--live {
|
||||
background: #52c99a;
|
||||
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);
|
||||
}
|
||||
|
||||
.status-dot--live { background: #52c99a; box-shadow: 0 0 0 5px rgba(82, 201, 154, .14); }
|
||||
.status-dot--error { background: #f26d6d; box-shadow: 0 0 0 5px rgba(242, 109, 109, .14); }
|
||||
.live-status {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
@@ -111,17 +278,66 @@ input {
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 15, 20, 0.76);
|
||||
color: #e8edf2;
|
||||
background: rgba(11, 15, 20, .8);
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.live-status .status-dot { width: 7px; height: 7px; margin: 0; box-shadow: none; }
|
||||
|
||||
.live-status .status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
.video-actions {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
right: 22px;
|
||||
bottom: 24px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.video-actions button { padding: 10px 15px; border-radius: 10px; font-weight: 600; }
|
||||
.recording-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 15px;
|
||||
border: 1px solid rgba(255, 255, 255, .24);
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
background: rgba(22, 29, 39, .88);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.recording-status--active { border-color: rgba(244, 99, 115, .64); background: rgba(126, 35, 50, .9); }
|
||||
.recording-status--error { border-color: rgba(242, 109, 109, .56); color: #ffe4e7; background: rgba(100, 31, 40, .88); }
|
||||
.recording-indicator {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #f46373;
|
||||
box-shadow: 0 0 0 4px rgba(244, 99, 115, .16);
|
||||
}
|
||||
.recording-status--active .recording-indicator { animation: recording-pulse 1.25s ease-in-out infinite; }
|
||||
@keyframes recording-pulse {
|
||||
50% { box-shadow: 0 0 0 8px rgba(244, 99, 115, .04); opacity: .72; }
|
||||
}
|
||||
.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%; }
|
||||
}
|
||||
|
||||
@@ -361,12 +361,48 @@ class DiagnosisController extends BaseAdminController
|
||||
// 传递当前管理员ID
|
||||
$params['admin_id'] = $this->adminId;
|
||||
|
||||
$result = DiagnosisLogic::startCall($params);
|
||||
if ($result) {
|
||||
return $this->success('发起通话成功', [], 1, 1);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
$result = DiagnosisLogic::startCall($params, $this->adminInfo);
|
||||
if ($result !== false) {
|
||||
return $this->data($result);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
/** @notes 为当前医生的指定通话记录启动实时录音转写 */
|
||||
public function startCallTranscription()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::startCallTranscription($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** @notes 幂等写入当前通话的已完成转写分段 */
|
||||
public function upsertCallTranscriptSegments()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::upsertCallTranscriptSegments($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** @notes 完成当前通话转写并固化对话文字 */
|
||||
public function finishCallTranscription()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::finishCallTranscription($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 结束通话
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -957,8 +957,14 @@ 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:企微客户列表中改名带「删」的客户往往仍在列表中;
|
||||
* - 剔除继承客户:跟进人 add_way∈{201 内部成员共享, 202 管理员/负责人分配}(含在职/离职继承)。
|
||||
*
|
||||
* @param array<string, mixed>|null $mediaChannel
|
||||
* @param int[]|null $adminIds null means all active/unbound WeCom users
|
||||
@@ -981,7 +987,7 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$baseKey = self::requestRowsCacheKey('fans', [
|
||||
$baseKey = self::requestRowsCacheKey('fans-effective-v3', [
|
||||
$startTimestamp,
|
||||
$endTimestamp,
|
||||
self::mediaChannelCacheKey($mediaChannel),
|
||||
@@ -1019,12 +1025,33 @@ class ConversionLogic
|
||||
}
|
||||
}
|
||||
|
||||
$eventTable = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
|
||||
$query = Db::name('qywx_external_contact_event')
|
||||
->alias('e')
|
||||
->where('e.change_type', 'add_external_contact')
|
||||
->where('e.event_time', 'between', [$startTimestamp, $endTimestamp])
|
||||
->fieldRaw('e.user_id, COUNT(*) AS add_fans_count')
|
||||
->group('e.user_id');
|
||||
->where('e.user_id', '<>', '')
|
||||
->where('e.external_userid', '<>', '')
|
||||
->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]
|
||||
)
|
||||
->field(['e.user_id', 'e.external_userid'])
|
||||
->group('e.user_id, e.external_userid');
|
||||
if ($workWechatUserIds !== null) {
|
||||
$query->whereIn('e.user_id', $workWechatUserIds);
|
||||
}
|
||||
@@ -1032,11 +1059,114 @@ class ConversionLogic
|
||||
MediaChannelService::applyExternalUserChannelFilter($query, 'e.external_userid', $mediaChannel);
|
||||
}
|
||||
|
||||
self::$requestRowsCache[$cacheKey] = $query->select()->toArray();
|
||||
$pairs = $query->select()->toArray();
|
||||
$pairs = self::excludeInheritedFanPairs($pairs);
|
||||
|
||||
$countsByUser = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
if ($userId === '') {
|
||||
continue;
|
||||
}
|
||||
$countsByUser[$userId] = ($countsByUser[$userId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($countsByUser as $userId => $count) {
|
||||
$rows[] = [
|
||||
'user_id' => $userId,
|
||||
'add_fans_count' => $count,
|
||||
];
|
||||
}
|
||||
|
||||
self::$requestRowsCache[$cacheKey] = $rows;
|
||||
|
||||
return self::$requestRowsCache[$cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* 剔除企微「继承/分配」客户:跟进人 add_way 为 201(内部成员共享)或 202(管理员/负责人分配,含在职/离职继承)。
|
||||
* 无本地客户档案或跟进信息不含该员工时保守保留(无法判定则仍计加粉)。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $pairs
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function excludeInheritedFanPairs(array $pairs): array
|
||||
{
|
||||
if ($pairs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$externalUserIds = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($extId !== '') {
|
||||
$externalUserIds[$extId] = true;
|
||||
}
|
||||
}
|
||||
$externalIdList = array_keys($externalUserIds);
|
||||
if ($externalIdList === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/** @var array<string, true> $inheritedKeys user_id\0external_userid */
|
||||
$inheritedKeys = [];
|
||||
foreach (array_chunk($externalIdList, 500) as $chunk) {
|
||||
$contactRows = Db::name('qywx_external_contact')
|
||||
->whereIn('external_userid', $chunk)
|
||||
->field(['external_userid', 'follow_users'])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($contactRows as $contact) {
|
||||
$extId = trim((string) ($contact['external_userid'] ?? ''));
|
||||
if ($extId === '') {
|
||||
continue;
|
||||
}
|
||||
$followUsers = $contact['follow_users'] ?? null;
|
||||
if (\is_string($followUsers) && $followUsers !== '') {
|
||||
$decoded = json_decode($followUsers, true);
|
||||
$followUsers = \is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
if (!\is_array($followUsers)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($followUsers as $fu) {
|
||||
if (!\is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$addWay = (int) ($fu['add_way'] ?? $fu['AddWay'] ?? 0);
|
||||
if ($addWay !== 201 && $addWay !== 202) {
|
||||
continue;
|
||||
}
|
||||
$followUserId = trim((string) ($fu['userid'] ?? $fu['UserId'] ?? ''));
|
||||
if ($followUserId === '') {
|
||||
continue;
|
||||
}
|
||||
$inheritedKeys[$followUserId . "\0" . $extId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($inheritedKeys === []) {
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
$kept = [];
|
||||
foreach ($pairs as $pair) {
|
||||
$userId = trim((string) ($pair['user_id'] ?? ''));
|
||||
$extId = trim((string) ($pair['external_userid'] ?? ''));
|
||||
if ($userId === '' || $extId === '') {
|
||||
continue;
|
||||
}
|
||||
if (isset($inheritedKeys[$userId . "\0" . $extId])) {
|
||||
continue;
|
||||
}
|
||||
$kept[] = $pair;
|
||||
}
|
||||
|
||||
return $kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $userIds
|
||||
* @return array<string, array{id: int|string, name: string}>
|
||||
@@ -1601,9 +1731,14 @@ class ConversionLogic
|
||||
$entity['account_cost'] = $effectiveAccountCost;
|
||||
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||
// 预约率:面诊 / 预约(看预约后未到面)
|
||||
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||
// 接诊率:接诊诊单 / 总进线(加粉);医生维度仍用面诊作分母
|
||||
$entity['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, $dimension);
|
||||
// 面诊接诊率:接诊诊单 / 面诊
|
||||
$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['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||
$entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
|
||||
@@ -1837,6 +1972,7 @@ class ConversionLogic
|
||||
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||
$node['receive_rate'] = self::receiveRate($completedOrderCount, $addFansCount, $interviewCount, 'dept');
|
||||
$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['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||
$node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
|
||||
@@ -2258,6 +2394,7 @@ class ConversionLogic
|
||||
'interview_rate' => 0.0,
|
||||
'receive_rate' => 0.0,
|
||||
'interview_receive_rate' => 0.0,
|
||||
'interview_paid_rate' => 0.0,
|
||||
'open_receive_rate' => 0.0,
|
||||
'avg_unit_price' => 0.0,
|
||||
'cash_cost' => 0.0,
|
||||
@@ -2324,6 +2461,7 @@ class ConversionLogic
|
||||
'member'
|
||||
),
|
||||
'interview_receive_rate' => self::percent($completedOrderCount, $interviewCount),
|
||||
'interview_paid_rate' => self::percent($interviewCount, $paidAppointmentCount),
|
||||
'open_receive_rate' => self::percent($completedOrderCount, $totalOpenCount),
|
||||
'avg_unit_price' => self::safeDivideMoney($completedOrderAmount, $completedOrderCount),
|
||||
'cash_cost' => self::safeDivideMoney($effectiveAccountCost, $addFansCount),
|
||||
@@ -2500,6 +2638,7 @@ class ConversionLogic
|
||||
$dimension
|
||||
);
|
||||
$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['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']);
|
||||
@@ -2681,6 +2820,10 @@ class ConversionLogic
|
||||
return $charts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接诊率:接诊诊单 ÷ 总进线(加粉)。
|
||||
* 医生维度无加粉口径时,退化为接诊诊单 ÷ 面诊。
|
||||
*/
|
||||
private static function receiveRate(int $completedOrderCount, int $addFansCount, int $interviewCount, string $dimension): float
|
||||
{
|
||||
$denominator = $dimension === 'doctor' ? $interviewCount : $addFansCount;
|
||||
|
||||
@@ -1572,32 +1572,67 @@ class DiagnosisLogic extends BaseLogic
|
||||
/**
|
||||
* @notes 发起通话
|
||||
* @param array $params
|
||||
* @return bool
|
||||
*/
|
||||
public static function startCall(array $params): bool
|
||||
{
|
||||
try {
|
||||
// 获取当前管理员ID(从参数中获取)
|
||||
$adminId = $params['admin_id'] ?? 0;
|
||||
|
||||
if (!$adminId) {
|
||||
self::setError('获取管理员信息失败');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建通话记录
|
||||
\app\common\model\tcm\CallRecord::create([
|
||||
'diagnosis_id' => $params['diagnosis_id'],
|
||||
'caller_id' => $adminId,
|
||||
'caller_type' => 'doctor',
|
||||
'callee_id' => $params['patient_id'] ?? 0,
|
||||
'callee_type' => 'patient',
|
||||
'call_type' => $params['call_type'] ?? 2, // 1-语音 2-视频
|
||||
* @return array{call_record_id:int}|false
|
||||
*/
|
||||
public static function startCall(array $params, array $adminInfo = [])
|
||||
{
|
||||
try {
|
||||
$adminId = (int)($params['admin_id'] ?? 0);
|
||||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||||
$patientId = (int)($params['patient_id'] ?? 0);
|
||||
$callType = (int)($params['call_type'] ?? 2);
|
||||
if ($adminId <= 0 || $diagnosisId <= 0 || $patientId <= 0) {
|
||||
self::setError('通话身份参数无效');
|
||||
return false;
|
||||
}
|
||||
if (!in_array($callType, [1, 2], true)) {
|
||||
self::setError('通话类型无效');
|
||||
return false;
|
||||
}
|
||||
$diagnosisQuery = Diagnosis::where('id', $diagnosisId)
|
||||
->where('patient_id', $patientId);
|
||||
$roleIds = array_map(
|
||||
'intval',
|
||||
AdminRole::where('admin_id', $adminId)->column('role_id')
|
||||
);
|
||||
if (in_array(2, $roleIds, true)) {
|
||||
$diagnosisQuery->where('assistant_id', $adminId);
|
||||
}
|
||||
if (DataScopeService::isEnabled()) {
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === []) {
|
||||
self::setError('诊单不存在或无权访问');
|
||||
return false;
|
||||
}
|
||||
if (is_array($visibleIds)) {
|
||||
$diagnosisQuery->whereIn('assistant_id', $visibleIds);
|
||||
}
|
||||
}
|
||||
$diagnosis = $diagnosisQuery->find();
|
||||
if (!$diagnosis) {
|
||||
self::setError('诊单不存在、患者不匹配或无权访问');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建通话记录
|
||||
$record = \app\common\model\tcm\CallRecord::create([
|
||||
'diagnosis_id' => $diagnosisId,
|
||||
'caller_id' => $adminId,
|
||||
'caller_type' => 'doctor',
|
||||
'callee_id' => $patientId,
|
||||
'callee_type' => 'patient',
|
||||
'call_type' => $callType,
|
||||
'status' => 1, // 1-进行中
|
||||
'start_time' => time()
|
||||
]);
|
||||
|
||||
return true;
|
||||
'start_time' => time()
|
||||
]);
|
||||
|
||||
$callRecordId = (int)($record['id'] ?? 0);
|
||||
if ($callRecordId <= 0) {
|
||||
self::setError('创建通话记录后未取得记录ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
return ['call_record_id' => $callRecordId];
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
@@ -1807,7 +1842,7 @@ class DiagnosisLogic extends BaseLogic
|
||||
* @param array $params
|
||||
* @return array
|
||||
*/
|
||||
public static function getCallRecords(array $params): array
|
||||
public static function getCallRecords(array $params): array
|
||||
{
|
||||
try {
|
||||
$records = \app\common\model\tcm\CallRecord::where('diagnosis_id', $params['diagnosis_id'])
|
||||
@@ -1826,16 +1861,323 @@ class DiagnosisLogic extends BaseLogic
|
||||
$urls = $decoded;
|
||||
}
|
||||
}
|
||||
$record['recording_urls_list'] = $urls;
|
||||
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
|
||||
}
|
||||
$record['recording_urls_list'] = $urls;
|
||||
$record['recording_status_text'] = self::recordingStatusText((int)($record['recording_status'] ?? 0));
|
||||
$record['transcription_status_text'] = self::transcriptionStatusText(
|
||||
(string)($record['transcription_status'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
return $records;
|
||||
} catch (\Exception $e) {
|
||||
self::setError($e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 为指定通话记录创建幂等的实时转写会话
|
||||
* @return array|false
|
||||
*/
|
||||
public static function startCallTranscription(array $params)
|
||||
{
|
||||
try {
|
||||
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
|
||||
$language = trim((string)($params['language'] ?? 'zh-CN'));
|
||||
if ($sessionId === '' || mb_strlen($sessionId) > 128) {
|
||||
self::setError('转写会话ID无效');
|
||||
return false;
|
||||
}
|
||||
if ($language === '' || mb_strlen($language) > 32) {
|
||||
self::setError('转写语言无效');
|
||||
return false;
|
||||
}
|
||||
return \think\facade\Db::transaction(function () use ($params, $sessionId, $language) {
|
||||
$record = self::resolveCallRecordForTranscription($params, true, true);
|
||||
if (!$record) {
|
||||
return false;
|
||||
}
|
||||
$existingSession = trim((string)($record['transcription_session_id'] ?? ''));
|
||||
if ($existingSession !== '' && $existingSession !== $sessionId) {
|
||||
self::setError('该通话记录已绑定其他转写会话');
|
||||
return false;
|
||||
}
|
||||
if ($existingSession === '') {
|
||||
$now = time();
|
||||
$record->save([
|
||||
'transcription_session_id' => $sessionId,
|
||||
'transcription_language' => $language,
|
||||
'transcription_status' => 'running',
|
||||
'transcription_segment_count' => 0,
|
||||
'transcript_text' => '',
|
||||
'transcription_started_at' => $now,
|
||||
'transcription_finished_at' => 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'call_record_id' => (int)$record['id'],
|
||||
'transcription_session_id' => $sessionId,
|
||||
'status' => (string)($record['transcription_status'] ?? 'running'),
|
||||
];
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 幂等写入已完成的实时转写分段,并刷新通话记录文字字段
|
||||
* @return array|false
|
||||
*/
|
||||
public static function upsertCallTranscriptSegments(array $params)
|
||||
{
|
||||
try {
|
||||
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
|
||||
$segments = $params['segments'] ?? null;
|
||||
if ($sessionId === '' || mb_strlen($sessionId) > 128) {
|
||||
self::setError('转写会话ID无效');
|
||||
return false;
|
||||
}
|
||||
if (!is_array($segments) || count($segments) < 1 || count($segments) > 50) {
|
||||
self::setError('转写分段数量必须为1到50条');
|
||||
return false;
|
||||
}
|
||||
$normalized = [];
|
||||
foreach ($segments as $segment) {
|
||||
if (!is_array($segment)) {
|
||||
self::setError('转写分段格式无效');
|
||||
return false;
|
||||
}
|
||||
$segmentId = trim((string)($segment['segment_id'] ?? ''));
|
||||
$text = trim((string)($segment['text'] ?? ''));
|
||||
$speakerUserId = trim((string)($segment['speaker_user_id'] ?? ''));
|
||||
$speakerRole = trim((string)($segment['speaker_role'] ?? 'unknown'));
|
||||
if ($segmentId === '' || mb_strlen($segmentId) > 160) {
|
||||
self::setError('转写分段ID无效');
|
||||
return false;
|
||||
}
|
||||
if ($text === '' || mb_strlen($text) > 4000) {
|
||||
self::setError('转写文字长度无效');
|
||||
return false;
|
||||
}
|
||||
if (mb_strlen($speakerUserId) > 160) {
|
||||
self::setError('说话人ID过长');
|
||||
return false;
|
||||
}
|
||||
if (!in_array($speakerRole, ['doctor', 'patient', 'unknown'], true)) {
|
||||
$speakerRole = 'unknown';
|
||||
}
|
||||
$normalized[] = [
|
||||
'segment_id' => $segmentId,
|
||||
'speaker_user_id' => $speakerUserId,
|
||||
'speaker_role' => $speakerRole,
|
||||
'timestamp_ms' => max(0, (int)($segment['timestamp'] ?? 0)),
|
||||
'text' => $text,
|
||||
];
|
||||
}
|
||||
|
||||
$result = \think\facade\Db::transaction(function () use (
|
||||
$params,
|
||||
$sessionId,
|
||||
$normalized
|
||||
) {
|
||||
$record = self::resolveCallRecordForTranscription($params, false, true);
|
||||
if (!$record) {
|
||||
return false;
|
||||
}
|
||||
if ((string)($record['transcription_session_id'] ?? '') !== $sessionId) {
|
||||
self::setError('转写会话与通话记录不匹配');
|
||||
return false;
|
||||
}
|
||||
if ((string)($record['transcription_status'] ?? '') !== 'running') {
|
||||
self::setError('该通话转写已经结束');
|
||||
return false;
|
||||
}
|
||||
$callRecordId = (int)$record['id'];
|
||||
$now = time();
|
||||
foreach ($normalized as $segment) {
|
||||
$values = array_merge($segment, [
|
||||
'call_record_id' => $callRecordId,
|
||||
'transcription_session_id' => $sessionId,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
\think\facade\Db::name('tcm_call_transcript_segment')
|
||||
->duplicate([
|
||||
'speaker_user_id',
|
||||
'speaker_role',
|
||||
'timestamp_ms',
|
||||
'text',
|
||||
'update_time',
|
||||
])
|
||||
->insert($values);
|
||||
}
|
||||
$snapshot = self::buildCallTranscriptSnapshot($callRecordId, $sessionId);
|
||||
$record->save([
|
||||
'transcription_segment_count' => $snapshot['segment_count'],
|
||||
'transcript_text' => $snapshot['transcript_text'],
|
||||
'update_time' => $now,
|
||||
]);
|
||||
return [
|
||||
'call_record_id' => $callRecordId,
|
||||
'segment_count' => (int)$snapshot['segment_count'],
|
||||
];
|
||||
});
|
||||
if ($result === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'call_record_id' => (int)$result['call_record_id'],
|
||||
'transcription_session_id' => $sessionId,
|
||||
'stored_segment_count' => (int)$result['segment_count'],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 完成实时转写并将最终文字固化在通话记录上
|
||||
* @return array|false
|
||||
*/
|
||||
public static function finishCallTranscription(array $params)
|
||||
{
|
||||
try {
|
||||
$sessionId = trim((string)($params['transcription_session_id'] ?? ''));
|
||||
$requestedStatus = strtolower(trim((string)($params['status'] ?? 'completed')));
|
||||
$expectedCount = (int)($params['expected_segment_count'] ?? 0);
|
||||
if ($sessionId === '' || mb_strlen($sessionId) > 128 || $expectedCount < 0) {
|
||||
self::setError('转写完成参数无效');
|
||||
return false;
|
||||
}
|
||||
if (!in_array($requestedStatus, ['completed', 'partial', 'failed'], true)) {
|
||||
self::setError('转写完成状态无效');
|
||||
return false;
|
||||
}
|
||||
return \think\facade\Db::transaction(function () use (
|
||||
$params,
|
||||
$sessionId,
|
||||
$requestedStatus,
|
||||
$expectedCount
|
||||
) {
|
||||
$record = self::resolveCallRecordForTranscription($params, false, true);
|
||||
if (!$record) {
|
||||
return false;
|
||||
}
|
||||
if ((string)($record['transcription_session_id'] ?? '') !== $sessionId) {
|
||||
self::setError('转写会话与通话记录不匹配');
|
||||
return false;
|
||||
}
|
||||
$existingStatus = (string)($record['transcription_status'] ?? '');
|
||||
if ($existingStatus !== '' && $existingStatus !== 'running') {
|
||||
return [
|
||||
'call_record_id' => (int)$record['id'],
|
||||
'transcription_session_id' => $sessionId,
|
||||
'status' => $existingStatus,
|
||||
'segment_count' => (int)($record['transcription_segment_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
$callRecordId = (int)$record['id'];
|
||||
$snapshot = self::buildCallTranscriptSnapshot($callRecordId, $sessionId);
|
||||
$actualCount = (int)$snapshot['segment_count'];
|
||||
$finalStatus = $requestedStatus;
|
||||
if ($requestedStatus === 'completed' && $actualCount < $expectedCount) {
|
||||
$finalStatus = 'partial';
|
||||
}
|
||||
$now = time();
|
||||
$record->save([
|
||||
'transcription_status' => $finalStatus,
|
||||
'transcription_segment_count' => $actualCount,
|
||||
'transcript_text' => $snapshot['transcript_text'],
|
||||
'transcription_finished_at' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
return [
|
||||
'call_record_id' => $callRecordId,
|
||||
'transcription_session_id' => $sessionId,
|
||||
'status' => $finalStatus,
|
||||
'segment_count' => $actualCount,
|
||||
];
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function resolveCallRecordForTranscription(
|
||||
array $params,
|
||||
bool $requireRunning,
|
||||
bool $lock = false
|
||||
)
|
||||
{
|
||||
$diagnosisId = (int)($params['diagnosis_id'] ?? 0);
|
||||
$callRecordId = (int)($params['call_record_id'] ?? 0);
|
||||
$adminId = (int)($params['admin_id'] ?? 0);
|
||||
if ($diagnosisId <= 0 || $callRecordId <= 0 || $adminId <= 0) {
|
||||
self::setError('通话转写身份参数无效');
|
||||
return null;
|
||||
}
|
||||
$query = \app\common\model\tcm\CallRecord::where('id', $callRecordId)
|
||||
->where('diagnosis_id', $diagnosisId)
|
||||
->where('caller_id', $adminId)
|
||||
->where('caller_type', 'doctor');
|
||||
if ($lock) {
|
||||
$query->lock(true);
|
||||
}
|
||||
$record = $query->find();
|
||||
if (!$record) {
|
||||
self::setError('通话记录不存在或无权操作');
|
||||
return null;
|
||||
}
|
||||
if ($requireRunning && (int)($record['status'] ?? 0) !== 1) {
|
||||
self::setError('通话记录已经结束');
|
||||
return null;
|
||||
}
|
||||
return $record;
|
||||
}
|
||||
|
||||
/** @return array{segment_count:int,transcript_text:string} */
|
||||
private static function buildCallTranscriptSnapshot(int $callRecordId, string $sessionId): array
|
||||
{
|
||||
$rows = \think\facade\Db::name('tcm_call_transcript_segment')
|
||||
->where('call_record_id', $callRecordId)
|
||||
->where('transcription_session_id', $sessionId)
|
||||
->order('timestamp_ms asc, id asc')
|
||||
->select()
|
||||
->toArray();
|
||||
$labels = ['doctor' => '医生', 'patient' => '患者', 'unknown' => '未知说话人'];
|
||||
$lines = [];
|
||||
foreach ($rows as $row) {
|
||||
$text = trim((string)($row['text'] ?? ''));
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
$role = (string)($row['speaker_role'] ?? 'unknown');
|
||||
$lines[] = ($labels[$role] ?? $labels['unknown']) . ':' . $text;
|
||||
}
|
||||
return [
|
||||
'segment_count' => count($rows),
|
||||
'transcript_text' => implode("\n", $lines),
|
||||
];
|
||||
}
|
||||
|
||||
private static function transcriptionStatusText(string $status): string
|
||||
{
|
||||
return [
|
||||
'running' => '录音转写中',
|
||||
'completed' => '文字已生成',
|
||||
'partial' => '文字部分保存',
|
||||
'failed' => '文字生成失败',
|
||||
][$status] ?? '未生成文字';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 将 TRTC 房间号写入当前诊单通话记录,并尝试 API 合流云端录制
|
||||
|
||||
@@ -3963,9 +3963,9 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
// 药方名称反查:处方库(开方医师 creator_id + 公开模板),按 formula_type + 药材集合匹配,
|
||||
// 复刻前端 resolveSlipAuxLibraryName 口径(主方/辅方均如此;处方记录 prescription_name 仅做兜底)。
|
||||
[$libByDoctor, $libPublic] = self::buildPrescriptionLibraryNameIndexForExport($rxById);
|
||||
// 药方名称反查:处方库(开方医师 → 公开 → 全库同药材集合兜底),按 formula_type + 药材集合匹配。
|
||||
// 辅方模板额外全量加载,避免「系统代开 / 非模板所属医师」因 creator 范围过窄丢辅方名。
|
||||
[$libByDoctor, $libPublic, $libAny] = self::buildPrescriptionLibraryNameIndexForExport($rxById);
|
||||
|
||||
if ($needGuahaoChannel) {
|
||||
$yejiChannelHighlightByDiag = [];
|
||||
@@ -4174,11 +4174,12 @@ class PrescriptionOrderLogic
|
||||
|
||||
$item['export_medication_form'] = self::formatMedicationFormForExport(\is_array($rx) ? $rx : []);
|
||||
|
||||
// 药方名称:主方/辅方均反查处方库取名称(与处方笺一致),带标签;甘草/无匹配留空
|
||||
// 药方名称:主方/辅方均反查处方库取名称(与处方笺一致),带标签;有辅方药材时即使未匹配到库名也保留「辅方」标记
|
||||
[$mainRxName, $auxRxName] = self::resolvePrescriptionNamesForExport(
|
||||
\is_array($rx) ? $rx : [],
|
||||
$libByDoctor,
|
||||
$libPublic
|
||||
$libPublic,
|
||||
$libAny
|
||||
);
|
||||
$rxNameParts = [];
|
||||
if ($mainRxName !== '') {
|
||||
@@ -4298,19 +4299,24 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出药方名称反查:批量加载相关开方医师处方库(creator_id 命中 + 公开模板),
|
||||
* 建立 [creator_id][formula_type][herbKey] => name 与 公开 [formula_type][herbKey] => name 两级索引。
|
||||
* 导出药方名称反查:批量加载相关开方医师处方库(creator_id 命中 + 公开模板 + 全量辅方模板),
|
||||
* 建立三级索引:医师私有 / 公开 / 任意已加载(跨医师同药材集合兜底)。
|
||||
*
|
||||
* @param array<int, array> $rxById 处方 id => 处方行(需含 herbs / creator_id)
|
||||
*
|
||||
* @return array{0: array<int, array<string, array<string, string>>>, 1: array<string, array<string, string>>}
|
||||
* @return array{
|
||||
* 0: array<int, array<string, array<string, string>>>,
|
||||
* 1: array<string, array<string, string>>,
|
||||
* 2: array<string, array<string, string>>
|
||||
* }
|
||||
*/
|
||||
private static function buildPrescriptionLibraryNameIndexForExport(array $rxById): array
|
||||
{
|
||||
$libByDoctor = [];
|
||||
$libPublic = [];
|
||||
$libAny = [];
|
||||
if ($rxById === []) {
|
||||
return [$libByDoctor, $libPublic];
|
||||
return [$libByDoctor, $libPublic, $libAny];
|
||||
}
|
||||
|
||||
$doctorIds = [];
|
||||
@@ -4323,12 +4329,15 @@ class PrescriptionOrderLogic
|
||||
$doctorIdList = array_keys($doctorIds);
|
||||
|
||||
try {
|
||||
// 开方医师私有 + 公开模板 + 全量辅方模板(辅方常被非模板所有者的「系统代开」处方复用)
|
||||
$libRows = PrescriptionLibrary::whereNull('delete_time')
|
||||
->where(function ($q) use ($doctorIdList) {
|
||||
if ($doctorIdList !== []) {
|
||||
$q->whereIn('creator_id', $doctorIdList)->whereOr('is_public', 1);
|
||||
$q->whereIn('creator_id', $doctorIdList)
|
||||
->whereOr('is_public', 1)
|
||||
->whereOr('formula_type', '辅方');
|
||||
} else {
|
||||
$q->where('is_public', 1);
|
||||
$q->where('is_public', 1)->whereOr('formula_type', '辅方');
|
||||
}
|
||||
})
|
||||
->field(['prescription_name', 'formula_type', 'herbs', 'creator_id', 'is_public'])
|
||||
@@ -4337,7 +4346,7 @@ class PrescriptionOrderLogic
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('buildPrescriptionLibraryNameIndexForExport failed: ' . $e->getMessage());
|
||||
|
||||
return [$libByDoctor, $libPublic];
|
||||
return [$libByDoctor, $libPublic, $libAny];
|
||||
}
|
||||
|
||||
foreach ($libRows as $lib) {
|
||||
@@ -4359,6 +4368,9 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$ft = ((string) ($lib['formula_type'] ?? '')) === '辅方' ? '辅方' : '主方';
|
||||
|
||||
// 跨医师兜底:同药材集合优先保留先写入的名称(稳定、可预期)
|
||||
$libAny[$ft][$key] ??= $name;
|
||||
|
||||
if ((int) ($lib['is_public'] ?? 0) === 1) {
|
||||
$libPublic[$ft][$key] ??= $name;
|
||||
}
|
||||
@@ -4368,23 +4380,28 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
return [$libByDoctor, $libPublic];
|
||||
return [$libByDoctor, $libPublic, $libAny];
|
||||
}
|
||||
|
||||
/**
|
||||
* 反查单张处方的主方 / 辅方名称(与处方笺展示一致):
|
||||
* - 主方:主方药材集合匹配开方医师/公开处方库(formula_type=主方);兜底取处方 prescription_name。
|
||||
* - 主方:主方药材集合匹配 开方医师 → 公开 → 任意已加载;兜底取处方 prescription_name。
|
||||
* - 辅方:仅在实际存在辅方药材时,优先 aux_usage.prescription_name / library_name,
|
||||
* 否则按辅方药材集合匹配(formula_type=辅方)。
|
||||
* 否则按辅方药材集合匹配;仍无匹配时回落为「辅方」(保证导出名含辅方标记)。
|
||||
*
|
||||
* @param array $rx 处方行(含 herbs / creator_id / prescription_name / aux_usage)
|
||||
* @param array<int, array<string, array<string, string>>> $libByDoctor
|
||||
* @param array<string, array<string, string>> $libPublic
|
||||
* @param array<string, array<string, string>> $libAny
|
||||
*
|
||||
* @return array{0: string, 1: string} [主方名称, 辅方名称]
|
||||
*/
|
||||
private static function resolvePrescriptionNamesForExport(array $rx, array $libByDoctor, array $libPublic): array
|
||||
{
|
||||
private static function resolvePrescriptionNamesForExport(
|
||||
array $rx,
|
||||
array $libByDoctor,
|
||||
array $libPublic,
|
||||
array $libAny = []
|
||||
): array {
|
||||
if ($rx === []) {
|
||||
return ['', ''];
|
||||
}
|
||||
@@ -4393,7 +4410,7 @@ class PrescriptionOrderLogic
|
||||
|
||||
[$mainHerbs, $auxHerbs] = self::splitPrescriptionHerbsFromRx($rx);
|
||||
|
||||
$lookup = static function (string $ft, array $hs) use ($doctorId, $libByDoctor, $libPublic): string {
|
||||
$lookup = static function (string $ft, array $hs) use ($doctorId, $libByDoctor, $libPublic, $libAny): string {
|
||||
if ($hs === []) {
|
||||
return '';
|
||||
}
|
||||
@@ -4404,8 +4421,11 @@ class PrescriptionOrderLogic
|
||||
if ($doctorId > 0 && isset($libByDoctor[$doctorId][$ft][$key])) {
|
||||
return $libByDoctor[$doctorId][$ft][$key];
|
||||
}
|
||||
if (isset($libPublic[$ft][$key])) {
|
||||
return $libPublic[$ft][$key];
|
||||
}
|
||||
|
||||
return (string) ($libPublic[$ft][$key] ?? '');
|
||||
return (string) ($libAny[$ft][$key] ?? '');
|
||||
};
|
||||
|
||||
// 主方
|
||||
@@ -4432,6 +4452,10 @@ class PrescriptionOrderLogic
|
||||
if ($auxName === '') {
|
||||
$auxName = $lookup('辅方', $auxHerbs);
|
||||
}
|
||||
// 有辅方药材但未匹配到库名/持久化名时,仍输出标记,避免导出名静默丢掉辅方
|
||||
if ($auxName === '') {
|
||||
$auxName = '辅方';
|
||||
}
|
||||
|
||||
return [$mainName, $auxName];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 医生工作站视频接通后的实时录音转写。
|
||||
-- 转写摘要固化到每条通话记录,分段表用唯一键保证 WebChannel 重试幂等。
|
||||
|
||||
ALTER TABLE `zyt_tcm_call_record`
|
||||
ADD COLUMN `transcription_session_id` varchar(128) NOT NULL DEFAULT '' COMMENT '实时转写会话ID',
|
||||
ADD COLUMN `transcription_language` varchar(32) NOT NULL DEFAULT '' COMMENT '转写源语言',
|
||||
ADD COLUMN `transcription_status` varchar(16) NOT NULL DEFAULT '' COMMENT 'running/completed/partial/failed',
|
||||
ADD COLUMN `transcription_segment_count` int unsigned NOT NULL DEFAULT 0 COMMENT '已持久化完成分段数',
|
||||
ADD COLUMN `transcript_text` longtext NULL COMMENT '按时间和说话人生成的面诊对话文字',
|
||||
ADD COLUMN `transcription_started_at` int unsigned NOT NULL DEFAULT 0 COMMENT '转写开始Unix时间',
|
||||
ADD COLUMN `transcription_finished_at` int unsigned NOT NULL DEFAULT 0 COMMENT '转写完成Unix时间';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `zyt_tcm_call_transcript_segment` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '分段ID',
|
||||
`call_record_id` int unsigned NOT NULL COMMENT '通话记录ID',
|
||||
`transcription_session_id` varchar(128) NOT NULL COMMENT '实时转写会话ID',
|
||||
`segment_id` varchar(160) NOT NULL COMMENT '腾讯云完成分段ID',
|
||||
`speaker_user_id` varchar(160) NOT NULL DEFAULT '' COMMENT '说话人TRTC用户ID',
|
||||
`speaker_role` varchar(20) NOT NULL DEFAULT 'unknown' COMMENT 'doctor/patient/unknown',
|
||||
`timestamp_ms` bigint unsigned NOT NULL DEFAULT 0 COMMENT '分段时间戳(毫秒)',
|
||||
`text` text NOT NULL COMMENT '完成分段文字,最多4000字符',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_call_session_segment` (`call_record_id`, `transcription_session_id`, `segment_id`),
|
||||
KEY `idx_call_session_time` (`call_record_id`, `transcription_session_id`, `timestamp_ms`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频面诊实时转写完成分段';
|
||||
@@ -0,0 +1,89 @@
|
||||
-- 实时转写接口继承现有“1v1通话”权限,避免未注册 action 被默认放行。
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
SET @video_call_menu_id := (
|
||||
SELECT `id`
|
||||
FROM `zyt_system_menu`
|
||||
WHERE `perms` = 'tcm.diagnosis/video-call'
|
||||
ORDER BY `id`
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
INSERT INTO `zyt_system_menu` (
|
||||
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||
)
|
||||
SELECT @video_call_menu_id, 'A', '启动通话转写', '', 1,
|
||||
'tcm.diagnosis/startCallTranscription', '', '', '', '', 0, 1, 0,
|
||||
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL
|
||||
WHERE @video_call_menu_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `zyt_system_menu`
|
||||
WHERE `perms` = 'tcm.diagnosis/startCallTranscription'
|
||||
);
|
||||
|
||||
INSERT INTO `zyt_system_menu` (
|
||||
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||
)
|
||||
SELECT @video_call_menu_id, 'A', '保存通话转写分段', '', 2,
|
||||
'tcm.diagnosis/upsertCallTranscriptSegments', '', '', '', '', 0, 1, 0,
|
||||
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL
|
||||
WHERE @video_call_menu_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `zyt_system_menu`
|
||||
WHERE `perms` = 'tcm.diagnosis/upsertCallTranscriptSegments'
|
||||
);
|
||||
|
||||
INSERT INTO `zyt_system_menu` (
|
||||
`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`,
|
||||
`selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`
|
||||
)
|
||||
SELECT @video_call_menu_id, 'A', '完成通话转写', '', 3,
|
||||
'tcm.diagnosis/finishCallTranscription', '', '', '', '', 0, 1, 0,
|
||||
UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
FROM DUAL
|
||||
WHERE @video_call_menu_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `zyt_system_menu`
|
||||
WHERE `perms` = 'tcm.diagnosis/finishCallTranscription'
|
||||
);
|
||||
|
||||
SET @transcription_start_menu_id := (
|
||||
SELECT `id` FROM `zyt_system_menu`
|
||||
WHERE `perms` = 'tcm.diagnosis/startCallTranscription'
|
||||
ORDER BY `id` LIMIT 1
|
||||
);
|
||||
SET @transcription_segment_menu_id := (
|
||||
SELECT `id` FROM `zyt_system_menu`
|
||||
WHERE `perms` = 'tcm.diagnosis/upsertCallTranscriptSegments'
|
||||
ORDER BY `id` LIMIT 1
|
||||
);
|
||||
SET @transcription_finish_menu_id := (
|
||||
SELECT `id` FROM `zyt_system_menu`
|
||||
WHERE `perms` = 'tcm.diagnosis/finishCallTranscription'
|
||||
ORDER BY `id` LIMIT 1
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT `role_id`, @transcription_start_menu_id
|
||||
FROM `zyt_system_role_menu`
|
||||
WHERE `menu_id` = @video_call_menu_id
|
||||
AND @transcription_start_menu_id IS NOT NULL;
|
||||
|
||||
INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT `role_id`, @transcription_segment_menu_id
|
||||
FROM `zyt_system_role_menu`
|
||||
WHERE `menu_id` = @video_call_menu_id
|
||||
AND @transcription_segment_menu_id IS NOT NULL;
|
||||
|
||||
INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT `role_id`, @transcription_finish_menu_id
|
||||
FROM `zyt_system_role_menu`
|
||||
WHERE `menu_id` = @video_call_menu_id
|
||||
AND @transcription_finish_menu_id IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import t from"./error-CjcUa6k8.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-CC7b2w5D.js";import"./index-Ur49qshF.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
import t from"./error-Db3RUzsa.js";import{o,q as r,r as a,v as n,D as c,s}from"./.pnpm-C1KTa5cB.js";import"./index-Cxx1IoGl.js";const p="/admin/assets/no_perms-jDxcYpYC.png",i={class:"error404"},x=o({__name:"403",setup(m){return(_,e)=>(r(),a("div",i,[n(t,{code:"403",title:"您的账号权限不足,请联系管理员添加权限!","show-btn":!1},{content:c(()=>[...e[0]||(e[0]=[s("div",{class:"flex justify-center"},[s("img",{class:"w-[150px] h-[150px]",src:p,alt:""})],-1)])]),_:1})]))}});export{x as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import e from"./error-CjcUa6k8.js";import{o,q as r,r as t,v as s}from"./.pnpm-CC7b2w5D.js";import"./index-Ur49qshF.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
import e from"./error-Db3RUzsa.js";import{o,q as r,r as t,v as s}from"./.pnpm-C1KTa5cB.js";import"./index-Cxx1IoGl.js";const a={class:"error404"},d=o({__name:"404",setup(c){return(n,_)=>(r(),t("div",a,[s(e,{code:"404",title:"哎呀,出错了!您访问的页面不存在…"})]))}});export{d as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-CC7b2w5D.js";import{a as V}from"./doctor-BNptQnoQ.js";import{m as A,_ as M}from"./index-Ur49qshF.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
import{o as C,R as D,q as d,r as I,ac as N,O as u,bj as B,D as o,v as l,bk as E,br as L,L as i,T as r,s as p,bi as R,M as w}from"./.pnpm-C1KTa5cB.js";import{a as V}from"./doctor-fOWXzRlL.js";import{m as A,_ as M}from"./index-Cxx1IoGl.js";const P={class:"appointment-record-panel"},$={class:"cell-stack"},j={class:"font-medium"},q={class:"text-gray-500 text-sm"},O={class:"cell-stack"},F={class:"text-gray-500 text-sm"},G=C({__name:"AppointmentRecordPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const f=v,m=w(!1),g=w([]),h=w({});function x(t){return t?String(t).length>=8?String(t).slice(0,5):t:""}function k(t){return t==="morning"?"上午":t==="afternoon"?"下午":t==="all"?"全天":t||"—"}function S(t){const n=t.channel_source??t.channels??"";if(n===""||n===null||n===void 0)return"—";const a=String(n),s=h.value[a]||a,c=String(t.channel_source_detail??"").trim();return c!==""?`${s}(${c})`:s}function T(t){return[...t].sort((n,a)=>{const s=String(n.appointment_date||""),c=String(a.appointment_date||"");if(s!==c)return c.localeCompare(s);const _=String(n.appointment_time||""),e=String(a.appointment_time||"");return _!==e?e.localeCompare(_):Number(a.id||0)-Number(n.id||0)})}const z=async()=>{try{const t=await A({type:"channels"}),n=((t==null?void 0:t.channels)||[]).filter(s=>s.status!==0),a={};for(const s of n)s.value!=null&&(a[String(s.value)]=s.name||String(s.value));h.value=a}catch{h.value={}}},b=async()=>{if(f.diagnosisId){m.value=!0;try{await z();const t=await V({patient_id:f.diagnosisId,diag_scope_relax:1,page_no:1,page_size:500}),n=(t==null?void 0:t.lists)||[];g.value=T(n)}catch(t){console.error(t),g.value=[]}finally{m.value=!1}}};return D(()=>f.diagnosisId,()=>{b()},{immediate:!0}),y({refresh:b}),(t,n)=>{const a=E,s=L,c=B,_=R;return d(),I("div",P,[N((d(),u(c,{data:g.value,border:"",stripe:"","empty-text":"暂无挂号记录"},{default:o(()=>[l(a,{label:"ID",prop:"id",width:"72",align:"center"}),l(a,{label:"状态",width:"100",align:"center"},{default:o(({row:e})=>[l(s,{type:e.status===1?"success":e.status===2?"info":e.status===3?"primary":"danger",size:"small",effect:"light"},{default:o(()=>[i(r(e.status_desc||"—"),1)]),_:2},1032,["type"])]),_:1}),l(a,{label:"患者(挂号人)","min-width":"150"},{default:o(({row:e})=>[p("div",$,[p("span",j,r(e.patient_name||"—"),1),p("span",q,r(e.patient_phone||""),1)])]),_:1}),l(a,{label:"挂号医生",prop:"doctor_name",width:"110","show-overflow-tooltip":""}),l(a,{label:"挂号助理",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.assistant_name||"—"),1)]),_:1}),l(a,{label:"预约时间","min-width":"130"},{default:o(({row:e})=>[p("div",O,[p("span",null,r(e.appointment_date||"—"),1),p("span",F,r(x(e.appointment_time)),1)])]),_:1}),l(a,{label:"时段",width:"80",align:"center"},{default:o(({row:e})=>[i(r(k(e.period)),1)]),_:1}),l(a,{label:"类型",width:"100","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(e.appointment_type_desc||"—"),1)]),_:1}),l(a,{label:"渠道",width:"110","show-overflow-tooltip":""},{default:o(({row:e})=>[i(r(S(e)),1)]),_:1}),l(a,{label:"确认诊单",width:"92",align:"center"},{default:o(({row:e})=>[e.diagnosis_confirmed?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[0]||(n[0]=[i("已确认",-1)])]),_:1})):(d(),u(s,{key:1,type:"warning",size:"small",effect:"plain"},{default:o(()=>[...n[1]||(n[1]=[i("未确认",-1)])]),_:1}))]),_:1}),l(a,{label:"开方",width:"80",align:"center"},{default:o(({row:e})=>[e.has_prescription?(d(),u(s,{key:0,type:"success",size:"small",effect:"plain"},{default:o(()=>[...n[2]||(n[2]=[i("已开方",-1)])]),_:1})):(d(),u(s,{key:1,type:"info",size:"small",effect:"plain"},{default:o(()=>[...n[3]||(n[3]=[i("未开方",-1)])]),_:1}))]),_:1}),l(a,{label:"备注",prop:"remark","min-width":"100","show-overflow-tooltip":""}),l(a,{label:"创建时间",width:"165",prop:"create_time"})]),_:1},8,["data"])),[[_,m.value]])])}}}),Q=M(G,[["__scopeId","data-v-4de87dfa"]]);export{Q as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as r,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ch as F,bi as M,M as w}from"./.pnpm-CC7b2w5D.js";import{aa as V}from"./tcm-CrnCrBQX.js";import{_ as q}from"./index-Ur49qshF.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:r(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:r(({row:i})=>[c(d(f(i,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:r(({row:i})=>[Number(i.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:r(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:r(({row:i})=>[c(d(N(i)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:r(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:r(()=>[n(o,{class:"assign-log-col-hint"},{default:r(()=>[n(B(F))]),_:1})]),_:1})]),default:r(({row:i})=>[c(d(x(i)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
import{o as T,R as C,q as _,r as b,ac as D,O as h,bj as $,D as i,v as n,bk as L,L as c,T as d,br as k,s as E,a1 as A,w as P,u as B,ci as F,bi as M,M as w}from"./.pnpm-C1KTa5cB.js";import{ae as V}from"./tcm-DJJMkqKG.js";import{_ as q}from"./index-Cxx1IoGl.js";const K={class:"assign-log-panel"},j={key:1,class:"text-gray-400"},z=T({__name:"AssignLogPanel",props:{diagnosisId:{}},setup(v,{expose:y}){const u=v,m=w(!1),p=w([]);function N(a){const e=a.related_po_creator_name;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_creator_id);return Number.isFinite(t)&&t>0?`ID:${t}`:"—"}function x(a){const e=a.related_po_create_time_text;if(e!=null&&String(e).trim()!=="")return String(e);const t=Number(a.related_po_create_time);if(!Number.isFinite(t)||t<=0)return"—";const s=new Date(t*1e3);if(Number.isNaN(s.getTime()))return"—";const o=l=>String(l).padStart(2,"0");return`${s.getFullYear()}-${o(s.getMonth()+1)}-${o(s.getDate())} ${o(s.getHours())}:${o(s.getMinutes())}:${o(s.getSeconds())}`}function f(a,e){const t=e==="from"?"from_assistant_name":"to_assistant_name",s=e==="from"?"from_assistant_id":"to_assistant_id",o=a[t];if(o!=null&&String(o).trim()!==""&&String(o)!=="—")return String(o);const l=Number(a[s]);return Number.isFinite(l)&&l>0?`ID:${l}`:"—"}const g=async()=>{if(u.diagnosisId){m.value=!0;try{const a=await V({id:u.diagnosisId}),e=Array.isArray(a)?a:[];p.value=e}catch(a){console.error(a),p.value=[]}finally{m.value=!1}}};return C(()=>u.diagnosisId,()=>{g()},{immediate:!0}),y({refresh:g}),(a,e)=>{const t=L,s=k,o=P,l=A,S=$,I=M;return _(),b("div",K,[D((_(),h(S,{data:p.value,border:"",stripe:"","empty-text":"暂无指派记录"},{default:i(()=>[n(t,{label:"操作时间",width:"175",prop:"create_time_text"}),n(t,{label:"原医助","min-width":"120"},{default:i(({row:r})=>[c(d(f(r,"from")),1)]),_:1}),n(t,{label:"新医助","min-width":"120"},{default:i(({row:r})=>[c(d(f(r,"to")),1)]),_:1}),n(t,{label:"继承",width:"72",align:"center"},{default:i(({row:r})=>[Number(r.is_inherit)===1?(_(),h(s,{key:0,type:"success",size:"small"},{default:i(()=>[...e[0]||(e[0]=[c("是",-1)])]),_:1})):(_(),b("span",j,"否"))]),_:1}),n(t,{label:"快照·业务单创建人","min-width":"130","show-overflow-tooltip":""},{default:i(({row:r})=>[c(d(N(r)),1)]),_:1}),n(t,{label:"快照·业务单创建时间",width:"190"},{header:i(()=>[e[1]||(e[1]=E("span",null,"快照·业务单创建时间",-1)),n(l,{placement:"top",content:"related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"},{default:i(()=>[n(o,{class:"assign-log-col-hint"},{default:i(()=>[n(B(F))]),_:1})]),_:1})]),default:i(({row:r})=>[c(d(x(r)),1)]),_:1}),n(t,{label:"操作人",width:"110",prop:"operator_name"}),n(t,{label:"操作账号",width:"120",prop:"operator_account","show-overflow-tooltip":""}),n(t,{label:"IP",width:"130",prop:"ip","show-overflow-tooltip":""})]),_:1},8,["data"])),[[I,m.value]])])}}}),Y=q(z,[["__scopeId","data-v-f670e3e6"]]);export{Y as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-CC7b2w5D.js";import{ab as Y}from"./tcm-CrnCrBQX.js";import{_ as q}from"./index-Ur49qshF.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(M){console.warn("[AssistantWatchCall] startRemoteVideo",M)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function b(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:b},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
import{o as B,R as D,q as E,O,D as I,r as _,T as S,P as x,ac as W,s as h,ad as P,v as U,K as $,L as j,bt as H,p as K,M as v,dg as c}from"./.pnpm-C1KTa5cB.js";import{af as Y}from"./tcm-DJJMkqKG.js";import{_ as q}from"./index-Cxx1IoGl.js";const z={key:0,class:"watch-state"},F={key:1,class:"watch-state watch-error"},G=B({__name:"AssistantWatchCallDialog",props:{modelValue:{type:Boolean},diagnosisId:{}},emits:["update:modelValue","closed"],setup(R,{emit:A}){const u=R,g=A,y=K({get:()=>u.modelValue,set:t=>g("update:modelValue",t)}),r=v(null),l=v(!1),i=v(""),f=v("旁观视频通话"),n=new Map;let a=null,p=0;function w(t,e){return`${t}\0${String(e)}`}function N(t){return t.startsWith("patient_")?"患者":t.startsWith("doctor_")?"医护":t}async function T(t){if(!a||!r.value||t.streamType!==c.TYPE.STREAM_TYPE_MAIN)return;const e=w(t.userId,t.streamType);if(n.has(e))return;const o=document.createElement("div");o.className="watch-tile";const s=document.createElement("div");s.className="watch-tile-cap",s.textContent=N(t.userId);const d=document.createElement("div");d.className="watch-tile-view",o.appendChild(s),o.appendChild(d),r.value.appendChild(o),n.set(e,{wrap:o,userId:t.userId,streamType:t.streamType});try{await a.startRemoteVideo({userId:t.userId,streamType:t.streamType,view:d})}catch(b){console.warn("[AssistantWatchCall] startRemoteVideo",b)}}async function V(t){if(!a)return;const e=w(t.userId,t.streamType),o=n.get(e);if(o){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}o.wrap.remove(),n.delete(e)}}function C(){a&&(a.on(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.on(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}function k(){a&&(a.off(c.EVENT.REMOTE_VIDEO_AVAILABLE,T),a.off(c.EVENT.REMOTE_VIDEO_UNAVAILABLE,V))}async function m(){if(k(),a){for(const[,t]of n){try{await a.stopRemoteVideo({userId:t.userId,streamType:t.streamType})}catch{}t.wrap.remove()}n.clear(),r.value&&(r.value.innerHTML="");try{await a.exitRoom()}catch{}try{a.destroy()}catch{}a=null}else n.clear(),r.value&&(r.value.innerHTML="")}async function L(){const t=++p;if(await m(),!u.diagnosisId){i.value="诊单无效";return}l.value=!0,i.value="",f.value="旁观视频通话";try{const e=await Y({diagnosis_id:u.diagnosisId});if(t!==p)return;e.patientName&&(f.value=`旁观视频通话 · ${e.patientName}`),a=c.create(),C();const o={sdkAppId:e.sdkAppId,userId:e.userId,userSig:e.userSig,autoReceiveAudio:!0,autoReceiveVideo:!0,...e.roomId!=null&&e.roomId>0?{roomId:e.roomId}:{strRoomId:e.strRoomId}};if(!(e.roomId!=null&&e.roomId>0)&&!e.strRoomId)throw new Error("缺少房间号");if(await a.enterRoom(o),t!==p){await m();return}l.value=!1}catch(e){l.value=!1;let o="进入房间失败";if(typeof e=="string")o=e;else if(e&&typeof e=="object"){const s=e;s.msg?o=String(s.msg):s.message&&(o=String(s.message))}i.value=o,await m()}}function M(){m(),l.value=!1,i.value="",f.value="旁观视频通话",g("closed")}return D(()=>[u.modelValue,u.diagnosisId],([t,e])=>{if(!t){p++,m();return}e>0&&L()}),(t,e)=>{const o=$,s=H;return E(),O(s,{modelValue:y.value,"onUpdate:modelValue":e[1]||(e[1]=d=>y.value=d),title:f.value,width:"760px","destroy-on-close":"","append-to-body":"","close-on-click-modal":!1,class:"assistant-watch-call-dialog",onClosed:M},{footer:I(()=>[e[3]||(e[3]=h("span",{class:"watch-hint"},"仅观看,不会开启摄像头与麦克风",-1)),U(o,{type:"primary",onClick:e[0]||(e[0]=d=>y.value=!1)},{default:I(()=>[...e[2]||(e[2]=[j("离开",-1)])]),_:1})]),default:I(()=>[l.value?(E(),_("div",z,"正在连接房间…")):i.value?(E(),_("div",F,S(i.value),1)):x("",!0),W(h("div",{ref_key:"gridRef",ref:r,class:"watch-grid"},null,512),[[P,!l.value&&!i.value]])]),_:1},8,["modelValue","title"])}}}),Z=q(G,[["__scopeId","data-v-7aff665d"]]);export{Z as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as z,T as u,s as y,bi as M,M as v}from"./.pnpm-CC7b2w5D.js";import j from"./RecordingPlaybackBlock-Ca8jCM_Z.js";import{U as x}from"./index-CSWtRkLa.js";import{i as c,_ as q}from"./index-Ur49qshF.js";import{af as K,ag as k,ah as A}from"./tcm-CrnCrBQX.js";import"./RecordingVideoPlayer-BsUV5wIC.js";import"./file-BtNv0rvz.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await k({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await k({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=z,I=L,B=M;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(x,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(j,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(x,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
import{o as N,di as O,R as P,q as d,r as f,v as o,D as i,K as V,L as n,P as h,ac as D,O as w,bj as L,bk as j,T as u,s as y,bi as z,M as v}from"./.pnpm-C1KTa5cB.js";import M from"./RecordingPlaybackBlock-BLiZhM1Y.js";import{U as k}from"./index-CZyPOnHt.js";import{i as c,_ as q}from"./index-Cxx1IoGl.js";import{aj as K,ak as x,al as A}from"./tcm-DJJMkqKG.js";import"./RecordingVideoPlayer-ByTd0pZe.js";import"./file-D2AwthSZ.js";const F={class:"call-record-panel"},G={key:0,class:"call-record-toolbar"},H={class:"call-record-empty"},J={class:"call-record-empty__desc"},Q={key:0,class:"text-primary"},W={key:1,class:"text-gray-400"},X=N({__name:"CallRecordPanel",props:{diagnosisId:{},readOnly:{type:Boolean,default:!1}},setup(_,{expose:R}){const r=_,p=v(!1),g=v([]),U=O("toolbarUploadRef"),m=async()=>{if(r.diagnosisId){p.value=!0;try{g.value=await K({diagnosis_id:r.diagnosisId})||[]}catch(e){console.error(e),g.value=[]}finally{p.value=!1}}};P(()=>r.diagnosisId,()=>{m()},{immediate:!0}),R({refresh:m});function C(e){return{1:"进行中",2:"已结束",3:"未接听",4:"已取消"}[e]??"—"}async function S(e){const t=b(e);if(!t){c.msgError("上传成功但未返回视频地址");return}try{const a=await A({diagnosis_id:r.diagnosisId});await x({diagnosis_id:r.diagnosisId,call_record_id:Number((a==null?void 0:a.id)||0),file_url:t}),c.msgSuccess("视频回放上传成功"),await m()}catch(a){c.msgError((a==null?void 0:a.message)||"写入回放失败")}}async function E(e,t){const a=b(t);if(!a){c.msgError("上传成功但未返回视频地址");return}try{await x({diagnosis_id:r.diagnosisId,call_record_id:Number(e.id||0),file_url:a}),c.msgSuccess("视频回放上传成功"),await m()}catch(l){c.msgError((l==null?void 0:l.message)||"写入回放失败")}}function b(e){var t,a;return String(((t=e==null?void 0:e.data)==null?void 0:t.uri)||((a=e==null?void 0:e.data)==null?void 0:a.url)||"").trim()}return(e,t)=>{const a=V,l=j,I=L,B=z;return d(),f("div",F,[_.readOnly?h("",!0):(d(),f("div",G,[o(k,{ref_key:"toolbarUploadRef",ref:U,type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:S},{default:i(()=>[o(a,{type:"primary"},{default:i(()=>[...t[0]||(t[0]=[n("上传视频",-1)])]),_:1})]),_:1},512)])),D((d(),w(I,{data:g.value,border:"",stripe:""},{empty:i(()=>[y("div",H,[t[1]||(t[1]=y("div",{class:"call-record-empty__title"},"暂无通话记录",-1)),y("div",J,u(_.readOnly?"暂无录制回放数据。":"现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。"),1)])]),default:i(()=>[o(l,{label:"录制回放","min-width":"320"},{default:i(({row:s})=>[o(M,{"record-id":s.id,urls:s.recording_urls_list},null,8,["record-id","urls"])]),_:1}),o(l,{label:"开始时间",width:"170",prop:"start_time_text"}),o(l,{label:"结束时间",width:"170",prop:"end_time_text"}),o(l,{label:"通话类型",width:"100"},{default:i(({row:s})=>[n(u(s.call_type===1?"语音":"视频"),1)]),_:1}),o(l,{label:"房间号",width:"180"},{default:i(({row:s})=>[s.room_id?(d(),f("span",Q,u(s.room_id),1)):(d(),f("span",W,"—"))]),_:1}),o(l,{label:"时长",width:"110",prop:"duration_text"}),o(l,{label:"状态",width:"90"},{default:i(({row:s})=>[n(u(C(s.status)),1)]),_:1}),o(l,{label:"录制",width:"100"},{default:i(({row:s})=>[n(u(s.recording_status_text||"—"),1)]),_:1}),_.readOnly?h("",!0):(d(),w(l,{key:0,label:"上传回放",width:"180"},{default:i(({row:s})=>[o(k,{type:"video",direct:"",multiple:!1,limit:1,"show-progress":!0,onSuccess:T=>E(s,T)},{default:i(()=>[o(a,{type:"primary",plain:"",size:"small"},{default:i(()=>[...t[2]||(t[2]=[n("上传视频",-1)])]),_:1})]),_:1},8,["onSuccess"])]),_:1}))]),_:1},8,["data"])),[[B,p.value]])])}}}),sa=q(X,[["__scopeId","data-v-78d5c9e4"]]);export{sa as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-CC7b2w5D.js";import{ai as q}from"./tcm-CrnCrBQX.js";import{_ as H}from"./index-Ur49qshF.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
import{o as T,ap as V,R as I,q as o,r as l,v as a,K as N,D as i,L as c,P as k,ac as z,bi as M,u as p,O as f,bk as O,T as _,F as P,br as j,s as F,bj as R,bB as A,M as w}from"./.pnpm-C1KTa5cB.js";import{am as q}from"./tcm-DJJMkqKG.js";import{_ as H}from"./index-Cxx1IoGl.js";const K={class:"case-record-list"},Y={key:0,class:"mb-3 flex justify-end"},G={key:0},J={key:1,class:"text-gray-400"},Q={class:"void-detail text-xs text-gray-500 mt-1"},U=T({__name:"CaseRecordList",props:{diagnosisId:{type:Number,default:0},readOnly:{type:Boolean,default:!1}},emits:["view","openPrescription"],setup(y,{expose:x,emit:C}){const m=y,b=C,r=w([]),d=w(!1),u=async()=>{if(m.diagnosisId){d.value=!0;try{const t=await q({diagnosis_id:m.diagnosisId});r.value=Array.isArray(t)?t:[]}catch(t){console.error("获取病历记录失败:",t),r.value=[]}finally{d.value=!1}}},S=t=>{if(!t)return"";const e=new Date(t*1e3);return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")} ${String(e.getHours()).padStart(2,"0")}:${String(e.getMinutes()).padStart(2,"0")}`},B=t=>{b("view",t)},$=()=>{b("openPrescription")};return V(()=>{u()}),I(()=>m.diagnosisId,()=>{u()}),x({refresh:u}),(t,e)=>{const h=N,n=O,v=j,E=R,D=A,L=M;return o(),l("div",K,[y.readOnly?k("",!0):(o(),l("div",Y,[a(h,{type:"primary",size:"small",onClick:$},{default:i(()=>[...e[0]||(e[0]=[c("开方",-1)])]),_:1})])),z((o(),f(E,{data:p(r),border:""},{default:i(()=>[a(n,{prop:"prescription_date",label:"就诊日期",width:"120"}),a(n,{prop:"visit_no",label:"门诊号",width:"120"}),a(n,{prop:"clinical_diagnosis",label:"临床诊断","min-width":"160","show-overflow-tooltip":""}),a(n,{label:"处方摘要","min-width":"180"},{default:i(({row:s})=>[s.herbs&&s.herbs.length?(o(),l("span",G,_(s.herbs.slice(0,3).map(g=>`${g.name}${g.dosage}克`).join("、"))+_(s.herbs.length>3?"...":""),1)):(o(),l("span",J,"—"))]),_:1}),a(n,{prop:"doctor_name",label:"医师",width:"90","show-overflow-tooltip":""}),a(n,{label:"状态",width:"140",align:"center"},{default:i(({row:s})=>[s.void_status===1?(o(),l(P,{key:0},[a(v,{type:"danger",size:"small"},{default:i(()=>[...e[1]||(e[1]=[c("已作废",-1)])]),_:1}),F("div",Q,_(s.void_by_name||"—")+" "+_(S(s.void_time)),1)],64)):(o(),f(v,{key:1,type:"success",size:"small"},{default:i(()=>[...e[2]||(e[2]=[c("正常",-1)])]),_:1}))]),_:1}),a(n,{label:"操作",width:"120",fixed:"right"},{default:i(({row:s})=>[a(h,{link:"",type:"primary",size:"small",onClick:g=>B(s)},{default:i(()=>[...e[3]||(e[3]=[c(" 查看 ",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[L,p(d)]]),!p(d)&&p(r).length===0?(o(),f(D,{key:1,description:"暂无病历记录,开方后会自动显示",class:"mt-4"})):k("",!0)])}}}),ee=H(U,[["__scopeId","data-v-043d2738"]]);export{ee as default};
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-BgSVHF_E.js";import"./.pnpm-CC7b2w5D.js";import"./tcm-CrnCrBQX.js";import"./index-Ur49qshF.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-CTGxGmsz.js";import"./.pnpm-C1KTa5cB.js";import"./tcm-DJJMkqKG.js";import"./index-Cxx1IoGl.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-CC7b2w5D.js";import{p as j}from"./tcm-CrnCrBQX.js";import{i as C}from"./index-Ur49qshF.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
import{o as U,be as F,q as f,r as k,F as M,ac as D,O as E,D as t,L as u,K as G,v as r,bt as B,bh as q,b9 as P,aw as A,b6 as L,bc as T,bf as K,b7 as z,P as g,p as W,M as v,ba as $}from"./.pnpm-C1KTa5cB.js";import{p as j}from"./tcm-DJJMkqKG.js";import{i as C}from"./index-Cxx1IoGl.js";const X=U({__name:"GancaoSubmissionReconcileButton",props:{order:{}},emits:["resolved"],setup(N,{emit:w}){const d=N,y=w,V=W(()=>{var m,p,a;const i=String(((m=d.order)==null?void 0:m.pharmacy_claim_target)||"").trim().toLowerCase(),e=String(((p=d.order)==null?void 0:p.pharmacy_claim_status)||"").trim().toUpperCase(),n=Number(((a=d.order)==null?void 0:a.pharmacy_claim_lease_expires_at)||0),c=e==="PENDING"&&n>0&&n<=Math.floor(Date.now()/1e3);return i==="gancao"&&(c||["UNKNOWN","PENDING_RECONCILE"].includes(e))}),s=v(!1),_=v(!1),o=$({resolution:"CONFIRM_SUCCESS",remote_order_no:"",note:""});function O(){o.resolution="CONFIRM_SUCCESS",o.remote_order_no="",o.note="",s.value=!0}async function b(){var e;const i=Number((e=d.order)==null?void 0:e.id);if(i){if(o.resolution==="CONFIRM_SUCCESS"&&!o.remote_order_no.trim()){C.msgError("请填写甘草药方单号");return}if(!o.note.trim()){C.msgError("请填写甘草后台核对依据");return}_.value=!0;try{await j({id:i,resolution:o.resolution,remote_order_no:o.resolution==="CONFIRM_SUCCESS"?o.remote_order_no.trim():"",note:o.note.trim()}),C.msgSuccess("甘草提交核对已记录"),s.value=!1,y("resolved")}catch{}finally{_.value=!1}}}return(i,e)=>{const n=G,c=q,m=K,p=T,a=L,S=z,R=P,x=B,I=F("perms");return V.value?(f(),k(M,{key:0},[D((f(),E(n,{type:"danger",size:"small",plain:"",onClick:O},{default:t(()=>[...e[5]||(e[5]=[u("核对甘草提交",-1)])]),_:1})),[[I,["tcm.prescriptionOrder/confirmGancaoSubmission"]]]),r(x,{modelValue:s.value,"onUpdate:modelValue":e[4]||(e[4]=l=>s.value=l),title:"人工核对甘草提交",width:"min(92vw, 520px)","append-to-body":"","destroy-on-close":"","close-on-click-modal":!1},{footer:t(()=>[r(n,{onClick:e[3]||(e[3]=l=>s.value=!1)},{default:t(()=>[...e[8]||(e[8]=[u("取消",-1)])]),_:1}),r(n,{type:"primary",loading:_.value,onClick:b},{default:t(()=>[...e[9]||(e[9]=[u("确认并记录",-1)])]),_:1},8,["loading"])]),default:t(()=>[r(c,{title:"请先在甘草后台核对。本操作会写入不可变更的审计记录。",type:"warning",closable:!1,"show-icon":"",class:"mb-4"}),r(R,{"label-width":"100px",onSubmit:A(b,["prevent"])},{default:t(()=>[r(a,{label:"核对结果",required:""},{default:t(()=>[r(p,{modelValue:o.resolution,"onUpdate:modelValue":e[0]||(e[0]=l=>o.resolution=l)},{default:t(()=>[r(m,{label:"CONFIRM_SUCCESS"},{default:t(()=>[...e[6]||(e[6]=[u("确认已创建",-1)])]),_:1}),r(m,{label:"CONFIRM_NOT_CREATED"},{default:t(()=>[...e[7]||(e[7]=[u("确认未创建",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),o.resolution==="CONFIRM_SUCCESS"?(f(),E(a,{key:0,label:"甘草单号",required:""},{default:t(()=>[r(S,{modelValue:o.remote_order_no,"onUpdate:modelValue":e[1]||(e[1]=l=>o.remote_order_no=l),maxlength:"64",clearable:""},null,8,["modelValue"])]),_:1})):g("",!0),r(a,{label:"核对依据",required:""},{default:t(()=>[r(S,{modelValue:o.note,"onUpdate:modelValue":e[2]||(e[2]=l=>o.note=l),type:"textarea",rows:3,maxlength:"1000","show-word-limit":"",placeholder:"例如:核对时间、甘草后台查询条件及结果"},null,8,["modelValue"])]),_:1})]),_:1})]),_:1},8,["modelValue"])],64)):g("",!0)}}});export{X as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as ae,a8 as se,E as C}from"./.pnpm-CC7b2w5D.js";import{d as te}from"./dayjs-o4qa-HO_.js";import{an as ne,ao as oe}from"./tcm-CrnCrBQX.js";import{p as re}from"./im-business-message-parse-CemYOgNU.js";import{_ as le}from"./index-Ur49qshF.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=se([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const s=P(e);let l="";return s!=null&&s.tag?l=s.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:s,tag:l}}));function N(e){if(e==null||!e)return"—";const s=e>1e12?Math.floor(e/1e3):e;return te.unix(s).format("YYYY-MM-DD HH:mm:ss")}function P(e){const s=(e.text||"").trim();if(!s)return null;const l=s.startsWith("{")&&(/\bbusinessID\b/.test(s)||/\bcmd\b/.test(s));return e.msg_type==="custom"||l?re(s):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,s)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=ae,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...s[0]||(s[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),s[1]||(s[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),s[2]||(s[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,a=>(t(),n("div",{key:a.raw.msg_id,class:X(["chat-row",a.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(a.raw)),1),o("span",me,r(N(a.raw.time)),1),a.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(a.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[a.raw.msg_type==="image"&&a.raw.image_url?(t(),f(V,{key:0,src:a.raw.image_url,"preview-src-list":[a.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(a.raw.msg_type==="file"||a.raw.msg_type==="sound"||a.raw.msg_type==="video")&&a.raw.file_url?(t(),f(Y,{key:1,href:a.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(a.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[a.friendly?(t(),n("div",ye,[o("div",ge,r(a.friendly.main),1),a.friendly.sub?(t(),n("div",ve,r(a.friendly.sub),1)):v("",!0)])):a.raw.msg_type==="text"&&a.raw.text?(t(),n("div",he,r(a.raw.text),1)):a.raw.text?(t(),n("div",we,r(a.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
import{o as F,R as q,q as t,r as n,v as i,D as c,s as o,L as _,bh as G,w as H,u as x,cV as $,K as j,by as J,ac as K,F as E,G as O,O as f,bB as W,P as v,bi as Q,M as m,p as U,ae as X,T as r,br as Z,aa as ee,bq as se,a8 as ae,E as C}from"./.pnpm-C1KTa5cB.js";import{d as te}from"./dayjs-DznSeB-q.js";import{ar as ne,as as oe}from"./tcm-DJJMkqKG.js";import{p as re}from"./im-business-message-parse-DXRL3M5q.js";import{_ as le}from"./index-Cxx1IoGl.js";const ie={class:"im-chat-record-panel"},ce={class:"toolbar mb-3"},de={class:"chat-wrap"},_e={key:0,class:"chat-list"},ue={class:"meta"},fe={class:"name"},me={class:"time"},pe={class:"bubble"},ye={key:0,class:"friendly-text"},ge={class:"friendly-main"},ve={key:0,class:"friendly-sub"},he={key:1,class:"text-content"},we={key:2,class:"text-content"},be={key:3,class:"muted"},ke=F({__name:"ImChatRecordPanel",props:{diagnosisId:{}},setup(B,{expose:M}){const u=B,d=m(!1),p=m(!1),y=ae([]),L=m(""),g=m(""),D={text:"",image:"图片",file:"文件",sound:"语音",video:"视频",location:"位置",custom:"自定义",face:"表情",other:""},h=U(()=>y.value.map(e=>{const a=P(e);let l="";return a!=null&&a.tag?l=a.tag:l=D[e.msg_type]||(e.msg_type!=="other"?e.msg_type:""),{raw:e,friendly:a,tag:l}}));function N(e){if(e==null||!e)return"—";const a=e>1e12?Math.floor(e/1e3):e;return te.unix(a).format("YYYY-MM-DD HH:mm:ss")}function P(e){const a=(e.text||"").trim();if(!a)return null;const l=a.startsWith("{")&&(/\bbusinessID\b/.test(a)||/\bcmd\b/.test(a));return e.msg_type==="custom"||l?re(a):null}function R(e){return e.is_from_doctor?e.from_staff_name?`医生(${e.from_staff_name})`:"医生/员工":g.value?`患者(${g.value})`:"患者"}async function w(){if(u.diagnosisId){d.value=!0;try{const e=await ne({diagnosis_id:u.diagnosisId,only_archived:1});y.value=(e==null?void 0:e.lists)||[],L.value=(e==null?void 0:e.patient_im_id)||"",g.value=(e==null?void 0:e.patient_name)||""}catch(e){console.error(e),y.value=[]}finally{d.value=!1}}}function b(){w()}async function S(){if(u.diagnosisId){p.value=!0;try{await oe({diagnosis_id:u.diagnosisId}),C.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')}catch(e){console.error(e),C.error("发起同步失败")}finally{p.value=!1}}}return q(()=>u.diagnosisId,()=>{w()},{immediate:!0}),M({refresh:b}),(e,a)=>{const l=G,k=H,I=j,T=Z,V=ee,Y=se,z=W,A=Q;return t(),n("div",ie,[i(l,{type:"info","show-icon":"",closable:!1,class:"mb-4",title:"说明"},{default:c(()=>[...a[0]||(a[0]=[o("p",{class:"panel-tip"},[_(" 展示单聊记录:已合并患者 与 "),o("strong",null,"所有医生 / 医助账号"),_("分别产生的会话,按时间排序。 数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档;点击下方按钮可立即触发后台同步。 ")],-1)])]),_:1}),o("div",ce,[i(I,{type:"primary",link:"",loading:p.value,onClick:S},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x($))]),_:1}),a[1]||(a[1]=_(" 同步最新(后台异步) ",-1))]),_:1},8,["loading"]),i(I,{type:"primary",link:"",loading:d.value,onClick:b},{default:c(()=>[i(k,{class:"mr-1"},{default:c(()=>[i(x(J))]),_:1}),a[2]||(a[2]=_(" 重新加载已归档 ",-1))]),_:1},8,["loading"])]),K((t(),n("div",de,[!d.value&&h.value.length?(t(),n("div",_e,[(t(!0),n(E,null,O(h.value,s=>(t(),n("div",{key:s.raw.msg_id,class:X(["chat-row",s.raw.is_from_doctor?"from-doctor":"from-patient"])},[o("div",ue,[o("span",fe,r(R(s.raw)),1),o("span",me,r(N(s.raw.time)),1),s.tag?(t(),f(T,{key:0,size:"small",type:"info",class:"ml-2"},{default:c(()=>[_(r(s.tag),1)]),_:2},1024)):v("",!0)]),o("div",pe,[s.raw.msg_type==="image"&&s.raw.image_url?(t(),f(V,{key:0,src:s.raw.image_url,"preview-src-list":[s.raw.image_url],fit:"contain",class:"chat-img"},null,8,["src","preview-src-list"])):(s.raw.msg_type==="file"||s.raw.msg_type==="sound"||s.raw.msg_type==="video")&&s.raw.file_url?(t(),f(Y,{key:1,href:s.raw.file_url,target:"_blank",type:"primary"},{default:c(()=>[_(r(s.raw.file_name||"打开文件"),1)]),_:2},1032,["href"])):(t(),n(E,{key:2},[s.friendly?(t(),n("div",ye,[o("div",ge,r(s.friendly.main),1),s.friendly.sub?(t(),n("div",ve,r(s.friendly.sub),1)):v("",!0)])):s.raw.msg_type==="text"&&s.raw.text?(t(),n("div",he,r(s.raw.text),1)):s.raw.text?(t(),n("div",we,r(s.raw.text),1)):(t(),n("span",be,"(无法展示该消息类型)"))],64))])],2))),128))])):d.value?v("",!0):(t(),f(z,{key:1,description:"暂无 IM 聊天记录"}))])),[[A,d.value]])])}}}),Me=le(ke,[["__scopeId","data-v-58b699dd"]]);export{Me as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-Ds8_XODJ.js";import"./.pnpm-CC7b2w5D.js";export{m as default};
|
||||
import{_ as m}from"./MediaSourceSelect.vue_vue_type_script_setup_true_lang-E4oG3NCo.js";import"./.pnpm-C1KTa5cB.js";export{m as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-CC7b2w5D.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
import{o as g,q as n,O as s,D as V,r as C,F as v,G as h,bn as B,u as c,P as p,t as w,ae as S,bm as k,p as i}from"./.pnpm-C1KTa5cB.js";const z=g({__name:"MediaSourceSelect",props:{modelValue:{default:""},options:{},loading:{type:Boolean,default:!1},placeholder:{default:"请选择自媒体来源"},clearable:{type:Boolean,default:!0},filterable:{type:Boolean,default:!0},allowCreate:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},selectClass:{},selectStyle:{}},emits:["update:modelValue","change","visible-change"],setup(e,{emit:m}){const u=e,o=m,d=i(()=>(u.options||[]).map(l=>typeof l=="string"?{name:l}:{name:l.name,value:l.value})),f=i(()=>d.value.some(l=>l.name===u.modelValue)),b=l=>{o("visible-change",l)};return(l,t)=>{const r=B,y=k;return n(),s(y,{"model-value":e.modelValue,placeholder:e.placeholder,clearable:e.clearable,filterable:e.filterable,"allow-create":e.allowCreate,"default-first-option":e.allowCreate,disabled:e.disabled,loading:e.loading,class:S(e.selectClass),style:w(e.selectStyle),"onUpdate:modelValue":t[0]||(t[0]=a=>o("update:modelValue",a)),onChange:t[1]||(t[1]=a=>o("change",a)),onVisibleChange:b},{default:V(()=>[(n(!0),C(v,null,h(c(d),a=>(n(),s(r,{key:a.name,label:a.name,value:a.name},null,8,["label","value"]))),128)),e.modelValue&&!c(f)?(n(),s(r,{key:`__legacy_${e.modelValue}`,label:`${e.modelValue}(已停用)`,value:e.modelValue},null,8,["label","value"])):p("",!0)]),_:1},8,["model-value","placeholder","clearable","filterable","allow-create","default-first-option","disabled","loading","class","style"])}}});export{z as _};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-CC7b2w5D.js";import{t as j,_ as J}from"./index-Ur49qshF.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
import{o as S,q as a,r as l,ae as x,v as r,D as m,L as d,T as i,a0 as D,s as n,O as g,br as G,P as B,aa as K,u as v,bE as L,w as q,bF as A,bG as H,bH as O,F as P,K as U,p as u}from"./.pnpm-C1KTa5cB.js";import{t as j,_ as J}from"./index-Cxx1IoGl.js";const Q={class:"msg-body"},R={class:"msg-meta"},W={class:"msg-sender"},X={class:"msg-time"},Y={key:0,class:"content-text"},Z={key:2,class:"content-pending"},ee=["src"],se={key:4,class:"content-pending"},te=["src"],ae={key:6,class:"content-pending"},ne={key:7,class:"content-file"},le={class:"file-meta"},ie=["title"],oe={class:"file-info"},ce={key:8,class:"content-fallback"},re={class:"fallback-title"},me={class:"fallback-desc"},ue=S({__name:"MessageBubble",props:{message:{}},setup(s){const o=s,w=u(()=>o.message.from_is_staff),c=u(()=>(o.message.media||[]).find(t=>t.status===1)??null),_=u(()=>{const e=o.message.from_profile;return e&&e.name||o.message.from_user}),z=u(()=>{const e=o.message.from_profile;return(e==null?void 0:e.avatar)||""}),F=u(()=>(_.value||"").slice(-2)||"?"),E=u(()=>{const e=o.message.msgtype;return e==="image"||e==="video"||e==="voice"?"bubble-media":e==="file"?"bubble-file":"bubble-text"}),C=u(()=>{switch(o.message.msgtype){case"link":return"[链接]";case"weapp":return"[小程序]";case"chatrecord":return"[聊天记录]";case"location":return"[位置]";case"card":return"[名片]";case"meeting":return"[会议]";case"docmsg":return"[文档]";case"mixed":return"[混合消息]";case"emotion":return"[表情]";default:return`[${o.message.msgtype}]`}});function M(e){return e?j(e*1e3,"mm-dd hh:MM"):""}function N(e){return e?e<1024?`${e} B`:e<1024*1024?`${(e/1024).toFixed(1)} KB`:e<1024*1024*1024?`${(e/1024/1024).toFixed(2)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:""}function $(e){window.open(e,"_blank","noopener")}return(e,t)=>{var k,y,p,h;const T=D,b=G,V=K,f=q,I=U;return a(),l("div",{class:x(["msg-bubble",{"is-staff":w.value}])},[r(T,{class:"msg-avatar",size:36,src:z.value},{default:m(()=>[d(i(F.value),1)]),_:1},8,["src"]),n("div",Q,[n("div",R,[n("span",W,i(_.value),1),n("span",X,i(M(s.message.send_time)),1),s.message.action==="recall"?(a(),g(b,{key:0,size:"small",type:"info"},{default:m(()=>[...t[1]||(t[1]=[d(" 已撤回 ",-1)])]),_:1})):B("",!0)]),n("div",{class:x(["msg-content",E.value])},[s.message.msgtype==="text"||s.message.msgtype==="markdown"?(a(),l("div",Y,i(s.message.content),1)):s.message.msgtype==="image"&&((k=c.value)!=null&&k.file_url)?(a(),g(V,{key:1,src:c.value.file_url,"preview-src-list":[c.value.file_url],fit:"contain",class:"content-image","hide-on-click-modal":""},null,8,["src","preview-src-list"])):s.message.msgtype==="image"?(a(),l("div",Z,[r(f,null,{default:m(()=>[r(v(L))]),_:1}),t[2]||(t[2]=n("span",null,"图片下载中…",-1))])):s.message.msgtype==="video"&&((y=c.value)!=null&&y.file_url)?(a(),l("video",{key:3,src:c.value.file_url,controls:"",class:"content-video"},null,8,ee)):s.message.msgtype==="video"?(a(),l("div",se,[r(f,null,{default:m(()=>[r(v(A))]),_:1}),t[3]||(t[3]=n("span",null,"视频下载中…",-1))])):s.message.msgtype==="voice"&&((p=c.value)!=null&&p.file_url)?(a(),l("audio",{key:5,src:c.value.file_url,controls:"",class:"content-audio"},null,8,te)):s.message.msgtype==="voice"?(a(),l("div",ae,[r(f,null,{default:m(()=>[r(v(H))]),_:1}),n("span",null,"语音下载中…("+i(s.message.play_length)+"s)",1)])):s.message.msgtype==="file"?(a(),l("div",ne,[r(f,{size:24},{default:m(()=>[r(v(O))]),_:1}),n("div",le,[n("div",{class:"file-name",title:s.message.file_name},i(s.message.file_name||"未命名文件"),9,ie),n("div",oe,[d(i(N(s.message.file_size))+" ",1),s.message.file_ext?(a(),l(P,{key:0},[d("· "+i(s.message.file_ext),1)],64)):B("",!0)])]),(h=c.value)!=null&&h.file_url?(a(),g(I,{key:0,size:"small",type:"primary",link:"",onClick:t[0]||(t[0]=de=>$(c.value.file_url))},{default:m(()=>[...t[4]||(t[4]=[d(" 下载 ",-1)])]),_:1})):(a(),g(b,{key:1,size:"small",type:"info"},{default:m(()=>[...t[5]||(t[5]=[d("下载中…",-1)])]),_:1}))])):(a(),l("div",ce,[n("div",re,i(C.value),1),n("div",me,i(s.message.content),1)]))],2)])],2)}}}),ve=J(ue,[["__scopeId","data-v-b5ff1168"]]);export{ve as default};
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-CC7b2w5D.js";import{_ as fe}from"./picker-0fGGjL76.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-Ur49qshF.js";import{a as T,d as he}from"./patient-lGQYvy3x.js";import{h as ke}from"./perm-B1f1uL1_.js";import"./index-BhFu9zdX.js";import"./index-DyL_9FDq.js";import"./index.vue_vue_type_script_setup_true_lang-DHXV5Zdc.js";import"./index-CIoly03W.js";import"./index-CSWtRkLa.js";import"./file-BtNv0rvz.js";import"./index.vue_vue_type_script_setup_true_lang-CYjIL6rl.js";import"./usePaging-Cer8s1LR.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
import{o as ie,R as oe,q as n,r as o,O as k,K as le,D as r,L as P,P as m,v as l,s as d,F as w,G as E,bB as ae,b7 as re,bt as de,p as ue,M as g,T as U,aa as me,u as V,d7 as z,aw as M,w as ce,bH as pe,e as ge}from"./.pnpm-C1KTa5cB.js";import{_ as fe}from"./picker-CNX2EN3u.js";import{e as ve,c as _e,i as f,_ as ye}from"./index-Cxx1IoGl.js";import{a as T,d as he}from"./patient-BpamRx-E.js";import{h as ke}from"./perm-CZ2bIVzs.js";import"./index-B3Fu5tec.js";import"./index-BhA3CyxZ.js";import"./index.vue_vue_type_script_setup_true_lang-CerDQAzd.js";import"./index-B-rCQZ_o.js";import"./index-CZyPOnHt.js";import"./file-D2AwthSZ.js";import"./index.vue_vue_type_script_setup_true_lang-0C3mdvgv.js";import"./usePaging--aAVZOna.js";const we={class:"note-timeline-wrap"},Ie={key:0,class:"timeline-actions"},Ce={class:"upload-trigger"},be={class:"upload-trigger"},xe={key:1,class:"note-timeline"},Ee={class:"timeline-date"},Ve={class:"timeline-body"},Ne={key:0,class:"timeline-content"},Se={key:1,class:"timeline-images"},De={key:2,class:"timeline-images"},Be={key:0,class:"thumb-wrap"},Ae={key:1,class:"file-wrap"},Pe=["href","title"],Ue={class:"file-name"},W=8e3,ze=ie({__name:"NoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(u,{emit:j}){const c=u,I=j,X=ue(()=>ke(["doctor.appointment/addDoctorNote"])),v=g(!1),C=g(""),N=g(!1),q=ve(),_=t=>q.getImageUrl(t),S=g([]),D=g([]),y=g(0),h=g(0),H=["jpg","jpeg","png","gif","bmp","webp","svg"],B=t=>{var i;const e=((i=t.split(".").pop())==null?void 0:i.toLowerCase().split("?")[0])||"";return H.includes(e)},$=t=>{var i;const e=t.split("/");return decodeURIComponent(((i=e[e.length-1])==null?void 0:i.split("?")[0])||"文件")},K=t=>t.filter(B).map(_),Z=(t,e)=>{const i=t.filter(B),b=t[e];return i.indexOf(b)},J=t=>t?t.split(`
|
||||
`).filter(Boolean):[],Q=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=y.value){y.value=e.length;return}const i=e.slice(y.value);y.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,tongue_images:i}).then(()=>{f.msgSuccess("舌苔照片已添加"),I("refresh")})},Y=t=>{if(!c.diagnosisId)return;const e=Array.isArray(t)?t:[t];if(e.length<=h.value){h.value=e.length;return}const i=e.slice(h.value);h.value=e.length,i.length>0&&T({diagnosis_id:c.diagnosisId,report_files:i}).then(()=>{f.msgSuccess("检查报告已添加"),I("refresh")})};oe(()=>c.notes,()=>{S.value=[],D.value=[],y.value=0,h.value=0});const ee=async()=>{if(!c.diagnosisId){f.msgWarning("当前无诊单,无法添加备注");return}const t=C.value.trim();if(!t){f.msgWarning("请输入备注内容");return}N.value=!0;try{await T({diagnosis_id:c.diagnosisId,content:t}),f.msgSuccess("备注保存成功"),C.value="",v.value=!1,I("refresh")}catch(e){f.msgError((e==null?void 0:e.message)||"保存失败")}finally{N.value=!1}},A=async(t,e,i)=>{try{await ge.confirm("确认删除?","提示",{type:"warning"})}catch{return}await he({note_id:t,image_type:e,image_path:i}),f.msgSuccess("已删除"),I("refresh")};return(t,e)=>{const i=le,b=_e,F=fe,L=me,x=ce,te=ae,se=re,ne=de;return n(),o("div",we,[!u.readonly&&u.diagnosisId?(n(),o("div",Ie,[X.value?(n(),k(i,{key:0,type:"primary",plain:"",size:"small",onClick:e[0]||(e[0]=s=>v.value=!0)},{default:r(()=>[...e[6]||(e[6]=[P(" 添加备注 ",-1)])]),_:1})):m("",!0),l(F,{modelValue:S.value,"onUpdate:modelValue":e[1]||(e[1]=s=>S.value=s),limit:99,type:"image","exclude-domain":!0,onChange:Q},{upload:r(()=>[d("div",Ce,[l(b,{size:20,name:"el-icon-Plus"}),e[7]||(e[7]=d("span",null,"舌苔照片",-1))])]),_:1},8,["modelValue"]),l(F,{modelValue:D.value,"onUpdate:modelValue":e[2]||(e[2]=s=>D.value=s),limit:99,type:"file","exclude-domain":!0,onChange:Y},{upload:r(()=>[d("div",be,[l(b,{size:20,name:"el-icon-Plus"}),e[8]||(e[8]=d("span",null,"检查报告",-1))])]),_:1},8,["modelValue"])])):m("",!0),u.notes.length?(n(),o("div",xe,[(n(!0),o(w,null,E(u.notes,s=>{var R,G;return n(),o("div",{key:s.id,class:"timeline-node"},[e[11]||(e[11]=d("div",{class:"timeline-dot"},null,-1)),d("div",Ee,U(s.note_date),1),d("div",Ve,[s.content?(n(),o("div",Ne,[(n(!0),o(w,null,E(J(s.content),(a,p)=>(n(),o("div",{key:p,class:"content-line"},U(a),1))),128))])):m("",!0),(R=s.tongue_images)!=null&&R.length?(n(),o("div",Se,[e[9]||(e[9]=d("span",{class:"images-label"},"舌苔照片",-1)),(n(!0),o(w,null,E(s.tongue_images,(a,p)=>(n(),o("div",{key:p,class:"thumb-wrap"},[l(L,{src:_(a),"preview-src-list":s.tongue_images.map(_),"initial-index":p,"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"tongue_images",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))),128))])):m("",!0),(G=s.report_files)!=null&&G.length?(n(),o("div",De,[e[10]||(e[10]=d("span",{class:"images-label"},"检查报告",-1)),(n(!0),o(w,null,E(s.report_files,(a,p)=>(n(),o(w,{key:p},[B(a)?(n(),o("div",Be,[l(L,{src:_(a),"preview-src-list":K(s.report_files),"initial-index":Z(s.report_files,p),"z-index":W,fit:"cover",class:"timeline-thumb","preview-teleported":""},null,8,["src","preview-src-list","initial-index"]),u.readonly?m("",!0):(n(),k(x,{key:0,class:"thumb-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))])):(n(),o("div",Ae,[d("a",{href:_(a),target:"_blank",class:"file-link",title:$(a)},[l(x,{size:20},{default:r(()=>[l(V(pe))]),_:1}),d("span",Ue,U($(a)),1)],8,Pe),u.readonly?m("",!0):(n(),k(x,{key:0,class:"file-delete",onClick:M(O=>A(s.id,"report_files",a),["stop"])},{default:r(()=>[l(V(z))]),_:1},8,["onClick"]))]))],64))),128))])):m("",!0)])])}),128))])):m("",!0),!u.notes.length&&u.readonly?(n(),k(te,{key:2,description:"暂无备注","image-size":48})):m("",!0),l(ne,{modelValue:v.value,"onUpdate:modelValue":e[5]||(e[5]=s=>v.value=s),title:"添加备注",width:"480px","append-to-body":""},{footer:r(()=>[l(i,{onClick:e[4]||(e[4]=s=>v.value=!1)},{default:r(()=>[...e[12]||(e[12]=[P("取消",-1)])]),_:1}),l(i,{type:"primary",loading:N.value,onClick:ee},{default:r(()=>[...e[13]||(e[13]=[P("保存",-1)])]),_:1},8,["loading"])]),default:r(()=>[l(se,{modelValue:C.value,"onUpdate:modelValue":e[3]||(e[3]=s=>C.value=s),type:"textarea",rows:4,placeholder:"输入今日备注...",maxlength:"500","show-word-limit":""},null,8,["modelValue"])]),_:1},8,["modelValue"])])}}}),Ke=ye(ze,[["__scopeId","data-v-530ad386"]]);export{Ke as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-CC7b2w5D.js";import{_ as V}from"./index-Ur49qshF.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
import{m as I,f as p,a as w}from"./diag-display-DCz_VAqj.js";import{o as y,q as x,r as B,s as e,T as a,u as i,P as N}from"./.pnpm-C1KTa5cB.js";import{_ as V}from"./index-Cxx1IoGl.js";const b={class:"card patient-card"},q={class:"patient-hero"},D={class:"patient-name"},E={class:"patient-meta"},G={key:0},S=y({__name:"PatientInfoCard",props:{apt:{default:()=>({})},diag:{default:()=>({})}},setup(t){return(T,n)=>{var d,s,o,c,m,l,r,g,u,f,h,v,k,P,C;return x(),B("div",b,[n[0]||(n[0]=e("div",{class:"card-title"},"患者信息",-1)),e("div",q,[e("div",D,a(((d=t.apt)==null?void 0:d.patient_name)||"—"),1),e("div",E,[e("div",null,a(i(I)((s=t.apt)==null?void 0:s.patient_phone))+" · "+a(i(p)((o=t.diag)==null?void 0:o.gender))+" · "+a(((c=t.diag)==null?void 0:c.age)!=null?t.diag.age+"岁":"—"),1),e("div",null,a((m=t.diag)!=null&&m.height?t.diag.height+"cm":"—")+" / "+a((l=t.diag)!=null&&l.weight?t.diag.weight+"kg":"—")+" · "+a(((r=t.diag)==null?void 0:r.region)||"—"),1),e("div",null," 预约:"+a((g=t.apt)==null?void 0:g.appointment_date)+" "+a((u=t.apt)==null?void 0:u.appointment_time)+" · "+a(i(w)((f=t.apt)==null?void 0:f.period)),1),e("div",null,"医生:"+a(((h=t.apt)==null?void 0:h.doctor_name)||"—")+" 客服:"+a(((v=t.apt)==null?void 0:v.assistant_name)||"—"),1),e("div",null," 状态:"+a(((k=t.apt)==null?void 0:k.status_desc)||"—")+" · "+a((P=t.apt)!=null&&P.has_prescription?"已开方":"未开方"),1),(C=t.apt)!=null&&C.remark?(x(),B("div",G,"备注:"+a(t.apt.remark),1)):N("",!0)])])])}}}),F=V(S,[["__scopeId","data-v-1b0de09c"]]);export{F as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.batch-dialog-body[data-v-e1699253]{display:flex;flex-direction:column;gap:16px;min-height:260px}.loading-state[data-v-e1699253],.load-error-state[data-v-e1699253]{padding:28px 12px}.state-description[data-v-e1699253]{max-width:520px;margin:0 auto 14px;color:var(--el-text-color-secondary);line-height:1.6}.overview-panel[data-v-e1699253],.progress-panel[data-v-e1699253],.pending-panel[data-v-e1699253],.issues-panel[data-v-e1699253]{border:1px solid var(--el-border-color-lighter);border-radius:10px;background:var(--el-bg-color)}.overview-panel[data-v-e1699253]{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 18px}.overview-panel strong[data-v-e1699253]{margin-left:10px;color:var(--el-color-primary);font-size:28px;line-height:1}.overview-label[data-v-e1699253],.overview-unit[data-v-e1699253],.overview-meta[data-v-e1699253],.pending-count[data-v-e1699253],.footer-tip[data-v-e1699253]{color:var(--el-text-color-secondary)}.overview-unit[data-v-e1699253]{margin-left:5px}.overview-meta[data-v-e1699253]{display:flex;align-items:center;gap:10px;font-size:13px}.truncated-alert[data-v-e1699253],.stop-alert[data-v-e1699253]{flex-shrink:0}.refresh-error[data-v-e1699253]{display:flex;align-items:center;gap:12px}.refresh-error[data-v-e1699253] .el-alert{flex:1}.progress-panel[data-v-e1699253],.pending-panel[data-v-e1699253],.issues-panel[data-v-e1699253]{padding:18px}.progress-heading[data-v-e1699253],.section-heading[data-v-e1699253],.dialog-footer[data-v-e1699253]{display:flex;align-items:center;justify-content:space-between;gap:16px}.progress-heading[data-v-e1699253],.section-heading[data-v-e1699253]{margin-bottom:14px}.section-kicker[data-v-e1699253]{display:block;margin-bottom:3px;color:var(--el-text-color-secondary);font-size:12px}.progress-heading strong[data-v-e1699253],.section-heading strong[data-v-e1699253]{font-size:16px}.current-prescription[data-v-e1699253]{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;margin-top:14px;padding:12px 14px;border-radius:8px;background:var(--el-fill-color-light)}.current-prescription span[data-v-e1699253],.current-prescription small[data-v-e1699253]{color:var(--el-text-color-secondary)}.counter-grid[data-v-e1699253]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:14px}.counter-item[data-v-e1699253]{padding:12px;border-radius:8px;background:var(--el-fill-color-light)}.counter-item span[data-v-e1699253]{display:block;margin-bottom:5px;color:var(--el-text-color-secondary);font-size:12px}.counter-item strong[data-v-e1699253]{font-size:22px}.counter-success strong[data-v-e1699253]{color:var(--el-color-success)}.counter-partial strong[data-v-e1699253]{color:var(--el-color-warning)}.counter-failed strong[data-v-e1699253]{color:var(--el-color-danger)}.stop-alert[data-v-e1699253]{margin-top:14px}.issue-name[data-v-e1699253]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.issues-panel small[data-v-e1699253]{color:var(--el-text-color-secondary)}.dialog-footer[data-v-e1699253]{width:100%}.footer-tip[data-v-e1699253]{font-size:12px}@media (max-width: 640px){.overview-panel[data-v-e1699253],.overview-meta[data-v-e1699253],.refresh-error[data-v-e1699253],.dialog-footer[data-v-e1699253]{align-items:flex-start;flex-direction:column}.counter-grid[data-v-e1699253]{grid-template-columns:repeat(2,minmax(0,1fr))}.current-prescription[data-v-e1699253]{grid-template-columns:1fr}}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.dialog-heading[data-v-5a75a47c]{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-right:28px}.dialog-heading h2[data-v-5a75a47c]{margin:0;color:var(--el-text-color-primary);font-size:18px;line-height:1.4}.dialog-heading p[data-v-5a75a47c]{margin:3px 0 0;color:var(--el-text-color-secondary);font-size:13px}.report-shell[data-v-5a75a47c]{display:flex;flex-direction:column;gap:14px}.prescription-snapshot[data-v-5a75a47c]{display:grid;grid-template-columns:72px minmax(0,1fr);gap:12px;align-items:start;padding:12px 14px;border:1px solid var(--el-border-color-lighter);border-radius:8px;background:var(--el-fill-color-lighter);font-size:13px;line-height:1.65}.snapshot-label[data-v-5a75a47c]{color:var(--el-text-color-secondary)}.snapshot-content[data-v-5a75a47c]{color:var(--el-text-color-primary);word-break:break-word}.medical-notice[data-v-5a75a47c] .el-alert__title,.operation-error[data-v-5a75a47c] .el-alert__title{line-height:1.55}.report-loading[data-v-5a75a47c]{min-height:360px;padding:18px 10px}.model-tabs[data-v-5a75a47c] .el-tabs__header{margin-bottom:12px}.model-tabs[data-v-5a75a47c] .el-tabs__item{height:44px}.model-tab-label[data-v-5a75a47c]{display:inline-flex;align-items:center;gap:7px}.model-name[data-v-5a75a47c]{color:var(--el-text-color-secondary);font-size:12px}.report-pane[data-v-5a75a47c]{min-height:360px;padding:4px 2px}.report-error[data-v-5a75a47c]{display:grid;min-height:340px;place-items:center}.error-message[data-v-5a75a47c],.empty-message[data-v-5a75a47c]{max-width:520px;margin:-8px auto 16px;color:var(--el-text-color-secondary);font-size:13px;line-height:1.6;text-align:center}.report-document[data-v-5a75a47c]{padding:2px 12px 8px 2px;color:var(--el-text-color-primary)}.report-meta[data-v-5a75a47c]{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding-bottom:14px;border-bottom:1px solid var(--el-border-color-lighter);color:var(--el-text-color-secondary);font-size:12px}.report-meta .report-meta-main[data-v-5a75a47c]{display:flex;flex:1;flex-direction:column;gap:5px;min-width:0}.report-meta .report-title-line[data-v-5a75a47c]{display:flex;align-items:baseline;gap:8px}.report-meta .report-times[data-v-5a75a47c]{display:flex;flex-wrap:wrap;gap:4px 14px}.report-meta strong[data-v-5a75a47c]{color:var(--el-text-color-primary);font-size:15px}.report-editor[data-v-5a75a47c]{padding:18px 0 4px}.saved-version-warning[data-v-5a75a47c]{margin-top:16px}.saved-version-warning[data-v-5a75a47c] .el-alert__title{line-height:1.55}.editor-error[data-v-5a75a47c]{margin-top:12px}.editor-format-notice[data-v-5a75a47c]{margin-bottom:12px}.editor-actions[data-v-5a75a47c]{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}.report-summary[data-v-5a75a47c]{margin:18px 0;padding:16px 18px;border-left:3px solid var(--el-color-primary);border-radius:0 8px 8px 0;background:var(--el-color-primary-light-9)}.report-grid[data-v-5a75a47c]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 28px}.report-section[data-v-5a75a47c],.report-cautions[data-v-5a75a47c]{padding:17px 0;border-bottom:1px solid var(--el-border-color-lighter)}.report-section-wide[data-v-5a75a47c]{width:100%}.report-cautions[data-v-5a75a47c]{margin-top:18px;padding:16px 18px;border:1px solid var(--el-color-warning-light-5);border-radius:8px;background:var(--el-color-warning-light-9)}.report-summary h3[data-v-5a75a47c],.report-section h3[data-v-5a75a47c],.report-cautions h3[data-v-5a75a47c]{margin:0 0 9px;color:var(--el-text-color-primary);font-size:14px;font-weight:600}.report-summary p[data-v-5a75a47c],.report-section p[data-v-5a75a47c],.report-cautions p[data-v-5a75a47c]{margin:0;color:var(--el-text-color-regular);font-size:13px;line-height:1.75;white-space:pre-wrap}[data-v-5a75a47c] .report-list{margin:0;padding-left:18px;color:var(--el-text-color-regular);font-size:13px;line-height:1.75}[data-v-5a75a47c] .report-empty-text{margin:0;color:var(--el-text-color-placeholder);font-size:13px}.report-disclaimer[data-v-5a75a47c]{margin:14px 0 0;color:var(--el-text-color-secondary);font-size:12px;line-height:1.65}.raw-content[data-v-5a75a47c]{margin:0;color:var(--el-text-color-regular);font-family:inherit;font-size:13px;line-height:1.75;white-space:pre-wrap;word-break:break-word}@media (max-width: 768px){.report-grid[data-v-5a75a47c]{grid-template-columns:1fr}.report-meta[data-v-5a75a47c]{flex-direction:column}.model-name[data-v-5a75a47c]{display:none}}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-CC7b2w5D.js";import H from"./RecordingVideoPlayer-BsUV5wIC.js";import{e as I,_ as P}from"./index-Ur49qshF.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
import{o as L,q as i,r as c,s as d,F as v,O as m,bq as q,D as _,L as k,P as y,G as B,p as f,T as w}from"./.pnpm-C1KTa5cB.js";import H from"./RecordingVideoPlayer-ByTd0pZe.js";import{e as I,_ as P}from"./index-Cxx1IoGl.js";const R={key:0,class:"recording-list"},C={class:"recording-item"},V={key:1,class:"recording-alternates"},N={class:"recording-alternates__links"},D={key:1,class:"text-gray-400"},E=L({__name:"RecordingPlaybackBlock",props:{recordId:{},urls:{}},setup(l){const $=l,h=I(),u=f(()=>{const e=$.urls||[],t=new Set,r=[];for(const o of e){const s=String(o??"").trim();!s||t.has(s)||(t.add(s),r.push(s))}return r}),a=f(()=>{const e=u.value;if(!e.length)return null;const t=e.find(n=>/\.mp4(\?|#|$)/i.test(n));if(t)return t;const r=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/vod-qcloud\.com/i.test(n));if(r)return r;const o=e.find(n=>/\.m3u8(\?|#|$)/i.test(n)&&/\.cos\.[^/]+\.myqcloud\.com/i.test(n));if(o)return o;const s=e.find(n=>/\.m3u8(\?|#|$)/i.test(n));return s||e[0]}),p=f(()=>{const e=u.value,t=a.value;return t?e.filter(r=>r!==t):e.slice(1)});function b(e){if(!e||typeof e!="string")return!1;const t=e.trim();return/^https?:\/\//i.test(t)?/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t):/\.(mp4|webm|ogg|mov|mkv|m4v|m3u8)(\?|#|$)/i.test(t)||t.startsWith("/uploads/")}function g(e){return h.getImageUrl(String(e||"").trim())}function S(e,t){const r=e.trim();return/\.mp4(\?|#|$)/i.test(r)?`MP4 ${t+1}`:/vod-qcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`点播 ${t+1}`:/\.cos\.[^/]+\.myqcloud\.com/i.test(r)&&/\.m3u8/i.test(r)?`COS HLS ${t+1}`:/\.m3u8/i.test(r)?`HLS ${t+1}`:`链接 ${t+1}`}return(e,t)=>{const r=q;return u.value.length?(i(),c("div",R,[d("div",C,[a.value?(i(),c(v,{key:0},[b(a.value)?(i(),m(H,{key:`${l.recordId}-${a.value}`,src:a.value},null,8,["src"])):(i(),m(r,{key:1,href:g(a.value),target:"_blank",type:"primary"},{default:_(()=>[...t[0]||(t[0]=[k(" 打开回放 ",-1)])]),_:1},8,["href"]))],64)):y("",!0),p.value.length?(i(),c("div",V,[t[1]||(t[1]=d("span",{class:"recording-alternates__label"},"备用地址",-1)),d("div",N,[(i(!0),c(v,null,B(p.value,(o,s)=>(i(),m(r,{key:`${l.recordId}-alt-${s}-${o.slice(-32)}`,href:g(o),target:"_blank",type:"primary",class:"recording-alternates__link"},{default:_(()=>[k(w(S(o,s)),1)]),_:2},1032,["href"]))),128))])])):y("",!0)])])):(i(),c("span",D,"暂无"))}}}),x=P(E,[["__scopeId","data-v-d67b2e91"]]);export{x as default};
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-CC7b2w5D.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-CC7b2w5D.js";import{e as ae,_ as ne}from"./index-Ur49qshF.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-CC7b2w5D.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/.pnpm-C1KTa5cB.js","assets/.pnpm-B3v8nGpq.css"])))=>i.map(i=>d[i]);
|
||||
import{o as K,ap as z,n as S,R as H,aq as G,q as v,r as p,s as f,ac as O,ad as W,b8 as $,aw as j,F as w,v as k,D as I,u as J,b5 as Q,w as X,P,L as Y,bq as Z,M as _,p as E,al as ee}from"./.pnpm-C1KTa5cB.js";import{e as ae,_ as ne}from"./index-Cxx1IoGl.js";const te={class:"recording-playback__shell"},re=["preload"],oe=["onKeydown"],le=K({__name:"RecordingVideoPlayer",props:{src:{}},setup(B){const V=B,A=ae(),b=_(null),m=_(null),c=_(!1),s=_(!1),t=_(!1);let d=null,r=null,l=0,u=null;function i(){u!==null&&(clearTimeout(u),u=null)}const y=E(()=>{const e=String(V.src||"").trim();return e?N(A.getImageUrl(e)):""}),D=E(()=>{if(!s.value)return"none";const e=y.value;return e&&!L(e)?"metadata":"none"}),q=E(()=>!s.value||t.value);function N(e){if(!e||!/^https?:\/\//i.test(e))return e;try{const a=new URL(e),n=a.hostname.toLowerCase();if(n.includes("myqcloud.com")||n.includes("vod-qcloud.com")||n.includes("vod-qcloud"))return a.pathname.includes("+")&&(a.pathname=a.pathname.replace(/\+/g,"%2B")),a.href}catch{}return e}function L(e){return/\.m3u8(\?|#|$)/i.test(e)}function U(){var n;t.value=!1,i();const e=m.value,a=(n=e==null?void 0:e.error)==null?void 0:n.code;a===MediaError.MEDIA_ERR_ABORTED||a===1||(c.value=!0)}function h(){d&&(d.destroy(),d=null);const e=m.value;e&&(e.pause(),e.removeAttribute("src"),e.load())}function R(e,a){let n=!1;const o=()=>{n||a!==l||(n=!0,i(),t.value=!1)};e.addEventListener("loadedmetadata",()=>o(),{once:!0}),e.addEventListener("loadeddata",()=>o(),{once:!0}),e.addEventListener("canplay",()=>o(),{once:!0}),e.addEventListener("playing",()=>o(),{once:!0}),e.addEventListener("progress",()=>{try{e.buffered.length>0&&e.buffered.end(0)>0&&o("progress-buffered")}catch{}},{passive:!0})}async function g(){if(!s.value)return;await S();const e=m.value,a=y.value;if(!e)return;if(!a){t.value=!1,i();return}const n=++l;if(t.value=!0,i(),h(),c.value=!1,L(a)){try{const o=await ee(()=>import("./.pnpm-C1KTa5cB.js").then(M=>M.dN),__vite__mapDeps([0,1])),x=o.default;if(x.isSupported()){d=new x({enableWorker:!0,lowLatencyMode:!1,maxBufferLength:20,maxMaxBufferLength:120}),d.on(o.Events.MANIFEST_PARSED,()=>{n===l&&(t.value=!1,i())}),d.on(o.Events.ERROR,(M,F)=>{if(F.fatal){if(n!==l)return;t.value=!1,i(),c.value=!0,h()}}),d.loadSource(a),d.attachMedia(e),u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}if(e.canPlayType("application/vnd.apple.mpegurl")){R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},45e3);return}}catch{if(n!==l)return;t.value=!1,i(),c.value=!0;return}if(n!==l)return;t.value=!1,i(),c.value=!0;return}R(e,n),e.src=a,u=setTimeout(()=>{u=null,n===l&&t.value&&(t.value=!1)},25e3)}function C(){const e=b.value;if(!e||typeof IntersectionObserver>"u"){s.value=!0,g();return}r=new IntersectionObserver(a=>{for(const n of a)if(n.isIntersecting){r==null||r.disconnect(),r=null,s.value=!0,g();break}},{root:null,rootMargin:"240px 0px",threshold:0}),r.observe(e)}function T(){r==null||r.disconnect(),r=null,s.value||(s.value=!0),g()}return z(()=>{S(()=>{C()})}),H(y,()=>{s.value&&g()}),G(()=>{r==null||r.disconnect(),r=null,i(),l++,h()}),(e,a)=>{const n=X,o=Z;return v(),p("div",{ref_key:"wrapRef",ref:b,class:"recording-playback"},[f("div",te,[O(f("video",{ref_key:"videoRef",ref:m,class:"recording-video",controls:"",playsinline:"","webkit-playsinline":"",preload:D.value,onError:U},null,40,re),[[W,!c.value]]),!c.value&&q.value?(v(),p("div",{key:0,class:"recording-playback__overlay",role:"button",tabindex:"0",onClick:T,onKeydown:$(j(T,["prevent"]),["enter"])},[s.value?(v(),p(w,{key:1},[k(n,{class:"recording-playback__spin"},{default:I(()=>[k(J(Q))]),_:1}),a[2]||(a[2]=f("span",null,"正在加载…",-1))],64)):(v(),p(w,{key:0},[a[0]||(a[0]=f("span",{class:"recording-playback__overlay-title"},"预览待加载",-1)),a[1]||(a[1]=f("span",{class:"recording-playback__overlay-desc"},"滚动至此处将自动加载(减轻并发卡顿),也可点击立即加载",-1))],64))],40,oe)):P("",!0)]),c.value?(v(),p(w,{key:0},[k(o,{href:y.value||"#",target:"_blank",type:"primary"},{default:I(()=>[...a[3]||(a[3]=[Y("在新窗口打开",-1)])]),_:1},8,["href"]),a[4]||(a[4]=f("div",{class:"recording-playback__hint"}," 内嵌播放失败(编码不支持、跨域或私有存储等)时可尝试在新窗口打开。 ",-1))],64)):P("",!0)],512)}}}),ie=ne(le,[["__scopeId","data-v-749b0199"]]);export{ie as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-CC7b2w5D.js";import{a0 as L}from"./tcm-CrnCrBQX.js";import{i as M,_ as S}from"./index-Ur49qshF.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
import{o as T,q as e,r as t,v as m,b7 as V,s as d,D as w,L as I,K as E,P as r,F as u,G as v,T as g,O as C,bB as z,M as p}from"./.pnpm-C1KTa5cB.js";import{a4 as L}from"./tcm-DJJMkqKG.js";import{i as M,_ as S}from"./index-Cxx1IoGl.js";/* empty css */const D={class:"tracking-timeline-wrap"},F={key:0,class:"timeline-input"},H={class:"input-actions"},q={key:1,class:"tracking-timeline"},A={class:"timeline-date"},G={class:"timeline-body"},K={key:0,class:"timeline-content"},O=T({__name:"TrackingNoteTimeline",props:{notes:{},diagnosisId:{},readonly:{type:Boolean}},emits:["refresh"],setup(n,{emit:f}){const c=n,y=f,o=p(""),l=p(!1),_=s=>s?s.split(`
|
||||
`).filter(Boolean):[],k=async()=>{if(!c.diagnosisId)return;const s=o.value.trim();if(s){l.value=!0;try{await L({diagnosis_id:c.diagnosisId,tracking_content:s}),M.msgSuccess("已添加"),o.value="",y("refresh")}finally{l.value=!1}}};return(s,i)=>{const b=V,h=E,B=z;return e(),t("div",D,[!n.readonly&&n.diagnosisId?(e(),t("div",F,[m(b,{modelValue:o.value,"onUpdate:modelValue":i[0]||(i[0]=a=>o.value=a),type:"textarea",rows:2,placeholder:"输入跟踪备注,回车换行;保存后将以「[HH:MM] 内容」追加到当天记录",maxlength:"1000","show-word-limit":"",resize:"none",disabled:l.value},null,8,["modelValue","disabled"]),d("div",H,[m(h,{type:"primary",size:"small",loading:l.value,disabled:!o.value.trim(),onClick:k},{default:w(()=>[...i[1]||(i[1]=[I(" 添加 ",-1)])]),_:1},8,["loading","disabled"])])])):r("",!0),n.notes.length?(e(),t("div",q,[(e(!0),t(u,null,v(n.notes,a=>(e(),t("div",{key:a.id,class:"timeline-node"},[i[2]||(i[2]=d("div",{class:"timeline-dot"},null,-1)),d("div",A,g(a.note_date),1),d("div",G,[a.content?(e(),t("div",K,[(e(!0),t(u,null,v(_(a.content),(x,N)=>(e(),t("div",{key:N,class:"content-line"},g(x),1))),128))])):r("",!0)])]))),128))])):r("",!0),!n.notes.length&&n.readonly?(e(),C(B,{key:2,description:"暂无跟踪备注","image-size":48})):r("",!0)])}}}),Q=S(O,[["__scopeId","data-v-82b635bd"]]);export{Q as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-lFOY4Hme.js";import"./.pnpm-CC7b2w5D.js";import"./index-BhFu9zdX.js";import"./index-Ur49qshF.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./account-adjust.vue_vue_type_script_setup_true_lang-B-w5z28y.js";import"./.pnpm-C1KTa5cB.js";import"./index-B3Fu5tec.js";import"./index-Cxx1IoGl.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-CC7b2w5D.js";import{_ as L}from"./index-BhFu9zdX.js";import{i as V}from"./index-Ur49qshF.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
import{o as h,R as v,q,O as B,D as l,s as D,v as t,b9 as F,u as n,b6 as I,L as u,T as w,bc as j,bf as S,b7 as T,a8 as y,ba as U,p as G}from"./.pnpm-C1KTa5cB.js";import{_ as L}from"./index-B3Fu5tec.js";import{i as V}from"./index-Cxx1IoGl.js";const M={class:"pr-8"},H=h({__name:"account-adjust",props:{show:{type:Boolean,required:!0},value:{type:[Number,String],required:!0}},emits:["update:show","confirm"],setup(d,{emit:k}){const s=y(),i=d,f=k,o=U({action:1,num:"",remark:""}),m=y(),c=G(()=>Number(i.value)+Number(o.num)*(o.action==1?1:-1)),R={num:[{required:!0,message:"请输入调整的金额"}]},g=e=>{if(e.includes("-"))return V.msgError("请输入正整数");o.num=e},x=async()=>{var e;await((e=s.value)==null?void 0:e.validate()),f("confirm",o)},E=()=>{var e;f("update:show",!1),(e=s.value)==null||e.resetFields()};return v(()=>i.show,e=>{var a,r;e?(a=m.value)==null||a.open():(r=m.value)==null||r.close()}),v(c,e=>{e<0&&(V.msgError("调整后余额需大于0"),o.num="")}),(e,a)=>{const r=I,_=S,C=j,b=T,N=F;return q(),B(L,{ref_key:"popupRef",ref:m,title:"余额调整",width:"500px",onConfirm:x,async:!0,onClose:E},{default:l(()=>[D("div",M,[t(N,{ref_key:"formRef",ref:s,model:n(o),"label-width":"120px",rules:R},{default:l(()=>[t(r,{label:"当前余额"},{default:l(()=>[u("¥ "+w(d.value),1)]),_:1}),t(r,{label:"余额增减",required:"",prop:"action"},{default:l(()=>[t(C,{modelValue:n(o).action,"onUpdate:modelValue":a[0]||(a[0]=p=>n(o).action=p)},{default:l(()=>[t(_,{value:1},{default:l(()=>[...a[2]||(a[2]=[u("增加余额",-1)])]),_:1}),t(_,{value:2},{default:l(()=>[...a[3]||(a[3]=[u("扣减余额",-1)])]),_:1})]),_:1},8,["modelValue"])]),_:1}),t(r,{label:"调整余额",prop:"num"},{default:l(()=>[t(b,{"model-value":n(o).num,placeholder:"请输入调整的金额",type:"number",onInput:g},null,8,["model-value"])]),_:1}),t(r,{label:"调整后余额"},{default:l(()=>[u(" ¥ "+w(n(c)),1)]),_:1}),t(r,{label:"备注",prop:"remark"},{default:l(()=>[t(b,{modelValue:n(o).remark,"onUpdate:modelValue":a[1]||(a[1]=p=>n(o).remark=p),type:"textarea",rows:4},null,8,["modelValue"])]),_:1})]),_:1},8,["model"])])]),_:1},512)}}});export{H as _};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user