This commit is contained in:
Your Name
2026-03-23 18:02:16 +08:00
parent 18fee2e8f8
commit 250d173c2f
525 changed files with 10659 additions and 793 deletions
@@ -2,157 +2,419 @@
<el-drawer
v-model="visible"
title="创建处方单"
size="800px"
size="90%"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
label-width="100px"
:rules="rules"
label-width="120px"
class="prescription-form"
>
<!-- 患者基本信息 -->
<div class="info-section">
<div class="section-title">患者信息</div>
<el-form-item label="姓名">
<span>{{ formData.patient_name }}</span>
</el-form-item>
<el-form-item label="性别">
<span>{{ formData.gender }}</span>
</el-form-item>
<el-form-item label="年龄">
<span>{{ formData.age }}</span>
</el-form-item>
<el-form-item label="诊号">
<span>{{ formData.diagnosis_no }}</span>
</el-form-item>
<el-form-item label="日期">
<span>{{ formData.date }}</span>
</el-form-item>
<el-form-item label="科室">
<span>{{ formData.department }}</span>
</el-form-item>
<el-form-item label="费别">
<span>{{ formData.fee_type }}</span>
</el-form-item>
<el-form-item label="手机号">
<span>{{ formData.phone }}</span>
</el-form-item>
</div>
<!-- 诊断信息 -->
<div class="info-section">
<div class="section-title">诊断信息</div>
<el-form-item label="诊断" prop="diagnosis">
<el-input
v-model="formData.diagnosis"
type="textarea"
:rows="3"
placeholder="请输入诊断信息"
/>
</el-form-item>
</div>
<!-- 药品信息 -->
<div class="info-section">
<div class="section-title">
药品信息
<el-button type="primary" size="small" @click="handleAddMedicine">
添加药品
</el-button>
</div>
<div
v-for="(medicine, index) in formData.medicines"
:key="index"
class="medicine-item"
>
<el-form-item :label="`药品${index + 1}`">
<el-input
v-model="medicine.name"
placeholder="药品名称"
class="mb-2"
/>
<el-input
v-model="medicine.usage"
type="textarea"
:rows="2"
placeholder="用法用量"
/>
<el-button
type="danger"
size="small"
link
@click="handleRemoveMedicine(index)"
class="mt-2"
>
删除
</el-button>
<el-form-item label="处方名称" prop="prescription_name">
<el-input
v-model="formData.prescription_name"
placeholder="请输入处方名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="处方类型" prop="prescription_type">
<el-select v-model="formData.prescription_type" placeholder="请选择处方类型" class="w-full">
<el-option label="浓缩水丸" value="浓缩水丸" />
<el-option label="饮片" value="饮片" />
<el-option label="颗粒" value="颗粒" />
<el-option label="丸剂" value="丸剂" />
<el-option label="散剂" value="散剂" />
<el-option label="膏方" value="膏方" />
<el-option label="汤剂" value="汤剂" />
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="患者姓名" prop="patient_name">
<el-input v-model="formData.patient_name" placeholder="请输入患者姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="门诊号" prop="visit_no">
<el-input v-model="formData.visit_no" placeholder="自动生成或手动输入" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender">
<el-radio :label="1"></el-radio>
<el-radio :label="0"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="年龄" prop="age">
<el-input-number
v-model="formData.age"
:min="0"
:max="150"
placeholder="请输入年龄"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="处方日期" prop="prescription_date">
<el-date-picker
v-model="formData.prescription_date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="面象" prop="tongue">
<el-input v-model="formData.tongue" placeholder="请输入面象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="舌象" prop="tongue_image">
<el-input v-model="formData.tongue_image" placeholder="请输入舌象" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="脉象" prop="pulse">
<el-input v-model="formData.pulse" placeholder="请输入脉象" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉象详情" prop="pulse_condition">
<el-input v-model="formData.pulse_condition" placeholder="请输入脉象详情" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="临床诊断" prop="clinical_diagnosis">
<el-input
v-model="formData.clinical_diagnosis"
type="textarea"
:rows="3"
placeholder="请输入临床诊断"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="药材配方" prop="herbs" required>
<div class="w-full">
<el-button
type="primary"
size="small"
@click="addHerb"
>
添加药材
</el-button>
<el-table :data="formData.herbs" class="mt-2" border>
<el-table-column label="序号" type="index" width="60" />
<el-table-column label="药材名称" min-width="150">
<template #default="{ row }">
<el-input
v-model="row.name"
placeholder="请输入药材名称"
/>
</template>
</el-table-column>
<el-table-column label="剂量(克)" min-width="120">
<template #default="{ row }">
<el-input-number
v-model="row.dosage"
:min="0"
:precision="1"
:step="0.5"
placeholder="剂量"
class="w-full"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button
type="danger"
link
@click="removeHerb($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="剂数" prop="dose_count">
<el-input-number
v-model="formData.dose_count"
:min="1"
placeholder="剂数"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="剂量单位" prop="dose_unit">
<el-select v-model="formData.dose_unit" placeholder="请选择" class="w-full">
<el-option label="剂" value="剂" />
<el-option label="丸" value="丸" />
<el-option label="袋" value="袋" />
<el-option label="盒" value="盒" />
<el-option label="瓶" value="瓶" />
<el-option label="膏" value="膏" />
<el-option label="贴" value="贴" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="服用天数" prop="usage_days">
<el-input-number
v-model="formData.usage_days"
:min="1"
:max="365"
placeholder="服用天数"
class="w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="用法" prop="usage_instruction">
<el-input
v-model="formData.usage_instruction"
placeholder="例如:水煎服,一日二次"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="服用时间" prop="usage_time">
<el-select v-model="formData.usage_time" placeholder="请选择服用时间" class="w-full">
<el-option label="饭前" value="饭前" />
<el-option label="饭后" value="饭后" />
<el-option label="饭中" value="饭中" />
<el-option label="空腹" value="空腹" />
<el-option label="睡前" value="睡前" />
<el-option label="晨起" value="晨起" />
<el-option label="随时" value="随时" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服用方式" prop="usage_way">
<el-select v-model="formData.usage_way" placeholder="请选择服用方式" class="w-full">
<el-option label="温水送服" value="温水送服" />
<el-option label="开水冲服" value="开水冲服" />
<el-option label="黄酒送服" value="黄酒送服" />
<el-option label="淡盐水送服" value="淡盐水送服" />
<el-option label="米汤送服" value="米汤送服" />
<el-option label="嚼服" value="嚼服" />
<el-option label="含化" value="含化" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="忌口" prop="dietary_taboo">
<el-select
v-model="formData.dietary_taboo"
multiple
placeholder="请选择忌口内容(可多选)"
class="w-full"
>
<el-option label="辛辣食物" value="辛辣食物" />
<el-option label="生冷食物" value="生冷食物" />
<el-option label="油腻食物" value="油腻食物" />
<el-option label="海鲜" value="海鲜" />
<el-option label="牛羊肉" value="牛羊肉" />
<el-option label="鸡蛋" value="鸡蛋" />
<el-option label="豆制品" value="豆制品" />
<el-option label="酒类" value="酒类" />
<el-option label="浓茶" value="浓茶" />
<el-option label="咖啡" value="咖啡" />
<el-option label="烟草" value="烟草" />
<el-option label="萝卜" value="萝卜" />
</el-select>
</el-form-item>
<el-form-item label="其他说明" prop="usage_notes">
<el-input
v-model="formData.usage_notes"
type="textarea"
:rows="2"
placeholder="其他服用注意事项"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="医师姓名" prop="doctor_name">
<el-input v-model="formData.doctor_name" placeholder="请输入医师姓名" />
</el-form-item>
<el-form-item label="是否共享" prop="is_shared">
<el-switch
v-model="formData.is_shared"
:active-value="1"
:inactive-value="0"
active-text="所有人可见"
inactive-text="仅自己可见"
/>
<div class="text-xs text-gray-400 mt-1">
勾选后所有医生都可以查看和使用此处方模板
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button type="primary" @click="handleSave" :loading="submitLoading">保存</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { prescriptionAdd } from '@/api/tcm'
import feedback from '@/utils/feedback'
interface Medicine {
name: string
usage: string
}
interface PrescriptionForm {
appointment_id: number
patient_name: string
gender: string
age: string
diagnosis_no: string
date: string
department: string
fee_type: string
phone: string
diagnosis: string
medicines: Medicine[]
}
import type { FormInstance, FormRules } from 'element-plus'
const emit = defineEmits(['success'])
const visible = ref(false)
const formRef = ref()
const formData = reactive<PrescriptionForm>({
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const formData = reactive({
appointment_id: 0,
diagnosis_id: 0,
prescription_name: '',
prescription_type: '浓缩水丸',
patient_name: '',
gender: '',
age: '',
diagnosis_no: '',
date: '',
department: '',
fee_type: '',
phone: '',
diagnosis: '',
medicines: []
gender: 1,
age: 0,
visit_no: '',
prescription_date: '',
tongue: '',
tongue_image: '',
pulse: '',
pulse_condition: '',
clinical_diagnosis: '',
herbs: [] as Array<{ name: string; dosage: number }>,
dose_count: 7,
dose_unit: '剂',
usage_days: 7,
usage_instruction: '水煎服,一日二次',
usage_time: '饭前',
usage_way: '温水送服',
dietary_taboo: [] as string[],
usage_notes: '',
doctor_name: '',
is_shared: 0
})
// 表单验证规则
const rules: FormRules = {
prescription_name: [
{ required: true, message: '请输入处方名称', trigger: 'blur' }
],
patient_name: [
{ required: true, message: '请输入患者姓名', trigger: 'blur' }
],
gender: [
{ required: true, message: '请选择性别', trigger: 'change' }
],
prescription_date: [
{ required: true, message: '请选择处方日期', trigger: 'change' }
],
clinical_diagnosis: [
{ required: true, message: '请输入临床诊断', trigger: 'blur' }
],
dose_count: [
{ required: true, message: '请输入剂数', trigger: 'blur' }
],
doctor_name: [
{ required: true, message: '请输入医师姓名', trigger: 'blur' }
]
}
// 添加药材
const addHerb = () => {
formData.herbs.push({
name: '',
dosage: 0
})
}
// 删除药材
const removeHerb = (index: number) => {
formData.herbs.splice(index, 1)
}
// 重置表单
const resetForm = () => {
formData.appointment_id = 0
formData.diagnosis_id = 0
formData.prescription_name = ''
formData.prescription_type = '浓缩水丸'
formData.patient_name = ''
formData.gender = 1
formData.age = 0
formData.visit_no = ''
formData.prescription_date = new Date().toISOString().split('T')[0]
formData.tongue = ''
formData.tongue_image = ''
formData.pulse = ''
formData.pulse_condition = ''
formData.clinical_diagnosis = ''
formData.herbs = []
formData.dose_count = 7
formData.dose_unit = '剂'
formData.usage_days = 7
formData.usage_instruction = '水煎服,一日二次'
formData.usage_time = '饭前'
formData.usage_way = '温水送服'
formData.dietary_taboo = []
formData.usage_notes = ''
formData.doctor_name = ''
formData.is_shared = 0
}
// 打开抽屉
const open = (data: any) => {
formData.appointment_id = data.id
resetForm()
formData.appointment_id = data.id || 0
formData.diagnosis_id = data.diagnosis_id || 0
formData.patient_name = data.patient_name || ''
formData.gender = data.patient_gender || ''
formData.age = data.patient_age || ''
formData.diagnosis_no = data.id.toString()
formData.date = data.appointment_date || ''
formData.department = '中医科'
formData.fee_type = data.appointment_type_desc || ''
formData.phone = data.patient_phone || ''
formData.diagnosis = ''
formData.medicines = []
formData.gender = data.patient_gender === '' ? 1 : 0
formData.age = parseInt(data.patient_age) || 0
formData.visit_no = data.id?.toString() || ''
formData.prescription_date = data.appointment_date || new Date().toISOString().split('T')[0]
formData.doctor_name = data.doctor_name || ''
visible.value = true
}
@@ -163,48 +425,41 @@ const handleClose = () => {
formRef.value?.resetFields()
}
// 添加药品
const handleAddMedicine = () => {
formData.medicines.push({
name: '',
usage: ''
})
}
// 删除药品
const handleRemoveMedicine = (index: number) => {
formData.medicines.splice(index, 1)
}
// 保存处方单
const handleSave = async () => {
if (!formData.diagnosis) {
feedback.msgWarning('请输入诊断信息')
if (!formRef.value) return
await formRef.value.validate()
// 验证药材
if (!formData.herbs || formData.herbs.length === 0) {
feedback.msgError('请至少添加一味药材')
return
}
if (formData.medicines.length === 0) {
feedback.msgWarning('请至少添加一个药品')
return
}
// 验证药品信息
for (const medicine of formData.medicines) {
if (!medicine.name || !medicine.usage) {
feedback.msgWarning('请完善药品信息')
for (let i = 0; i < formData.herbs.length; i++) {
const herb = formData.herbs[i]
if (!herb.name || !herb.name.trim()) {
feedback.msgError(`${i + 1}味药材名称不能为空`)
return
}
if (!herb.dosage || herb.dosage <= 0) {
feedback.msgError(`${i + 1}味药材剂量必须大于0`)
return
}
}
submitLoading.value = true
try {
// TODO: 调用保存处方单的API
// await savePrescription(formData)
await prescriptionAdd(formData)
feedback.msgSuccess('处方单创建成功')
handleClose()
emit('success')
} catch (error) {
feedback.msgError('保存失败')
console.error(error)
} finally {
submitLoading.value = false
}
}
@@ -215,41 +470,26 @@ defineExpose({
<style scoped lang="scss">
.prescription-form {
.info-section {
margin-bottom: 24px;
padding: 16px;
background: #f5f7fa;
border-radius: 4px;
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
display: flex;
justify-content: space-between;
align-items: center;
}
.el-form-item {
margin-bottom: 12px;
span {
color: #606266;
}
}
padding: 0 20px;
.w-full {
width: 100%;
}
.medicine-item {
margin-bottom: 16px;
padding: 12px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 4px;
.el-form-item {
margin-bottom: 0;
}
.mt-1 {
margin-top: 4px;
}
.mt-2 {
margin-top: 8px;
}
.text-xs {
font-size: 12px;
}
.text-gray-400 {
color: #909399;
}
}
</style>
@@ -0,0 +1,149 @@
<template>
<div class="call-record-panel">
<el-alert
type="info"
show-icon
:closable="false"
class="mb-4"
title="视频通话与云端录制"
>
<p class="call-record-tip">
下列为与本诊单关联的通话记录若在腾讯云 TRTC
控制台开启了云端录制并填写回调地址录制生成后会自动写入录制回放
</p>
<p class="call-record-tip muted">
回调 URL 示例<code>{{ callbackHint }}</code>
若配置了 <code>trtc.recording_callback_token</code>请在 URL 后附加
<code>?token=你的密钥</code>
</p>
</el-alert>
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无通话记录">
<el-table-column label="开始时间" width="170" prop="start_time_text" />
<el-table-column label="结束时间" width="170" prop="end_time_text" />
<el-table-column label="通话类型" width="100">
<template #default="{ row }">
{{ row.call_type === 1 ? '语音' : '视频' }}
</template>
</el-table-column>
<el-table-column label="时长" width="110" prop="duration_text" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
{{ statusText(row.status) }}
</template>
</el-table-column>
<el-table-column label="录制" width="100">
<template #default="{ row }">
{{ row.recording_status_text || '—' }}
</template>
</el-table-column>
<el-table-column label="录制回放" min-width="280">
<template #default="{ row }">
<div v-if="(row.recording_urls_list || []).length" class="recording-list">
<div
v-for="(url, idx) in row.recording_urls_list"
:key="idx"
class="recording-item"
>
<video
v-if="isPlayableVideo(url)"
:src="url"
controls
preload="metadata"
class="recording-video"
/>
<el-link v-else :href="url" target="_blank" type="primary">
打开链接 {{ idx + 1 }}
</el-link>
</div>
</div>
<span v-else class="text-gray-400">暂无</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { getCallRecords } from '@/api/tcm'
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const rows = ref<any[]>([])
const callbackHint = computed(() => {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/api/trtc/recording-notify`
})
const load = async () => {
if (!props.diagnosisId) return
loading.value = true
try {
rows.value = (await getCallRecords({ diagnosis_id: props.diagnosisId })) || []
} catch (e) {
console.error(e)
rows.value = []
} finally {
loading.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load()
},
{ immediate: true }
)
defineExpose({ refresh: load })
function statusText(status: number) {
const m: Record<number, string> = {
1: '进行中',
2: '已结束',
3: '未接听',
4: '已取消'
}
return m[status] ?? '—'
}
function isPlayableVideo(url: string) {
if (!url || typeof url !== 'string') return false
return /\.(mp4|webm|ogg)(\?|$)/i.test(url)
}
</script>
<style scoped lang="scss">
.call-record-panel {
.call-record-tip {
margin: 0 0 8px;
line-height: 1.5;
font-size: 13px;
&.muted {
color: var(--el-text-color-secondary);
}
code {
font-size: 12px;
word-break: break-all;
}
}
.recording-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.recording-video {
max-width: 100%;
max-height: 180px;
border-radius: 4px;
background: #000;
}
}
</style>
@@ -0,0 +1,355 @@
<template>
<div class="im-chat-record-panel">
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
<p class="panel-tip">
展示腾讯云 IM 单聊记录已合并患者 <code>{{ patientImHint }}</code>
<strong>所有医生 / 医助账号</strong><code>doctor_*</code>分别产生的会话按时间排序
数据来自 IM 漫游若未开通漫游或某账号与该患者无会话对应侧无消息
</p>
</el-alert>
<div class="toolbar mb-3">
<el-button type="primary" link :loading="loading" @click="load">
<el-icon class="mr-1"><Refresh /></el-icon>
刷新
</el-button>
</div>
<div v-loading="loading" class="chat-wrap">
<template v-if="!loading && rows.length">
<div class="chat-list">
<div
v-for="row in rows"
:key="row.msg_id"
class="chat-row"
:class="row.is_from_doctor ? 'from-doctor' : 'from-patient'"
>
<div class="meta">
<span class="name">{{ senderLabel(row) }}</span>
<span class="time">{{ formatTime(row.time) }}</span>
<el-tag v-if="typeLabel(row)" size="small" type="info" class="ml-2">
{{ typeLabel(row) }}
</el-tag>
</div>
<div class="bubble">
<template v-if="row.msg_type === 'image' && row.image_url">
<el-image
:src="row.image_url"
:preview-src-list="[row.image_url]"
fit="contain"
class="chat-img"
/>
</template>
<template v-else-if="(row.msg_type === 'file' || row.msg_type === 'sound' || row.msg_type === 'video') && row.file_url">
<el-link :href="row.file_url" target="_blank" type="primary">
{{ row.file_name || '打开文件' }}
</el-link>
</template>
<template v-else>
<template v-for="fr in [parseImFriendly(row)]" :key="row.msg_id + '-body'">
<div v-if="fr" class="friendly-text">
<div class="friendly-main">{{ fr.main }}</div>
<div v-if="fr.sub" class="friendly-sub">{{ fr.sub }}</div>
</div>
<div v-else-if="row.msg_type === 'text' && row.text" class="text-content">
{{ row.text }}
</div>
<div v-else-if="row.text" class="text-content">{{ row.text }}</div>
<span v-else class="muted">无法展示该消息类型</span>
</template>
</template>
</div>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import dayjs from 'dayjs'
import { Refresh } from '@element-plus/icons-vue'
import { getImChatMessages } from '@/api/tcm'
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const rows = ref<any[]>([])
const patientImId = ref('')
const patientName = ref('')
const patientImHint = computed(() => patientImId.value || 'patient_*')
function formatTime(ts: number | undefined) {
if (ts == null || !ts) return '—'
const sec = ts > 1e12 ? Math.floor(ts / 1000) : ts
return dayjs.unix(sec).format('YYYY-MM-DD HH:mm:ss')
}
type FriendlyParse = { main: string; sub?: string; tag?: string }
/** 业务时间:支持秒级时间戳或毫秒(字符串数字) */
function formatBizTime(t: unknown): string | undefined {
if (t == null || t === '') return undefined
const n =
typeof t === 'string' && /^\d+$/.test(t.trim())
? parseInt(t.trim(), 10)
: Number(t)
if (!Number.isFinite(n) || n <= 0) return undefined
return n > 1e12
? dayjs(n).format('YYYY-MM-DD HH:mm:ss')
: dayjs.unix(n).format('YYYY-MM-DD HH:mm:ss')
}
/** 将 IM 自定义 / 信令 JSON 解析为可读文案(医生进诊室、音视频通话等) */
function parseImBusinessPayload(raw: string): FriendlyParse | null {
const s = raw.trim()
if (!s.startsWith('{')) return null
let o: any
try {
o = JSON.parse(s)
} catch {
return null
}
if (!('businessID' in o) && !('cmd' in o)) {
return null
}
if (o.businessID === 'doctor_entered_consult_room') {
const t = formatBizTime(o.time)
return { main: '医生已进入诊室', sub: t, tag: '诊室' }
}
/** 小程序端:患者打开与医生的会话时发送(与 TUIKit/chat.vue 一致) */
if (o.businessID === 'patient_opened_chat') {
const parts: string[] = []
if (o.patientName) parts.push(`患者:${o.patientName}`)
if (o.patientId != null && o.patientId !== '') parts.push(`患者 ID${o.patientId}`)
if (o.doctorId != null && o.doctorId !== '') parts.push(`关联医生:${o.doctorId}`)
const t = formatBizTime(o.time)
if (t) parts.push(t)
return { main: '患者进入聊天', sub: parts.length ? parts.join(' · ') : undefined, tag: '诊室' }
}
if (o.businessID === 'user_typing_status') {
return { main: '对方正在输入…', tag: '状态' }
}
if (o.businessID === 'consultation_complete') {
return { main: '问诊已完成', sub: formatBizTime(o.time), tag: '诊室' }
}
if (o.businessID === 1 || o.businessID === '1') {
let inner: any = o.data
if (typeof inner === 'string') {
try {
inner = JSON.parse(inner)
} catch {
return { main: '通话信令', sub: '内层数据解析失败', tag: '通话' }
}
}
if (inner && typeof inner === 'object') {
return parseRtcInner(inner)
}
return null
}
if (o.businessID === 'rtc_call' || o.cmd) {
return parseRtcInner(o)
}
return null
}
function parseRtcInner(inner: any): FriendlyParse {
const callType = inner.call_type
const callTypeLabel =
callType === 2 ? '视频通话' : callType === 1 ? '语音通话' : '通话'
const cmd = String(inner.cmd || '')
const cmdMap: Record<string, string> = {
hangup: '已结束',
invite: '发起通话',
linebusy: '对方忙线',
cancel: '已取消',
reject: '已拒绝',
accept: '已接听',
timeout: '无人接听'
}
const action = cmdMap[cmd] || (cmd ? `状态:${cmd}` : '')
const main = action ? `${callTypeLabel} · ${action}` : callTypeLabel
const parts: string[] = []
if (inner.inviter) parts.push(`发起方 ${inner.inviter}`)
if (inner.invitee) parts.push(`对方 ${inner.invitee}`)
if (inner.groupID) parts.push(`群组 ${inner.groupID}`)
return {
main,
sub: parts.length ? parts.join('') : undefined,
tag: '通话'
}
}
function parseImFriendly(row: any): FriendlyParse | null {
const raw = (row.text || '').trim()
if (!raw) return null
const looksLikeBizJson =
raw.startsWith('{') && (/\bbusinessID\b/.test(raw) || /\bcmd\b/.test(raw))
if (row.msg_type === 'custom' || looksLikeBizJson) {
return parseImBusinessPayload(raw)
}
return null
}
function typeLabel(row: any) {
const fr = parseImFriendly(row)
if (fr?.tag) return fr.tag
const m: Record<string, string> = {
text: '',
image: '图片',
file: '文件',
sound: '语音',
video: '视频',
location: '位置',
custom: '自定义',
face: '表情',
other: ''
}
return m[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
function senderLabel(row: any) {
if (row.is_from_doctor) {
return row.from_staff_name ? `医生(${row.from_staff_name}` : '医生/员工'
}
return patientName.value ? `患者(${patientName.value}` : '患者'
}
async function load() {
if (!props.diagnosisId) return
loading.value = true
try {
const res = (await getImChatMessages({ diagnosis_id: props.diagnosisId })) as {
lists?: any[]
patient_im_id?: string
patient_name?: string
}
rows.value = res?.lists || []
patientImId.value = res?.patient_im_id || ''
patientName.value = res?.patient_name || ''
} catch (e) {
console.error(e)
rows.value = []
} finally {
loading.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load()
},
{ immediate: true }
)
defineExpose({ refresh: load })
</script>
<style scoped lang="scss">
.im-chat-record-panel {
min-height: 200px;
}
.panel-tip {
margin: 0;
line-height: 1.55;
font-size: 13px;
code {
font-size: 12px;
}
}
.toolbar {
display: flex;
align-items: center;
}
.chat-wrap {
min-height: 120px;
}
.chat-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: min(60vh, 520px);
overflow-y: auto;
padding: 4px 8px 12px;
}
.chat-row {
display: flex;
flex-direction: column;
max-width: 88%;
&.from-patient {
align-self: flex-start;
.bubble {
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
}
}
&.from-doctor {
align-self: flex-end;
align-items: flex-end;
.meta {
flex-direction: row-reverse;
}
.bubble {
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
}
}
}
.meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
.name {
font-weight: 500;
color: var(--el-text-color-regular);
}
}
.bubble {
border-radius: 8px;
padding: 10px 12px;
word-break: break-word;
font-size: 14px;
line-height: 1.5;
}
.text-content {
white-space: pre-wrap;
}
.chat-img {
max-width: 240px;
max-height: 200px;
border-radius: 4px;
}
.muted {
color: var(--el-text-color-placeholder);
}
.friendly-text {
.friendly-main {
font-size: 14px;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.friendly-sub {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
}
</style>
+31
View File
@@ -586,6 +586,28 @@
</div>
<el-empty v-else description="请先保存诊单开方后病历将在此显示" />
</el-tab-pane>
<el-tab-pane label="回访记录" name="visit" :disabled="!formData.id" v-perms="['tcm.diagnosis/huifang']" v-if="hasPermission(['tcm.diagnosis/huifang'])">
<call-record-panel
v-if="formData.id"
ref="callRecordPanelRef"
:diagnosis-id="Number(formData.id)"
/>
<el-empty v-else description="请先保存诊单后再查看回访记录" />
</el-tab-pane>
<el-tab-pane
v-if="hasPermission(['tcm.diagnosis/chat'])"
label="聊天记录"
name="chat"
:disabled="!formData.id"
lazy
>
<im-chat-record-panel
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
/>
<el-empty v-else description="请先保存诊单后再查看聊天记录" />
</el-tab-pane>
</el-tabs>
<!-- 处方详情(查看病历) -->
@@ -613,18 +635,21 @@
import { tcmDiagnosisAdd, tcmDiagnosisEdit, tcmDiagnosisDetail, checkPhone, checkIdCard } from '@/api/tcm'
import { getDictData } from '@/api/app'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import BloodRecordList from './components/BloodRecordList.vue'
import CaseRecordList from './components/CaseRecordList.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import TcmPrescription from '@/components/tcm-prescription/index.vue'
const emit = defineEmits(['success'])
const appStore = useAppStore()
const getImageUrl = (url: string) => (url?.indexOf?.('http') === 0 ? url : appStore.getImageUrl(url || ''))
const caseRecordListRef = ref()
const callRecordPanelRef = ref<{ refresh?: () => void } | null>(null)
const prescriptionRef = ref()
const visible = ref(false)
@@ -634,6 +659,12 @@ const submitting = ref(false)
const activeTab = ref('basic')
const drawerTitle = computed(() => (mode.value === 'add' ? '新增诊单' : '编辑诊单'))
watch([visible, activeTab], () => {
if (visible.value && activeTab.value === 'chat' && !hasPermission(['tcm.diagnosis/chat'])) {
activeTab.value = 'basic'
}
})
const formData = ref({
id: '',
patient_id: '',
+1 -1
View File
@@ -199,7 +199,7 @@
<img :src="qrcodeUrl" alt="小程序二维码" class="w-64 h-64 border border-gray-200 rounded" />
<div class="mt-4 text-sm text-gray-600">
<div>患者{{ currentQRCodePatient?.patient_name }}</div>
<div class="mt-2">请使用微信扫描二维码</div>
<div class="mt-2">请使用企业微信扫描二维码然后转发给患者</div>
</div>
</div>
<div v-else class="text-center text-gray-500">