新增功能

This commit is contained in:
Your Name
2026-04-01 09:46:25 +08:00
parent 099bc1dd22
commit a780356908
332 changed files with 7845 additions and 866 deletions
File diff suppressed because it is too large Load Diff
+27
View File
@@ -99,6 +99,33 @@
<span class="font-semibold text-blue-600">{{ row.total_count }}</span>
</template>
</el-table-column>
<el-table-column prop="diagnosis_count" label="诊单数" width="90" align="center">
<template #default="{ row }">
<el-tooltip
content="统计期内该医生挂号涉及的去重诊单数,用于计算成交率"
placement="top"
>
<span class="text-gray-700">{{ row.diagnosis_count ?? 0 }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="deal_count" label="成交单" width="90" align="center">
<template #default="{ row }">
<el-tooltip content="上述诊单中已开有效处方(未删除、未作废)的数量" placement="top">
<el-tag type="primary" size="small">{{ row.deal_count ?? 0 }}</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="deal_rate" label="成交率" width="100" align="center">
<template #default="{ row }">
<span :class="getCompletionRateClass(Number(row.deal_rate ?? 0))">
{{ row.deal_rate ?? 0 }}%
</span>
</template>
</el-table-column>
<el-table-column prop="registered_count" label="已挂号" width="90" align="center">
<template #default="{ row }">
@@ -331,7 +331,9 @@ const formData = reactive({
dietary_taboo: [] as string[],
usage_notes: '',
doctor_name: '',
is_shared: 0
is_shared: 0,
/** 预约开方直接生效,不走消费者处方「待审核」 */
audit_status: 1
})
// 表单验证规则
@@ -399,6 +401,7 @@ const resetForm = () => {
formData.usage_notes = ''
formData.doctor_name = ''
formData.is_shared = 0
formData.audit_status = 0
}
// 打开抽屉
+55 -25
View File
@@ -106,17 +106,15 @@
:data="pager.lists"
size="default"
class="appointment-table"
stripe
:row-class-name="getRowClassName"
>
<el-table-column label="ID" prop="id" width="72" align="center" />
<el-table-column label="患者信息" min-width="140">
<template #default="{ row }">
<div class="patient-cell">
<div class="patient-avatar">
<!-- <div class="patient-avatar">
{{ (row.patient_name || '患').charAt(0) }}
</div>
</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.patient_name }}</div>
<div class="patient-phone">{{ row.patient_phone }}</div>
@@ -139,9 +137,14 @@
</template>
</el-table-column>
<el-table-column label="助理" prop="assistant_name" width="100" show-overflow-tooltip>
<template #default="{ row }">
{{ row.assistant_name || '—' }}
</template>
</el-table-column>
<el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip />
<!-- <el-table-column label="预约类型" prop="appointment_type_desc" width="100" show-overflow-tooltip /> -->
<el-table-column label="确认诊单" width="90" align="center">
<template #default="{ row }">
@@ -172,8 +175,7 @@
<el-table-column label="备注" prop="remark" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="380" fixed="right" align="left">
<template #default="{ row }">
<div class="action-btns">
@@ -232,7 +234,7 @@
size="small"
@click="handlePrescription(row)"
>
开方
{{ prescriptionActionLabel(row) }}
</el-button>
<el-dropdown trigger="click" @command="(cmd) => handleAction(cmd, row)" placement="bottom-end">
@@ -456,7 +458,7 @@
</el-dialog>
<!-- 编辑患者弹窗 -->
<edit-popup ref="editRef" @success="loadData" />
<edit-popup ref="editRef" @success="() => loadData({ silent: true })" />
<!-- 中医处方单 -->
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
@@ -506,7 +508,7 @@
<script setup lang="ts">
import { usePaging } from '@/hooks/usePaging'
import { defineAsyncComponent, onMounted, watch } from 'vue'
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
import { getDictData } from '@/api/app'
@@ -598,11 +600,6 @@ watch(
}
)
// 待接诊行高亮
const getRowClassName = ({ row }: { row: any }) => {
return row.status === 1 ? 'row-pending' : ''
}
// 切换选项卡
const handleTabChange = (tabName: string | number) => {
if (tabName === 'all') {
@@ -613,11 +610,16 @@ const handleTabChange = (tabName: string | number) => {
resetPage()
}
/** 列表静默轮询间隔(毫秒) */
const LIST_POLL_MS = 20_000
let listPollTimer: ReturnType<typeof setInterval> | null = null
// 列表 + Tab 角标:一次请求(后端 extend.status_count),翻页等仅 getLists 不重复统计
const loadData = async () => {
const loadData = async (opts?: { silent?: boolean }) => {
const silent = opts?.silent === true
formData.include_status_counts = 1
try {
await getLists()
await getLists({ silent })
const ext = pager.extend as { status_count?: Record<number | string, number> }
const sc = ext?.status_count
if (sc) {
@@ -628,6 +630,11 @@ const loadData = async () => {
}
}
}
} catch (e) {
/* 定时静默刷新失败不向外抛,避免未处理的 rejection;手动刷新仍交给上层/拦截器 */
if (!silent) {
throw e
}
} finally {
formData.include_status_counts = 0
}
@@ -929,7 +936,18 @@ const onPrescriptionSuccess = () => {
editRef.value?.refreshCaseList?.()
}
// 开处方
/** 与本预约关联的处方:已通过且未作废 → 与消费者处方「查看」同为只读预览 */
function prescriptionActionLabel(row: any) {
if (
row?.prescription_audit_status === 1 &&
Number(row?.prescription_void_status) !== 1
) {
return '查看'
}
return '开方'
}
// 开处方 / 查看已通过处方(只读)
const handlePrescription = (row: any) => {
if (!row.diagnosis_id && !row.patient_id) {
feedback.msgWarning('该预约缺少诊单信息,请先编辑患者')
@@ -972,6 +990,16 @@ formData.status = 1
onMounted(() => {
loadData()
listPollTimer = setInterval(() => {
loadData({ silent: true })
}, LIST_POLL_MS)
})
onUnmounted(() => {
if (listPollTimer !== null) {
clearInterval(listPollTimer)
listPollTimer = null
}
})
</script>
@@ -1113,9 +1141,10 @@ onMounted(() => {
.action-btns {
display: flex;
align-items: center;
align-items: flex-start;
flex-wrap: wrap;
gap: 0 8px;
row-gap: 6px;
column-gap: 8px;
.action-qrcode {
color: var(--el-color-warning) !important;
@@ -1135,11 +1164,12 @@ onMounted(() => {
:deep(.el-table__row) {
cursor: default;
}
:deep(.el-table__row.row-pending) {
background: linear-gradient(90deg, rgba(var(--el-color-success-rgb), 0.06) 0%, transparent 100%) !important;
/* 数据行纯白底(不使用 stripe / 行高亮色) */
:deep(.el-table__body tr > td) {
background-color: #ffffff !important;
}
:deep(.el-table__row.row-pending:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-success-rgb), 0.1) 0%, transparent 100%) !important;
:deep(.el-table__body tr:hover > td) {
background-color: var(--el-fill-color-light) !important;
}
}
+12 -1
View File
@@ -188,6 +188,17 @@
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="在用药物" prop="current_medications">
<el-input
v-model="formData.current_medications"
type="textarea"
:rows="3"
placeholder="请填写患者当前正在使用的药物(如降压药、降糖药等),多种请换行或顿号分隔"
maxlength="2000"
show-word-limit
/>
</el-form-item>
</div>
<!-- 主诉 -->
@@ -549,6 +560,7 @@ const formData = ref({
prescription: '',
doctor_advice: '',
remark: '',
current_medications: '',
status: 1
})
@@ -578,7 +590,6 @@ const formRules = {
phone: [{ required: true, validator: validatePhone, trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
diagnosis_date: [{ required: true, message: '请选择诊断日期', trigger: 'change' }],
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }]
+75 -2
View File
@@ -8,6 +8,14 @@
:z-index="2000"
>
<div v-loading="loading">
<el-alert
v-if="todayHasBlockingAppointment"
:type="isSelectingToday ? 'warning' : 'info'"
:closable="false"
show-icon
class="today-block-alert"
:title="todayBlockingAlertText"
/>
<el-form :model="form" label-width="100px" class="appointment-form">
<!-- 上次就诊 -->
<el-form-item label="上次就诊:">
@@ -208,6 +216,8 @@ const lastVisit = ref('')
const selectedDoctorId = ref(0)
const doctorRosterDates = ref<string[]>([]) // 医生有排班的日期列表
const channelOptions = ref<{ name: string; value: string }[]>([])
/** 今日是否已有「已预约(1)/已过号(4)」挂号;仅在选择「今天」再预约时禁止提交,其他日期可正常挂 */
const todayHasBlockingAppointment = ref(false)
const patientInfo = reactive({
id: 0,
@@ -286,9 +296,28 @@ const filteredTimeSlots = computed(() => {
})
})
const todayYmd = () => dayjs().format('YYYY-MM-DD')
/** 当前选中的预约日是否为今天(与今日冲突检测联动) */
const isSelectingToday = computed(() => form.date === todayYmd())
/** 今日已有占用号时:选今天给警告文案,选他日给提示文案 */
const todayBlockingAlertText = computed(() => {
if (!todayHasBlockingAppointment.value) return ''
return isSelectingToday.value
? '该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
: '该患者今日已有挂号记录;可为患者预约其他日期的时段。'
})
/** 仅当「选今天 + 今日已有占用号」时禁止提交 */
const blockedByTodayDuplicate = computed(
() => todayHasBlockingAppointment.value && isSelectingToday.value
)
// 是否可以提交
const canSubmit = computed(() => {
return (
!blockedByTodayDuplicate.value &&
selectedDoctorId.value &&
form.date &&
form.appointmentTime &&
@@ -318,6 +347,7 @@ const open = async (patient: any) => {
timeSlots.value = []
doctorRosterDates.value = []
lastVisit.value = ''
todayHasBlockingAppointment.value = false
await loadChannelOptions()
// 加载医生列表
@@ -325,6 +355,34 @@ const open = async (patient: any) => {
// 查询该患者的最近一次就诊记录
await loadLastVisit()
await loadTodayAppointmentBlock()
}
/** 今日是否已有状态为已预约(1)或已过号(4)的挂号(仅禁止再挂「今天」,不禁止挂其他天) */
const loadTodayAppointmentBlock = async () => {
todayHasBlockingAppointment.value = false
const pid = patientInfo.id
if (!pid) {
return
}
const today = dayjs().format('YYYY-MM-DD')
try {
const res = await appointmentLists({
patient_id: pid,
start_date: today,
end_date: today,
page_no: 1,
page_size: 50
})
const rows = res?.lists || []
const blocking = rows.find((r: any) => {
const s = Number(r.status)
return s === 1 || s === 4
})
todayHasBlockingAppointment.value = !!blocking
} catch (e) {
console.error('检查今日挂号状态失败:', e)
}
}
// 加载患者最近一次就诊记录(从预约表查询已完成的预约)
@@ -432,9 +490,14 @@ const loadDoctorRoster = async () => {
await nextTick()
if (doctorRosterDates.value.length > 0) {
const today = dayjs().format('YYYY-MM-DD')
const defaultDate = doctorRosterDates.value.includes(today)
? today
let defaultDate = doctorRosterDates.value.includes(today)
? today
: doctorRosterDates.value[0]
// 今日已有占用号时,默认不要落在「今天」,避免一进来确定按钮灰掉
if (todayHasBlockingAppointment.value && defaultDate === today) {
const later = doctorRosterDates.value.find((d) => d > today)
defaultDate = later ?? doctorRosterDates.value.find((d) => d !== today) ?? defaultDate
}
selectDate(defaultDate)
}
} else {
@@ -549,6 +612,12 @@ const handleConfirm = async () => {
feedback.msgWarning('请选择渠道来源')
return
}
if (blockedByTodayDuplicate.value) {
feedback.msgWarning(
'该患者今日已有挂号(已预约或已过号),不能重复预约今天,请改选其他日期。'
)
return
}
try {
submitting.value = true
@@ -584,6 +653,10 @@ defineExpose({ open })
</script>
<style scoped lang="scss">
.today-block-alert {
margin-bottom: 16px;
}
.appointment-form {
:deep(.el-form-item__label) {
font-weight: 500;
@@ -0,0 +1,175 @@
<template>
<div class="appointment-record-panel">
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无挂号记录">
<el-table-column label="ID" prop="id" width="72" align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="
row.status === 1
? 'success'
: row.status === 2
? 'info'
: row.status === 3
? 'primary'
: 'danger'
"
size="small"
effect="light"
>
{{ row.status_desc || '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="患者(挂号人)" min-width="150">
<template #default="{ row }">
<div class="cell-stack">
<span class="font-medium">{{ row.patient_name || '—' }}</span>
<span class="text-gray-500 text-sm">{{ row.patient_phone || '' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="挂号医生" prop="doctor_name" width="110" show-overflow-tooltip />
<el-table-column label="挂号助理" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ row.assistant_name || '—' }}
</template>
</el-table-column>
<el-table-column label="预约时间" min-width="130">
<template #default="{ row }">
<div class="cell-stack">
<span>{{ row.appointment_date || '—' }}</span>
<span class="text-gray-500 text-sm">{{ formatTime(row.appointment_time) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="时段" width="80" align="center">
<template #default="{ row }">
{{ periodText(row.period) }}
</template>
</el-table-column>
<el-table-column label="类型" width="100" show-overflow-tooltip>
<template #default="{ row }">
{{ row.appointment_type_desc || '—' }}
</template>
</el-table-column>
<el-table-column label="渠道" width="110" show-overflow-tooltip>
<template #default="{ row }">
{{ channelLabel(row) }}
</template>
</el-table-column>
<el-table-column label="确认诊单" width="92" align="center">
<template #default="{ row }">
<el-tag v-if="row.diagnosis_confirmed" type="success" size="small" effect="plain">已确认</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">未确认</el-tag>
</template>
</el-table-column>
<el-table-column label="开方" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.has_prescription" type="success" size="small" effect="plain">已开方</el-tag>
<el-tag v-else type="info" size="small" effect="plain">未开方</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="100" show-overflow-tooltip />
<el-table-column label="创建时间" width="165" prop="create_time" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { appointmentLists } from '@/api/doctor'
import { getDictData } from '@/api/app'
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const rows = ref<any[]>([])
const channelMap = ref<Record<string, string>>({})
function formatTime(t: string | undefined) {
if (!t) return ''
return String(t).length >= 8 ? String(t).slice(0, 5) : t
}
function periodText(p: string | undefined) {
if (p === 'morning') return '上午'
if (p === 'afternoon') return '下午'
if (p === 'all') return '全天'
return p || '—'
}
function channelLabel(row: Record<string, any>) {
const raw = row.channel_source ?? row.channels ?? ''
if (raw === '' || raw === null || raw === undefined) return '—'
const key = String(raw)
return channelMap.value[key] || key
}
function sortRows(list: any[]) {
return [...list].sort((a, b) => {
const da = String(a.appointment_date || '')
const db = String(b.appointment_date || '')
if (da !== db) return db.localeCompare(da)
const ta = String(a.appointment_time || '')
const tb = String(b.appointment_time || '')
if (ta !== tb) return tb.localeCompare(ta)
return Number(b.id || 0) - Number(a.id || 0)
})
}
const loadChannels = async () => {
try {
const data = await getDictData({ type: 'channels' })
const opts = (data?.channels || []).filter((row: any) => row.status !== 0)
const map: Record<string, string> = {}
for (const row of opts) {
if (row.value != null) map[String(row.value)] = row.name || String(row.value)
}
channelMap.value = map
} catch {
channelMap.value = {}
}
}
const load = async () => {
if (!props.diagnosisId) return
loading.value = true
try {
await loadChannels()
const res = await appointmentLists({
patient_id: props.diagnosisId,
page_no: 1,
page_size: 500
})
const list = res?.lists || []
rows.value = sortRows(list)
} catch (e) {
console.error(e)
rows.value = []
} finally {
loading.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load()
},
{ immediate: true }
)
defineExpose({ refresh: load })
</script>
<style scoped>
.cell-stack {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
</style>
@@ -0,0 +1,48 @@
<template>
<div class="assign-log-panel">
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无指派记录">
<el-table-column label="操作时间" width="175" prop="create_time_text" />
<el-table-column label="原医助" min-width="120" prop="from_assistant_name" />
<el-table-column label="新医助" min-width="120" prop="to_assistant_name" />
<el-table-column label="操作人" width="110" prop="operator_name" />
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { tcmDiagnosisAssignLogList } from '@/api/tcm'
const props = defineProps<{
diagnosisId: number
}>()
const loading = ref(false)
const rows = ref<any[]>([])
const load = async () => {
if (!props.diagnosisId) return
loading.value = true
try {
const data = await tcmDiagnosisAssignLogList({ id: props.diagnosisId })
rows.value = Array.isArray(data) ? data : []
} catch (e) {
console.error(e)
rows.value = []
} finally {
loading.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load()
},
{ immediate: true }
)
defineExpose({ refresh: load })
</script>
@@ -72,6 +72,8 @@ import { ref, watch, computed } from 'vue'
import dayjs from 'dayjs'
import { Refresh } from '@element-plus/icons-vue'
import { getImChatMessages } from '@/api/tcm'
import type { FriendlyParse } from '@/utils/im-business-message-parse'
import { parseImBusinessPayload } from '@/utils/im-business-message-parse'
const props = defineProps<{
diagnosisId: number
@@ -90,109 +92,6 @@ function formatTime(ts: number | undefined) {
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
+3
View File
@@ -100,6 +100,9 @@
<el-descriptions-item label="治则" :span="2">
{{ detail.treatment_principle || '无' }}
</el-descriptions-item>
<el-descriptions-item label="在用药物" :span="2">
<div style="white-space: pre-wrap">{{ detail.current_medications || '无' }}</div>
</el-descriptions-item>
<el-descriptions-item label="处方" :span="2">
<div style="white-space: pre-wrap">{{ detail.prescription || '无' }}</div>
</el-descriptions-item>
+81 -53
View File
@@ -3,7 +3,7 @@
<el-drawer
v-model="visible"
:title="drawerTitle"
size="70%"
size="60%"
:before-close="handleClose"
:z-index="1500"
:modal="true"
@@ -198,7 +198,6 @@
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
@@ -207,6 +206,20 @@
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="在用药物" prop="current_medications">
<el-input
v-model="formData.current_medications"
type="textarea"
:rows="3"
placeholder="请填写患者当前正在使用的药物(如降压药、降糖药等),多种请换行或顿号分隔"
maxlength="2000"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">主诉</el-divider>
@@ -475,51 +488,7 @@
</el-form-item>
<!-- <el-row :gutter="20">
<el-col :span="12">
<el-form-item label="舌苔" prop="tongue_coating">
<el-input
v-model="formData.tongue_coating"
placeholder="请输入舌苔情况"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="脉象" prop="pulse">
<el-input
v-model="formData.pulse"
placeholder="请输入脉象"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="治则" prop="treatment_principle">
<el-input
v-model="formData.treatment_principle"
type="textarea"
:rows="3"
placeholder="请输入治则"
/>
</el-form-item>
<el-form-item label="处方" prop="prescription">
<el-input
v-model="formData.prescription"
type="textarea"
:rows="3"
placeholder="请输入处方"
/>
</el-form-item>
<el-form-item label="医嘱" prop="doctor_advice">
<el-input
v-model="formData.doctor_advice"
type="textarea"
:rows="3"
placeholder="请输入医嘱"
/>
</el-form-item> -->
<el-form-item label="病史补充" prop="remark">
<el-input
@@ -616,6 +585,35 @@
/>
<el-empty v-else description="请先保存诊单后再查看聊天记录" />
</el-tab-pane>
<el-tab-pane
v-if="
hasPermission(['tcm.diagnosis/assign']) ||
hasPermission(['tcm.diagnosis/detail'])
"
label="指派医助记录"
name="assignLog"
:disabled="!formData.id"
lazy
>
<assign-log-panel
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
/>
<el-empty v-else description="请先保存诊单后再查看指派记录" />
</el-tab-pane>
<el-tab-pane
v-if="hasPermission(['doctor.appointment/lists'])"
label="挂号记录"
name="appointmentRecords"
:disabled="!formData.id"
lazy
>
<appointment-record-panel
v-if="formData.id"
:diagnosis-id="Number(formData.id)"
/>
<el-empty v-else description="请先保存诊单后再查看挂号记录" />
</el-tab-pane>
</el-tabs>
<!-- 处方详情(查看病历) -->
@@ -652,7 +650,13 @@ import BloodRecordList from './components/BloodRecordList.vue'
import CaseRecordList from './components/CaseRecordList.vue'
import CallRecordPanel from './components/CallRecordPanel.vue'
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
import AssignLogPanel from './components/AssignLogPanel.vue'
import AppointmentRecordPanel from './components/AppointmentRecordPanel.vue'
import TcmPrescription from '@/components/tcm-prescription/index.vue'
import {
DIAGNOSIS_TONGUE_IMAGES_UPDATED,
type DiagnosisTongueImagesUpdatedDetail
} from '@/utils/diagnosis-sync-events'
const emit = defineEmits(['success'])
const appStore = useAppStore()
@@ -676,6 +680,32 @@ watch([visible, activeTab], () => {
if (activeTab.value === 'visit' && !hasPermission(['tcm.diagnosis/huifang'])) {
activeTab.value = 'basic'
}
if (
activeTab.value === 'assignLog' &&
!hasPermission(['tcm.diagnosis/assign']) &&
!hasPermission(['tcm.diagnosis/detail'])
) {
activeTab.value = 'basic'
}
if (activeTab.value === 'appointmentRecords' && !hasPermission(['doctor.appointment/lists'])) {
activeTab.value = 'basic'
}
})
function onTongueImagesSyncedFromCall(e: Event) {
if (!visible.value) return
const d = (e as CustomEvent<DiagnosisTongueImagesUpdatedDetail>).detail
if (!d?.diagnosisId || !Array.isArray(d.tongue_images)) return
if (Number(formData.value.id) !== Number(d.diagnosisId)) return
formData.value.tongue_images = [...d.tongue_images]
}
onMounted(() => {
window.addEventListener(DIAGNOSIS_TONGUE_IMAGES_UPDATED, onTongueImagesSyncedFromCall as EventListener)
})
onUnmounted(() => {
window.removeEventListener(DIAGNOSIS_TONGUE_IMAGES_UPDATED, onTongueImagesSyncedFromCall as EventListener)
})
const formData = ref({
@@ -720,7 +750,7 @@ const formData = ref({
allergy_history: 0,
family_history: 0,
pregnancy_history: 0,
tongue_images: [],
tongue_images: [] as string[],
report_files: [],
symptoms: '',
tongue_coating: '',
@@ -729,6 +759,7 @@ const formData = ref({
prescription: '',
doctor_advice: '',
remark: '',
current_medications: '',
status: 1,
external_userid: ''
})
@@ -761,11 +792,7 @@ const formRules = {
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
diagnosis_date: [{ required: true, message: '请选择诊断日期', trigger: 'change' }],
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
diabetes_discovery_year: [{ required: true, message: '请输入发现糖尿病病患病史年数', trigger: 'blur' }],
local_hospital_diagnosis: [{ required: true, type: 'array', min: 1, message: '请选择当地医院诊断结果', trigger: 'change' }],
local_hospital_name: [{ required: true, message: '请输入当地就诊医院名称', trigger: 'blur' }]
}
// 获取字典选项
@@ -1015,7 +1042,7 @@ const handleClose = () => {
allergy_history: 0,
family_history: 0,
pregnancy_history: 0,
tongue_images: [],
tongue_images: [] as string[],
report_files: [],
symptoms: '',
tongue_coating: '',
@@ -1024,6 +1051,7 @@ const handleClose = () => {
prescription: '',
doctor_advice: '',
remark: '',
current_medications: '',
status: 1,
external_userid: ''
}
+84 -15
View File
@@ -89,7 +89,7 @@
<el-table-column label="患者" min-width="150">
<template #default="{ row }">
<div class="patient-cell">
<div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div>
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
<div class="patient-info">
<div class="patient-name">{{ row.patient_name }}</div>
<div class="patient-meta">{{ row.id }} · {{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}</div>
@@ -99,9 +99,12 @@
</el-table-column>
<el-table-column label="挂号" min-width="180">
<template #default="{ row }">
<div class="appointment-cell" :class="{ 'has-apt': row.has_appointment }">
<div
class="appointment-cell"
:class="appointmentCellClasses(row)"
>
<template v-if="row.has_appointment">
<div class="apt-badge">已挂号</div>
<div class="apt-badge">{{ appointmentStatusLabel(row) }}</div>
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
</template>
@@ -183,9 +186,9 @@
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/assign'])" command="assign"><el-icon><User /></el-icon>指派</el-dropdown-item>
<el-dropdown-item v-if="row.has_appointment && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && row.has_appointment" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item v-if="row.has_appointment && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
<el-dropdown-item v-if="isAppointmentActiveForVideo(row) && hasPermission(['tcm.diagnosis/videoQr'])" command="videoQr"><el-icon><Picture /></el-icon>视频二维码</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/guahao']) && isAppointmentActiveForVideo(row)" command="confirmQr"><el-icon><Picture /></el-icon>二维码</el-dropdown-item>
<el-dropdown-item v-if="canCancelAppointmentRow(row) && hasPermission(['tcm.diagnosis/guahao'])" command="cancelApt"><el-icon><CircleClose /></el-icon>取消挂号</el-dropdown-item>
<el-dropdown-item command="order" v-if="hasPermission(['tcm.diagnosis/order'])"><el-icon><Document /></el-icon>创建订单</el-dropdown-item>
<el-dropdown-item v-if="hasPermission(['tcm.diagnosis/delete'])" command="delete" divided><el-icon><Delete /></el-icon><span class="text-danger">删除</span></el-dropdown-item>
</el-dropdown-menu>
@@ -201,9 +204,9 @@
</div>
</el-card>
<edit-popup ref="editRef" @success="getLists" />
<edit-popup ref="editRef" @success="refreshListAfterPopupSave" />
<detail-popup ref="detailRef" />
<appointment-popup ref="appointmentRef" @success="getLists" />
<appointment-popup ref="appointmentRef" @success="refreshListAfterPopupSave" />
<assistant-watch-call-dialog
v-model="watchCallVisible"
:diagnosis-id="watchCallDiagnosisId"
@@ -698,6 +701,8 @@ const { pager, getLists, resetParams, resetPage } = usePaging({
params: formData
})
/** 弹窗内保存后刷新列表:静默请求,避免 el-table v-loading 白蒙层盖住仍打开的抽屉 */
const refreshListAfterPopupSave = () => getLists({ silent: true })
// 重置
const handleReset = () => {
@@ -995,8 +1000,8 @@ const handleRowAction = (cmd: string, row: any) => {
switch (cmd) {
case 'assign': handleSingleAssign(row); break
case 'videoQr':
if (!row.has_appointment) {
feedback.msgWarning('未挂号患者无法生成视频二维码,请先预约医生')
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
handleVideoQRCode(row)
@@ -1008,10 +1013,42 @@ const handleRowAction = (cmd: string, row: any) => {
}
}
/** 挂号状态:1=已预约 3=已完成 4=已过号(与后端一致) */
const appointmentStatusLabel = (row: any) => {
const s = Number(row.appointment_status)
if (s === 3) return '已完成'
if (s === 4) return '已过号'
return '已挂号'
}
const appointmentCellClasses = (row: any) => {
const s = Number(row.appointment_status)
return {
'has-apt': row.has_appointment,
'apt-row-done': row.has_appointment && s === 3,
'apt-row-missed': row.has_appointment && s === 4
}
}
/** 仅已预约(1)可进视频/小程序码 */
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
/** 已预约、已过号可取消(后端同步限制) */
const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status)
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
}
// 取消挂号
const handleCancelAppointment = async (row: any) => {
if (!row.has_appointment || !row.appointment_id) {
feedback.msgWarning('该诊单未挂号或挂号信息异常')
if (!canCancelAppointmentRow(row)) {
const s = Number(row.appointment_status)
if (row.has_appointment && s === 3) {
feedback.msgWarning('已完成就诊,无法取消挂号')
} else {
feedback.msgWarning('该诊单未挂号或挂号信息异常')
}
return
}
try {
@@ -1081,10 +1118,10 @@ const submitFillIdCard = async () => {
}
}
// 生成视频二维码(跳转登录页)- 仅已挂号患者可生成
// 生成视频二维码(跳转登录页)- 仅已预约(1)可生成
const handleVideoQRCode = async (row: any) => {
if (!row.has_appointment) {
feedback.msgWarning('未挂号患者无法生成视频二维码,请先预约医生')
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
if (!row.patient_id) {
@@ -1125,6 +1162,10 @@ const handleVideoQRCode = async (row: any) => {
// 生成确认诊单二维码
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码')
return
}
if (!row.patient_id) {
feedback.msgWarning('患者信息不完整')
return
@@ -1532,6 +1573,34 @@ onUnmounted(() => {
}
}
&.apt-row-missed {
background: linear-gradient(135deg, rgba(var(--el-color-warning-rgb), 0.12), rgba(var(--el-color-warning-rgb), 0.05));
border-color: rgba(var(--el-color-warning-rgb), 0.35);
.apt-badge {
color: var(--el-color-warning-dark-2);
background: var(--el-color-warning-light-7);
}
.apt-time {
color: var(--el-color-warning);
}
}
&.apt-row-done {
background: linear-gradient(135deg, rgba(var(--el-color-info-rgb), 0.1), rgba(var(--el-color-info-rgb), 0.04));
border-color: rgba(var(--el-color-info-rgb), 0.28);
.apt-badge {
color: var(--el-color-info-dark-2);
background: var(--el-color-info-light-7);
}
.apt-time {
color: var(--el-color-info);
}
}
.apt-none {
font-size: 13px;
color: var(--el-text-color-placeholder);