新增功能

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
@@ -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