This commit is contained in:
Your Name
2026-08-11 17:39:41 +08:00
parent cfe4c82c90
commit 25467b9d91
350 changed files with 201115 additions and 132208 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,179 +1,179 @@
<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)
const base = channelMap.value[key] || key
const extra = String(row.channel_source_detail ?? '').trim()
return extra !== '' ? `${base}${extra}` : base
}
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,
/** 诊单维度拉挂号:后端豁免医生/医助与数据范围收窄 */
diag_scope_relax: 1,
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>
<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)
const base = channelMap.value[key] || key
const extra = String(row.channel_source_detail ?? '').trim()
return extra !== '' ? `${base}${extra}` : base
}
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,
/** 诊单维度拉挂号:后端豁免医生/医助与数据范围收窄 */
diag_scope_relax: 1,
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>
@@ -1,492 +1,492 @@
<template>
<div class="blood-record-list">
<div class="mb-4">
<el-button v-if="!readOnly" type="primary" @click="handleAdd">添加记录</el-button>
<el-select v-model="trendDays" @change="handleTrendDaysChange" class="ml-2" style="width: 150px">
<el-option label="最近7天" :value="7" />
<el-option label="最近30天" :value="30" />
<el-option label="最近90天" :value="90" />
<el-option label="最近一年" :value="365" />
<el-option label="自定义" :value="0" />
</el-select>
<el-date-picker
v-if="trendDays === 0"
v-model="customDateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="loadTrendData"
class="ml-2"
style="width: 300px"
/>
</div>
<div class="mb-4">
<el-card>
<template #header>
<div class="card-header">
<span>血糖趋势图</span>
</div>
</template>
<div ref="chartRef" style="width: 100%; height: 400px"></div>
</el-card>
</div>
<el-table :data="recordList" border style="width: 100%">
<el-table-column prop="record_date" label="记录日期" width="120" />
<el-table-column prop="record_time" label="记录时间" width="100" />
<el-table-column label="空腹血糖(mmol/L)" width="120">
<template #default="{ row }">
{{ formatBloodGlucoseCell(row.fasting_blood_sugar) }}
</template>
</el-table-column>
<el-table-column label="餐后2小时血糖(mmol/L)" width="150">
<template #default="{ row }">
{{ formatBloodGlucoseCell(row.postprandial_blood_sugar) }}
</template>
</el-table-column>
<el-table-column label="其他血糖(mmol/L)" width="120">
<template #default="{ row }">
{{ formatBloodGlucoseCell(row.other_blood_sugar) }}
</template>
</el-table-column>
<el-table-column label="血压(mmHg)" width="150">
<template #default="{ row }">
<span v-if="row.systolic_pressure && row.diastolic_pressure">
{{ row.systolic_pressure }}/{{ row.diastolic_pressure }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="western_medicine" label="西药" show-overflow-tooltip />
<el-table-column prop="insulin" label="胰岛素" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="recordForm" label-width="120px">
<el-form-item label="记录日期" required>
<el-date-picker
v-model="recordForm.record_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
<el-form-item label="记录时间">
<el-time-picker
v-model="recordForm.record_time"
placeholder="选择时间"
format="HH:mm"
value-format="HH:mm"
class="w-full"
/>
</el-form-item>
<el-form-item label="空腹血糖">
<el-input-number
v-model="recordForm.fasting_blood_sugar"
:precision="2"
:step="0.1"
:min="0"
:max="50"
placeholder="请输入空腹血糖"
/>
<span class="ml-2">mmol/L</span>
</el-form-item>
<el-form-item label="餐后2小时血糖">
<el-input-number
v-model="recordForm.postprandial_blood_sugar"
:precision="2"
:step="0.1"
:min="0"
:max="50"
placeholder="请输入餐后2小时血糖"
/>
<span class="ml-2">mmol/L</span>
</el-form-item>
<el-form-item label="其他血糖">
<el-input
v-model="recordForm.other_blood_sugar"
placeholder="请输入其他血糖"
type="number"
maxlength="10"
@input="handleOtherBloodSugarInput"
style="width: 200px"
/>
<span class="ml-2">mmol/L</span>
</el-form-item>
<el-form-item label="收缩压(高压)">
<el-input-number
v-model="recordForm.systolic_pressure"
:min="0"
:max="300"
placeholder="请输入收缩压"
/>
<span class="ml-2">mmHg</span>
</el-form-item>
<el-form-item label="舒张压(低压)">
<el-input-number
v-model="recordForm.diastolic_pressure"
:min="0"
:max="200"
placeholder="请输入舒张压"
/>
<span class="ml-2">mmHg</span>
</el-form-item>
<el-form-item label="西药">
<el-input
v-model="recordForm.western_medicine"
placeholder="请输入西药"
/>
</el-form-item>
<el-form-item label="胰岛素">
<el-input
v-model="recordForm.insulin"
placeholder="请输入胰岛素"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="recordForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { bloodRecordAdd, bloodRecordEdit, bloodRecordDelete, getRecordsByPatient, getBloodSugarTrend } from '@/api/tcm'
import feedback from '@/utils/feedback'
import * as echarts from 'echarts'
import { nextTick } from 'vue'
const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
const recordList = ref([])
const dialogVisible = ref(false)
const submitting = ref(false)
const dialogTitle = computed(() => recordForm.value.id ? '编辑记录' : '添加记录')
const trendDays = ref(7)
const customDateRange = ref<[Date, Date] | null>(null)
const trendData = ref({
dates: [],
fasting: [],
postprandial_2h: [],
other: []
})
const chartRef = ref()
let chartInstance: any = null
let renderRetryCount = 0
const MAX_RETRY_COUNT = 5
const recordForm = ref({
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
record_time: '',
fasting_blood_sugar: 0,
postprandial_blood_sugar: 0,
other_blood_sugar: 0,
systolic_pressure: 0,
diastolic_pressure: 0,
western_medicine: '',
insulin: '',
remark: ''
})
/** 列表展示:空值或 0 / 0.00(含字符串)统一显示为 - */
function formatBloodGlucoseCell(v: unknown): string {
if (v === null || v === undefined || v === '') return '-'
const n = Number(String(v).trim())
if (!Number.isFinite(n) || n === 0) return '-'
return String(n.toFixed(2).replace(/\.?0+$/, ''))
}
const loadTrendData = async () => {
if (!props.diagnosisId && !props.patientId) {
return
}
try {
const params: any = {
diagnosis_id: props.diagnosisId,
patient_id: props.patientId
}
if (trendDays.value === 0 && customDateRange.value) {
params.start_date = Math.floor(customDateRange.value[0].getTime() / 1000)
params.end_date = Math.floor(customDateRange.value[1].getTime() / 1000)
} else {
params.days = trendDays.value
}
const data = await getBloodSugarTrend(params)
trendData.value = data
renderChart()
} catch (error) {
console.error('获取趋势图数据失败:', error)
}
}
const handleTrendDaysChange = () => {
if (trendDays.value !== 0) {
customDateRange.value = null
}
loadTrendData()
}
const renderChart = () => {
nextTick(() => {
setTimeout(() => {
if (!chartRef.value) {
return
}
const container = chartRef.value
if (container.clientWidth === 0 || container.clientHeight === 0) {
if (renderRetryCount < MAX_RETRY_COUNT) {
renderRetryCount++
setTimeout(() => renderChart(), 100)
}
return
}
// 重置重试计数器
renderRetryCount = 0
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(container)
// 准备数据,确保每个日期都有对应的值
const prepareData = (dataArray: any[], dates: string[]) => {
const dataMap = new Map()
dataArray.forEach(item => {
dataMap.set(item.date, item.value)
})
return dates.map(date => dataMap.get(date) || null)
}
const fastingData = prepareData(trendData.value.fasting, trendData.value.dates)
const postprandialData = prepareData(trendData.value.postprandial_2h, trendData.value.dates)
const otherData = prepareData(trendData.value.other, trendData.value.dates)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['空腹血糖', '餐后2小时血糖', '其他血糖']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: trendData.value.dates
},
yAxis: {
type: 'value',
name: '血糖值 (mmol/L)'
},
series: [
{
name: '空腹血糖',
type: 'line',
data: fastingData,
smooth: true,
connectNulls: true
},
{
name: '餐后2小时血糖',
type: 'line',
data: postprandialData,
smooth: true,
connectNulls: true
},
{
name: '其他血糖',
type: 'line',
data: otherData,
smooth: true,
connectNulls: true
}
]
}
chartInstance.setOption(option)
chartInstance.resize()
}, 100)
})
}
// 获取记录列表
const getRecords = async () => {
if (!props.diagnosisId && !props.patientId) {
return
}
try {
const data = await getRecordsByPatient({
diagnosis_id: props.diagnosisId,
patient_id: props.patientId
})
recordList.value = data
} catch (error) {
console.error('获取记录失败:', error)
}
}
// 添加记录
const handleAdd = () => {
recordForm.value = {
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
record_time: '',
fasting_blood_sugar: 0,
postprandial_blood_sugar: 0,
other_blood_sugar: 0,
systolic_pressure: 0,
diastolic_pressure: 0,
western_medicine: '',
insulin: '',
remark: ''
}
dialogVisible.value = true
}
// 编辑记录
const handleEdit = (row: any) => {
recordForm.value = {
...row,
fasting_blood_sugar: parseFloat(row.fasting_blood_sugar) || 0,
postprandial_blood_sugar: parseFloat(row.postprandial_blood_sugar) || 0,
other_blood_sugar: parseFloat(row.other_blood_sugar) || 0,
systolic_pressure: parseInt(row.systolic_pressure) || 0,
diastolic_pressure: parseInt(row.diastolic_pressure) || 0
}
dialogVisible.value = true
}
// 处理其他血糖输入,只允许数字
const handleOtherBloodSugarInput = (value: string) => {
recordForm.value.other_blood_sugar = value.replace(/[^\d.]/g, '')
}
// 删除记录
const handleDelete = async (row: any) => {
await feedback.confirm('确定要删除这条记录吗?')
try {
await bloodRecordDelete({ id: row.id })
feedback.msgSuccess('删除成功')
getRecords()
} catch (error) {
console.error('删除失败:', error)
}
}
// 提交
const handleSubmit = async () => {
if (!recordForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
return
}
submitting.value = true
try {
if (recordForm.value.id) {
await bloodRecordEdit(recordForm.value)
feedback.msgSuccess('编辑成功')
} else {
await bloodRecordAdd(recordForm.value)
feedback.msgSuccess('添加成功')
}
dialogVisible.value = false
getRecords()
loadTrendData()
} catch (error) {
console.error('提交失败:', error)
} finally {
submitting.value = false
}
}
onMounted(() => {
// 延迟加载数据,确保DOM完全渲染
setTimeout(() => {
getRecords()
loadTrendData()
}, 500)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
}
})
// 监听props变化
watch(() => [props.diagnosisId, props.patientId], () => {
getRecords()
loadTrendData()
})
// 监听组件可见性变化(Tab切换时)
watch(() => props.diagnosisId, (newVal) => {
if (newVal) {
// 当组件变为可见时,延迟重新渲染图表
setTimeout(() => {
renderChart()
}, 500)
}
})
</script>
<style lang="scss" scoped>
.blood-record-list {
padding: 20px;
}
</style>
<template>
<div class="blood-record-list">
<div class="mb-4">
<el-button v-if="!readOnly" type="primary" @click="handleAdd">添加记录</el-button>
<el-select v-model="trendDays" @change="handleTrendDaysChange" class="ml-2" style="width: 150px">
<el-option label="最近7天" :value="7" />
<el-option label="最近30天" :value="30" />
<el-option label="最近90天" :value="90" />
<el-option label="最近一年" :value="365" />
<el-option label="自定义" :value="0" />
</el-select>
<el-date-picker
v-if="trendDays === 0"
v-model="customDateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="loadTrendData"
class="ml-2"
style="width: 300px"
/>
</div>
<div class="mb-4">
<el-card>
<template #header>
<div class="card-header">
<span>血糖趋势图</span>
</div>
</template>
<div ref="chartRef" style="width: 100%; height: 400px"></div>
</el-card>
</div>
<el-table :data="recordList" border style="width: 100%">
<el-table-column prop="record_date" label="记录日期" width="120" />
<el-table-column prop="record_time" label="记录时间" width="100" />
<el-table-column label="空腹血糖(mmol/L)" width="120">
<template #default="{ row }">
{{ formatBloodGlucoseCell(row.fasting_blood_sugar) }}
</template>
</el-table-column>
<el-table-column label="餐后2小时血糖(mmol/L)" width="150">
<template #default="{ row }">
{{ formatBloodGlucoseCell(row.postprandial_blood_sugar) }}
</template>
</el-table-column>
<el-table-column label="其他血糖(mmol/L)" width="120">
<template #default="{ row }">
{{ formatBloodGlucoseCell(row.other_blood_sugar) }}
</template>
</el-table-column>
<el-table-column label="血压(mmHg)" width="150">
<template #default="{ row }">
<span v-if="row.systolic_pressure && row.diastolic_pressure">
{{ row.systolic_pressure }}/{{ row.diastolic_pressure }}
</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="western_medicine" label="西药" show-overflow-tooltip />
<el-table-column prop="insulin" label="胰岛素" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="recordForm" label-width="120px">
<el-form-item label="记录日期" required>
<el-date-picker
v-model="recordForm.record_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
<el-form-item label="记录时间">
<el-time-picker
v-model="recordForm.record_time"
placeholder="选择时间"
format="HH:mm"
value-format="HH:mm"
class="w-full"
/>
</el-form-item>
<el-form-item label="空腹血糖">
<el-input-number
v-model="recordForm.fasting_blood_sugar"
:precision="2"
:step="0.1"
:min="0"
:max="50"
placeholder="请输入空腹血糖"
/>
<span class="ml-2">mmol/L</span>
</el-form-item>
<el-form-item label="餐后2小时血糖">
<el-input-number
v-model="recordForm.postprandial_blood_sugar"
:precision="2"
:step="0.1"
:min="0"
:max="50"
placeholder="请输入餐后2小时血糖"
/>
<span class="ml-2">mmol/L</span>
</el-form-item>
<el-form-item label="其他血糖">
<el-input
v-model="recordForm.other_blood_sugar"
placeholder="请输入其他血糖"
type="number"
maxlength="10"
@input="handleOtherBloodSugarInput"
style="width: 200px"
/>
<span class="ml-2">mmol/L</span>
</el-form-item>
<el-form-item label="收缩压(高压)">
<el-input-number
v-model="recordForm.systolic_pressure"
:min="0"
:max="300"
placeholder="请输入收缩压"
/>
<span class="ml-2">mmHg</span>
</el-form-item>
<el-form-item label="舒张压(低压)">
<el-input-number
v-model="recordForm.diastolic_pressure"
:min="0"
:max="200"
placeholder="请输入舒张压"
/>
<span class="ml-2">mmHg</span>
</el-form-item>
<el-form-item label="西药">
<el-input
v-model="recordForm.western_medicine"
placeholder="请输入西药"
/>
</el-form-item>
<el-form-item label="胰岛素">
<el-input
v-model="recordForm.insulin"
placeholder="请输入胰岛素"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="recordForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { bloodRecordAdd, bloodRecordEdit, bloodRecordDelete, getRecordsByPatient, getBloodSugarTrend } from '@/api/tcm'
import feedback from '@/utils/feedback'
import * as echarts from 'echarts'
import { nextTick } from 'vue'
const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
const recordList = ref([])
const dialogVisible = ref(false)
const submitting = ref(false)
const dialogTitle = computed(() => recordForm.value.id ? '编辑记录' : '添加记录')
const trendDays = ref(7)
const customDateRange = ref<[Date, Date] | null>(null)
const trendData = ref({
dates: [],
fasting: [],
postprandial_2h: [],
other: []
})
const chartRef = ref()
let chartInstance: any = null
let renderRetryCount = 0
const MAX_RETRY_COUNT = 5
const recordForm = ref({
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
record_time: '',
fasting_blood_sugar: 0,
postprandial_blood_sugar: 0,
other_blood_sugar: 0,
systolic_pressure: 0,
diastolic_pressure: 0,
western_medicine: '',
insulin: '',
remark: ''
})
/** 列表展示:空值或 0 / 0.00(含字符串)统一显示为 - */
function formatBloodGlucoseCell(v: unknown): string {
if (v === null || v === undefined || v === '') return '-'
const n = Number(String(v).trim())
if (!Number.isFinite(n) || n === 0) return '-'
return String(n.toFixed(2).replace(/\.?0+$/, ''))
}
const loadTrendData = async () => {
if (!props.diagnosisId && !props.patientId) {
return
}
try {
const params: any = {
diagnosis_id: props.diagnosisId,
patient_id: props.patientId
}
if (trendDays.value === 0 && customDateRange.value) {
params.start_date = Math.floor(customDateRange.value[0].getTime() / 1000)
params.end_date = Math.floor(customDateRange.value[1].getTime() / 1000)
} else {
params.days = trendDays.value
}
const data = await getBloodSugarTrend(params)
trendData.value = data
renderChart()
} catch (error) {
console.error('获取趋势图数据失败:', error)
}
}
const handleTrendDaysChange = () => {
if (trendDays.value !== 0) {
customDateRange.value = null
}
loadTrendData()
}
const renderChart = () => {
nextTick(() => {
setTimeout(() => {
if (!chartRef.value) {
return
}
const container = chartRef.value
if (container.clientWidth === 0 || container.clientHeight === 0) {
if (renderRetryCount < MAX_RETRY_COUNT) {
renderRetryCount++
setTimeout(() => renderChart(), 100)
}
return
}
// 重置重试计数器
renderRetryCount = 0
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(container)
// 准备数据,确保每个日期都有对应的值
const prepareData = (dataArray: any[], dates: string[]) => {
const dataMap = new Map()
dataArray.forEach(item => {
dataMap.set(item.date, item.value)
})
return dates.map(date => dataMap.get(date) || null)
}
const fastingData = prepareData(trendData.value.fasting, trendData.value.dates)
const postprandialData = prepareData(trendData.value.postprandial_2h, trendData.value.dates)
const otherData = prepareData(trendData.value.other, trendData.value.dates)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['空腹血糖', '餐后2小时血糖', '其他血糖']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: trendData.value.dates
},
yAxis: {
type: 'value',
name: '血糖值 (mmol/L)'
},
series: [
{
name: '空腹血糖',
type: 'line',
data: fastingData,
smooth: true,
connectNulls: true
},
{
name: '餐后2小时血糖',
type: 'line',
data: postprandialData,
smooth: true,
connectNulls: true
},
{
name: '其他血糖',
type: 'line',
data: otherData,
smooth: true,
connectNulls: true
}
]
}
chartInstance.setOption(option)
chartInstance.resize()
}, 100)
})
}
// 获取记录列表
const getRecords = async () => {
if (!props.diagnosisId && !props.patientId) {
return
}
try {
const data = await getRecordsByPatient({
diagnosis_id: props.diagnosisId,
patient_id: props.patientId
})
recordList.value = data
} catch (error) {
console.error('获取记录失败:', error)
}
}
// 添加记录
const handleAdd = () => {
recordForm.value = {
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
record_time: '',
fasting_blood_sugar: 0,
postprandial_blood_sugar: 0,
other_blood_sugar: 0,
systolic_pressure: 0,
diastolic_pressure: 0,
western_medicine: '',
insulin: '',
remark: ''
}
dialogVisible.value = true
}
// 编辑记录
const handleEdit = (row: any) => {
recordForm.value = {
...row,
fasting_blood_sugar: parseFloat(row.fasting_blood_sugar) || 0,
postprandial_blood_sugar: parseFloat(row.postprandial_blood_sugar) || 0,
other_blood_sugar: parseFloat(row.other_blood_sugar) || 0,
systolic_pressure: parseInt(row.systolic_pressure) || 0,
diastolic_pressure: parseInt(row.diastolic_pressure) || 0
}
dialogVisible.value = true
}
// 处理其他血糖输入,只允许数字
const handleOtherBloodSugarInput = (value: string) => {
recordForm.value.other_blood_sugar = value.replace(/[^\d.]/g, '')
}
// 删除记录
const handleDelete = async (row: any) => {
await feedback.confirm('确定要删除这条记录吗?')
try {
await bloodRecordDelete({ id: row.id })
feedback.msgSuccess('删除成功')
getRecords()
} catch (error) {
console.error('删除失败:', error)
}
}
// 提交
const handleSubmit = async () => {
if (!recordForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
return
}
submitting.value = true
try {
if (recordForm.value.id) {
await bloodRecordEdit(recordForm.value)
feedback.msgSuccess('编辑成功')
} else {
await bloodRecordAdd(recordForm.value)
feedback.msgSuccess('添加成功')
}
dialogVisible.value = false
getRecords()
loadTrendData()
} catch (error) {
console.error('提交失败:', error)
} finally {
submitting.value = false
}
}
onMounted(() => {
// 延迟加载数据,确保DOM完全渲染
setTimeout(() => {
getRecords()
loadTrendData()
}, 500)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
}
})
// 监听props变化
watch(() => [props.diagnosisId, props.patientId], () => {
getRecords()
loadTrendData()
})
// 监听组件可见性变化(Tab切换时)
watch(() => props.diagnosisId, (newVal) => {
if (newVal) {
// 当组件变为可见时,延迟重新渲染图表
setTimeout(() => {
renderChart()
}, 500)
}
})
</script>
<style lang="scss" scoped>
.blood-record-list {
padding: 20px;
}
</style>
@@ -1,214 +1,214 @@
<template>
<div class="call-record-panel">
<div v-if="!readOnly" class="call-record-toolbar">
<upload
ref="toolbarUploadRef"
type="video"
direct
:multiple="false"
:limit="1"
:show-progress="true"
@success="handleToolbarUploadSuccess"
>
<el-button type="primary">上传视频</el-button>
</upload>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<template #empty>
<div class="call-record-empty">
<div class="call-record-empty__title">暂无通话记录</div>
<div class="call-record-empty__desc">
{{
readOnly
? '暂无录制回放数据。'
: '现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。'
}}
</div>
</div>
</template>
<el-table-column label="录制回放" min-width="320">
<template #default="{ row }">
<RecordingPlaybackBlock :record-id="row.id" :urls="row.recording_urls_list" />
</template>
</el-table-column>
<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="180">
<template #default="{ row }">
<span v-if="row.room_id" class="text-primary">{{ row.room_id }}</span>
<span v-else class="text-gray-400"></span>
</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 v-if="!readOnly" label="上传回放" width="180">
<template #default="{ row }">
<upload
type="video"
direct
:multiple="false"
:limit="1"
:show-progress="true"
@success="(response: any) => handleRowUploadSuccess(row, response)"
>
<el-button type="primary" plain size="small">上传视频</el-button>
</upload>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
import { ref, useTemplateRef, watch } from 'vue'
type CallRecordRow = {
id: number
call_type: number
room_id?: string
status: number
recording_status_text?: string
recording_urls_list?: string[]
start_time_text?: string
end_time_text?: string
duration_text?: string
}
const props = withDefaults(
defineProps<{
diagnosisId: number
/** 只读场景(如诊单只读详情):不展示任何上传入口 */
readOnly?: boolean
}>(),
{ readOnly: false }
)
const loading = ref(false)
const rows = ref<CallRecordRow[]>([])
const toolbarUploadRef = useTemplateRef<InstanceType<typeof Upload>>('toolbarUploadRef')
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,
() => {
void load()
},
{ immediate: true }
)
defineExpose({ refresh: load })
function statusText(status: number) {
const m: Record<number, string> = {
1: '进行中',
2: '已结束',
3: '未接听',
4: '已取消'
}
return m[status] ?? '—'
}
async function handleToolbarUploadSuccess(response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
feedback.msgError('上传成功但未返回视频地址')
return
}
try {
const record = await createManualCallRecord({ diagnosis_id: props.diagnosisId })
await attachLocalCallRecording({
diagnosis_id: props.diagnosisId,
call_record_id: Number(record?.id || 0),
file_url: fileUrl
} as any)
feedback.msgSuccess('视频回放上传成功')
await load()
} catch (error: any) {
feedback.msgError(error?.message || '写入回放失败')
}
}
async function handleRowUploadSuccess(row: CallRecordRow, response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
feedback.msgError('上传成功但未返回视频地址')
return
}
try {
await attachLocalCallRecording({
diagnosis_id: props.diagnosisId,
call_record_id: Number(row.id || 0),
file_url: fileUrl
} as any)
feedback.msgSuccess('视频回放上传成功')
await load()
} catch (error: any) {
feedback.msgError(error?.message || '写入回放失败')
}
}
function normalizeUploadUrl(response: any): string {
return String(response?.data?.uri || response?.data?.url || '').trim()
}
</script>
<style scoped lang="scss">
.call-record-panel {
.call-record-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.call-record-empty {
padding: 28px 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.call-record-empty__title {
font-size: 14px;
color: var(--el-text-color-primary);
}
.call-record-empty__desc {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
}
}
</style>
<template>
<div class="call-record-panel">
<div v-if="!readOnly" class="call-record-toolbar">
<upload
ref="toolbarUploadRef"
type="video"
direct
:multiple="false"
:limit="1"
:show-progress="true"
@success="handleToolbarUploadSuccess"
>
<el-button type="primary">上传视频</el-button>
</upload>
</div>
<el-table v-loading="loading" :data="rows" border stripe>
<template #empty>
<div class="call-record-empty">
<div class="call-record-empty__title">暂无通话记录</div>
<div class="call-record-empty__desc">
{{
readOnly
? '暂无录制回放数据。'
: '现在可以直接点击上方“上传视频”。系统会自动生成一条默认通话记录来承载回放。'
}}
</div>
</div>
</template>
<el-table-column label="录制回放" min-width="320">
<template #default="{ row }">
<RecordingPlaybackBlock :record-id="row.id" :urls="row.recording_urls_list" />
</template>
</el-table-column>
<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="180">
<template #default="{ row }">
<span v-if="row.room_id" class="text-primary">{{ row.room_id }}</span>
<span v-else class="text-gray-400"></span>
</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 v-if="!readOnly" label="上传回放" width="180">
<template #default="{ row }">
<upload
type="video"
direct
:multiple="false"
:limit="1"
:show-progress="true"
@success="(response: any) => handleRowUploadSuccess(row, response)"
>
<el-button type="primary" plain size="small">上传视频</el-button>
</upload>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import RecordingPlaybackBlock from './RecordingPlaybackBlock.vue'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
import { attachLocalCallRecording, createManualCallRecord, getCallRecords } from '@/api/tcm'
import { ref, useTemplateRef, watch } from 'vue'
type CallRecordRow = {
id: number
call_type: number
room_id?: string
status: number
recording_status_text?: string
recording_urls_list?: string[]
start_time_text?: string
end_time_text?: string
duration_text?: string
}
const props = withDefaults(
defineProps<{
diagnosisId: number
/** 只读场景(如诊单只读详情):不展示任何上传入口 */
readOnly?: boolean
}>(),
{ readOnly: false }
)
const loading = ref(false)
const rows = ref<CallRecordRow[]>([])
const toolbarUploadRef = useTemplateRef<InstanceType<typeof Upload>>('toolbarUploadRef')
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,
() => {
void load()
},
{ immediate: true }
)
defineExpose({ refresh: load })
function statusText(status: number) {
const m: Record<number, string> = {
1: '进行中',
2: '已结束',
3: '未接听',
4: '已取消'
}
return m[status] ?? '—'
}
async function handleToolbarUploadSuccess(response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
feedback.msgError('上传成功但未返回视频地址')
return
}
try {
const record = await createManualCallRecord({ diagnosis_id: props.diagnosisId })
await attachLocalCallRecording({
diagnosis_id: props.diagnosisId,
call_record_id: Number(record?.id || 0),
file_url: fileUrl
} as any)
feedback.msgSuccess('视频回放上传成功')
await load()
} catch (error: any) {
feedback.msgError(error?.message || '写入回放失败')
}
}
async function handleRowUploadSuccess(row: CallRecordRow, response: any) {
const fileUrl = normalizeUploadUrl(response)
if (!fileUrl) {
feedback.msgError('上传成功但未返回视频地址')
return
}
try {
await attachLocalCallRecording({
diagnosis_id: props.diagnosisId,
call_record_id: Number(row.id || 0),
file_url: fileUrl
} as any)
feedback.msgSuccess('视频回放上传成功')
await load()
} catch (error: any) {
feedback.msgError(error?.message || '写入回放失败')
}
}
function normalizeUploadUrl(response: any): string {
return String(response?.data?.uri || response?.data?.url || '').trim()
}
</script>
<style scoped lang="scss">
.call-record-panel {
.call-record-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.call-record-empty {
padding: 28px 12px;
color: var(--el-text-color-secondary);
text-align: center;
}
.call-record-empty__title {
font-size: 14px;
color: var(--el-text-color-primary);
}
.call-record-empty__desc {
margin-top: 8px;
font-size: 12px;
line-height: 1.6;
}
}
</style>
@@ -1,107 +1,107 @@
<template>
<div class="case-record-list">
<div v-if="!readOnly" class="mb-3 flex justify-end">
<el-button type="primary" size="small" @click="handleOpenPrescription">开方</el-button>
</div>
<el-table :data="caseList" border v-loading="loading">
<el-table-column prop="prescription_date" label="就诊日期" width="120" />
<el-table-column prop="visit_no" label="门诊号" width="120" />
<el-table-column prop="clinical_diagnosis" label="临床诊断" min-width="160" show-overflow-tooltip />
<el-table-column label="处方摘要" min-width="180">
<template #default="{ row }">
<span v-if="row.herbs && row.herbs.length">
{{ row.herbs.slice(0, 3).map((h: any) => `${h.name}${h.dosage}`).join('、') }}{{ row.herbs.length > 3 ? '...' : '' }}
</span>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column prop="doctor_name" label="医师" width="90" show-overflow-tooltip />
<el-table-column label="状态" width="140" align="center">
<template #default="{ row }">
<template v-if="row.void_status === 1">
<el-tag type="danger" size="small">已作废</el-tag>
<div class="void-detail text-xs text-gray-500 mt-1">
{{ row.void_by_name || '—' }} {{ formatVoidTime(row.void_time) }}
</div>
</template>
<el-tag v-else type="success" size="small">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleView(row)">
查看
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && caseList.length === 0" description="暂无病历记录,开方后会自动显示" class="mt-4" />
</div>
</template>
<script setup lang="ts">
import { prescriptionListByDiagnosis } from '@/api/tcm'
const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
/** 只读:隐藏开方(与诊单 openViewOnly 一致) */
readOnly: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['view', 'openPrescription'])
const caseList = ref<any[]>([])
const loading = ref(false)
const getCases = async () => {
if (!props.diagnosisId) return
loading.value = true
try {
const data = await prescriptionListByDiagnosis({ diagnosis_id: props.diagnosisId })
caseList.value = Array.isArray(data) ? data : []
} catch (error) {
console.error('获取病历记录失败:', error)
caseList.value = []
} finally {
loading.value = false
}
}
const formatVoidTime = (ts: number | null | undefined) => {
if (!ts) return ''
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const handleView = (row: any) => {
emit('view', row)
}
const handleOpenPrescription = () => {
emit('openPrescription')
}
onMounted(() => {
getCases()
})
watch(() => props.diagnosisId, () => {
getCases()
})
defineExpose({
refresh: getCases
})
</script>
<style lang="scss" scoped>
.case-record-list {
padding: 20px;
}
</style>
<template>
<div class="case-record-list">
<div v-if="!readOnly" class="mb-3 flex justify-end">
<el-button type="primary" size="small" @click="handleOpenPrescription">开方</el-button>
</div>
<el-table :data="caseList" border v-loading="loading">
<el-table-column prop="prescription_date" label="就诊日期" width="120" />
<el-table-column prop="visit_no" label="门诊号" width="120" />
<el-table-column prop="clinical_diagnosis" label="临床诊断" min-width="160" show-overflow-tooltip />
<el-table-column label="处方摘要" min-width="180">
<template #default="{ row }">
<span v-if="row.herbs && row.herbs.length">
{{ row.herbs.slice(0, 3).map((h: any) => `${h.name}${h.dosage}`).join('、') }}{{ row.herbs.length > 3 ? '...' : '' }}
</span>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column prop="doctor_name" label="医师" width="90" show-overflow-tooltip />
<el-table-column label="状态" width="140" align="center">
<template #default="{ row }">
<template v-if="row.void_status === 1">
<el-tag type="danger" size="small">已作废</el-tag>
<div class="void-detail text-xs text-gray-500 mt-1">
{{ row.void_by_name || '—' }} {{ formatVoidTime(row.void_time) }}
</div>
</template>
<el-tag v-else type="success" size="small">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleView(row)">
查看
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && caseList.length === 0" description="暂无病历记录,开方后会自动显示" class="mt-4" />
</div>
</template>
<script setup lang="ts">
import { prescriptionListByDiagnosis } from '@/api/tcm'
const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
/** 只读:隐藏开方(与诊单 openViewOnly 一致) */
readOnly: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['view', 'openPrescription'])
const caseList = ref<any[]>([])
const loading = ref(false)
const getCases = async () => {
if (!props.diagnosisId) return
loading.value = true
try {
const data = await prescriptionListByDiagnosis({ diagnosis_id: props.diagnosisId })
caseList.value = Array.isArray(data) ? data : []
} catch (error) {
console.error('获取病历记录失败:', error)
caseList.value = []
} finally {
loading.value = false
}
}
const formatVoidTime = (ts: number | null | undefined) => {
if (!ts) return ''
const d = new Date(ts * 1000)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const handleView = (row: any) => {
emit('view', row)
}
const handleOpenPrescription = () => {
emit('openPrescription')
}
onMounted(() => {
getCases()
})
watch(() => props.diagnosisId, () => {
getCases()
})
defineExpose({
refresh: getCases
})
</script>
<style lang="scss" scoped>
.case-record-list {
padding: 20px;
}
</style>
@@ -1,240 +1,240 @@
<template>
<div class="diet-record-list">
<div v-if="!readOnly" class="mb-4">
<el-button type="primary" @click="handleAdd">添加记录</el-button>
</div>
<el-table :data="recordList" border>
<el-table-column prop="record_date" label="记录日期" width="120" />
<el-table-column label="早餐" min-width="200">
<template #default="{ row }">
<div v-if="row.breakfast_foods">{{ row.breakfast_foods }}</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="午餐" min-width="200">
<template #default="{ row }">
<div v-if="row.lunch_foods">{{ row.lunch_foods }}</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="晚餐" min-width="200">
<template #default="{ row }">
<div v-if="row.dinner_foods">{{ row.dinner_foods }}</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="800px">
<el-form :model="recordForm" label-width="100px">
<el-form-item label="记录日期" required>
<el-date-picker
v-model="recordForm.record_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
<el-divider content-position="left">早餐</el-divider>
<el-form-item label="食物描述">
<el-input
v-model="recordForm.breakfast_foods"
type="textarea"
:rows="2"
placeholder="请输入早餐食物"
/>
</el-form-item>
<el-form-item label="图片">
<material-picker
v-model="recordForm.breakfast_images"
:limit="3"
type="image"
/>
</el-form-item>
<el-divider content-position="left">午餐</el-divider>
<el-form-item label="食物描述">
<el-input
v-model="recordForm.lunch_foods"
type="textarea"
:rows="2"
placeholder="请输入午餐食物"
/>
</el-form-item>
<el-form-item label="图片">
<material-picker
v-model="recordForm.lunch_images"
:limit="3"
type="image"
/>
</el-form-item>
<el-divider content-position="left">晚餐</el-divider>
<el-form-item label="食物描述">
<el-input
v-model="recordForm.dinner_foods"
type="textarea"
:rows="2"
placeholder="请输入晚餐食物"
/>
</el-form-item>
<el-form-item label="图片">
<material-picker
v-model="recordForm.dinner_images"
:limit="3"
type="image"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="recordForm.note"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { dietRecordAdd, dietRecordEdit, dietRecordDelete, getDietRecordsByPatient } from '@/api/tcm'
import feedback from '@/utils/feedback'
const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
const recordList = ref([])
const dialogVisible = ref(false)
const submitting = ref(false)
const dialogTitle = computed(() => recordForm.value.id ? '编辑记录' : '添加记录')
const recordForm = ref({
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
breakfast_foods: '',
breakfast_images: [],
lunch_foods: '',
lunch_images: [],
dinner_foods: '',
dinner_images: [],
note: ''
})
const getRecords = async () => {
if (!props.diagnosisId && !props.patientId) {
return
}
try {
const data = await getDietRecordsByPatient({
diagnosis_id: props.diagnosisId,
patient_id: props.patientId
})
recordList.value = data
} catch (error) {
console.error('获取记录失败:', error)
}
}
const handleAdd = () => {
recordForm.value = {
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
breakfast_foods: '',
breakfast_images: [],
lunch_foods: '',
lunch_images: [],
dinner_foods: '',
dinner_images: [],
note: ''
}
dialogVisible.value = true
}
const handleEdit = (row: any) => {
recordForm.value = { ...row }
dialogVisible.value = true
}
const handleDelete = async (row: any) => {
await feedback.confirm('确定要删除这条记录吗?')
try {
await dietRecordDelete({ id: row.id })
feedback.msgSuccess('删除成功')
getRecords()
} catch (error) {
console.error('删除失败:', error)
}
}
const handleSubmit = async () => {
if (!recordForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
return
}
submitting.value = true
try {
if (recordForm.value.id) {
await dietRecordEdit(recordForm.value)
feedback.msgSuccess('编辑成功')
} else {
await dietRecordAdd(recordForm.value)
feedback.msgSuccess('添加成功')
}
dialogVisible.value = false
getRecords()
} catch (error) {
console.error('提交失败:', error)
} finally {
submitting.value = false
}
}
onMounted(() => {
getRecords()
})
watch(() => [props.diagnosisId, props.patientId], () => {
getRecords()
})
</script>
<style lang="scss" scoped>
.diet-record-list {
padding: 20px;
}
</style>
<template>
<div class="diet-record-list">
<div v-if="!readOnly" class="mb-4">
<el-button type="primary" @click="handleAdd">添加记录</el-button>
</div>
<el-table :data="recordList" border>
<el-table-column prop="record_date" label="记录日期" width="120" />
<el-table-column label="早餐" min-width="200">
<template #default="{ row }">
<div v-if="row.breakfast_foods">{{ row.breakfast_foods }}</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="午餐" min-width="200">
<template #default="{ row }">
<div v-if="row.lunch_foods">{{ row.lunch_foods }}</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="晚餐" min-width="200">
<template #default="{ row }">
<div v-if="row.dinner_foods">{{ row.dinner_foods }}</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="800px">
<el-form :model="recordForm" label-width="100px">
<el-form-item label="记录日期" required>
<el-date-picker
v-model="recordForm.record_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
<el-divider content-position="left">早餐</el-divider>
<el-form-item label="食物描述">
<el-input
v-model="recordForm.breakfast_foods"
type="textarea"
:rows="2"
placeholder="请输入早餐食物"
/>
</el-form-item>
<el-form-item label="图片">
<material-picker
v-model="recordForm.breakfast_images"
:limit="3"
type="image"
/>
</el-form-item>
<el-divider content-position="left">午餐</el-divider>
<el-form-item label="食物描述">
<el-input
v-model="recordForm.lunch_foods"
type="textarea"
:rows="2"
placeholder="请输入午餐食物"
/>
</el-form-item>
<el-form-item label="图片">
<material-picker
v-model="recordForm.lunch_images"
:limit="3"
type="image"
/>
</el-form-item>
<el-divider content-position="left">晚餐</el-divider>
<el-form-item label="食物描述">
<el-input
v-model="recordForm.dinner_foods"
type="textarea"
:rows="2"
placeholder="请输入晚餐食物"
/>
</el-form-item>
<el-form-item label="图片">
<material-picker
v-model="recordForm.dinner_images"
:limit="3"
type="image"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="recordForm.note"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { dietRecordAdd, dietRecordEdit, dietRecordDelete, getDietRecordsByPatient } from '@/api/tcm'
import feedback from '@/utils/feedback'
const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
const recordList = ref([])
const dialogVisible = ref(false)
const submitting = ref(false)
const dialogTitle = computed(() => recordForm.value.id ? '编辑记录' : '添加记录')
const recordForm = ref({
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
breakfast_foods: '',
breakfast_images: [],
lunch_foods: '',
lunch_images: [],
dinner_foods: '',
dinner_images: [],
note: ''
})
const getRecords = async () => {
if (!props.diagnosisId && !props.patientId) {
return
}
try {
const data = await getDietRecordsByPatient({
diagnosis_id: props.diagnosisId,
patient_id: props.patientId
})
recordList.value = data
} catch (error) {
console.error('获取记录失败:', error)
}
}
const handleAdd = () => {
recordForm.value = {
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
breakfast_foods: '',
breakfast_images: [],
lunch_foods: '',
lunch_images: [],
dinner_foods: '',
dinner_images: [],
note: ''
}
dialogVisible.value = true
}
const handleEdit = (row: any) => {
recordForm.value = { ...row }
dialogVisible.value = true
}
const handleDelete = async (row: any) => {
await feedback.confirm('确定要删除这条记录吗?')
try {
await dietRecordDelete({ id: row.id })
feedback.msgSuccess('删除成功')
getRecords()
} catch (error) {
console.error('删除失败:', error)
}
}
const handleSubmit = async () => {
if (!recordForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
return
}
submitting.value = true
try {
if (recordForm.value.id) {
await dietRecordEdit(recordForm.value)
feedback.msgSuccess('编辑成功')
} else {
await dietRecordAdd(recordForm.value)
feedback.msgSuccess('添加成功')
}
dialogVisible.value = false
getRecords()
} catch (error) {
console.error('提交失败:', error)
} finally {
submitting.value = false
}
}
onMounted(() => {
getRecords()
})
watch(() => [props.diagnosisId, props.patientId], () => {
getRecords()
})
</script>
<style lang="scss" scoped>
.diet-record-list {
padding: 20px;
}
</style>
@@ -1,231 +1,231 @@
<template>
<div class="exercise-record-list">
<div v-if="!readOnly" class="mb-4">
<el-button type="primary" @click="handleAdd">添加记录</el-button>
</div>
<el-table :data="recordList" border style="width: 100%">
<el-table-column prop="record_date" label="记录日期" width="120" />
<el-table-column prop="exercise_type" label="运动类型" width="150" />
<el-table-column prop="duration" label="运动时长" width="100">
<template #default="{ row }">
{{ row.duration }} 分钟
</template>
</el-table-column>
<el-table-column label="运动强度" width="100">
<template #default="{ row }">
{{ getIntensityText(row.intensity) }}
</template>
</el-table-column>
<el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="recordForm" label-width="120px">
<el-form-item label="记录日期" required>
<el-date-picker
v-model="recordForm.record_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
<el-form-item label="运动类型" required>
<el-select v-model="recordForm.exercise_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-form-item label="运动时长(分钟)" required>
<el-input-number
v-model="recordForm.duration"
:min="0"
:max="300"
placeholder="请输入运动时长"
/>
</el-form-item>
<el-form-item label="运动强度" required>
<el-select v-model="recordForm.intensity" placeholder="请选择运动强度" class="w-full">
<el-option label="低强度" :value="1" />
<el-option label="中强度" :value="2" />
<el-option label="高强度" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="运动图片">
<material-picker
v-model="recordForm.images"
:limit="3"
type="image"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="recordForm.note"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { exerciseRecordAdd, exerciseRecordEdit, exerciseRecordDelete, getExerciseRecordsByPatient } from '@/api/tcm'
import feedback from '@/utils/feedback'
const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
const recordList = ref([])
const dialogVisible = ref(false)
const submitting = ref(false)
const dialogTitle = computed(() => recordForm.value.id ? '编辑记录' : '添加记录')
const recordForm = ref({
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
exercise_type: '',
duration: 0,
intensity: 1,
images: [],
note: ''
})
const intensityOptions = [
{ label: '低强度', value: 1 },
{ label: '中强度', value: 2 },
{ label: '高强度', value: 3 }
]
const getIntensityText = (intensity: number) => {
const option = intensityOptions.find(opt => opt.value === intensity)
return option ? option.label : '未知'
}
const getRecords = async () => {
if (!props.diagnosisId && !props.patientId) {
return
}
try {
const data = await getExerciseRecordsByPatient({
diagnosis_id: props.diagnosisId,
patient_id: props.patientId
})
recordList.value = data
} catch (error) {
console.error('获取记录失败:', error)
}
}
const handleAdd = () => {
recordForm.value = {
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
exercise_type: '',
duration: 0,
intensity: 1,
images: [],
note: ''
}
dialogVisible.value = true
}
const handleEdit = (row: any) => {
recordForm.value = { ...row }
dialogVisible.value = true
}
const handleDelete = async (row: any) => {
await feedback.confirm('确定要删除这条记录吗?')
try {
await exerciseRecordDelete({ id: row.id })
feedback.msgSuccess('删除成功')
getRecords()
} catch (error) {
console.error('删除失败:', error)
}
}
const handleSubmit = async () => {
if (!recordForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
return
}
if (!recordForm.value.exercise_type) {
feedback.msgWarning('请选择运动类型')
return
}
if (!recordForm.value.duration) {
feedback.msgWarning('请输入运动时长')
return
}
submitting.value = true
try {
if (recordForm.value.id) {
await exerciseRecordEdit(recordForm.value)
feedback.msgSuccess('编辑成功')
} else {
await exerciseRecordAdd(recordForm.value)
feedback.msgSuccess('添加成功')
}
dialogVisible.value = false
getRecords()
} catch (error) {
console.error('提交失败:', error)
} finally {
submitting.value = false
}
}
onMounted(() => {
getRecords()
})
watch(() => [props.diagnosisId, props.patientId], () => {
getRecords()
})
</script>
<style lang="scss" scoped>
.exercise-record-list {
padding: 20px;
}
</style>
<template>
<div class="exercise-record-list">
<div v-if="!readOnly" class="mb-4">
<el-button type="primary" @click="handleAdd">添加记录</el-button>
</div>
<el-table :data="recordList" border style="width: 100%">
<el-table-column prop="record_date" label="记录日期" width="120" />
<el-table-column prop="exercise_type" label="运动类型" width="150" />
<el-table-column prop="duration" label="运动时长" width="100">
<template #default="{ row }">
{{ row.duration }} 分钟
</template>
</el-table-column>
<el-table-column label="运动强度" width="100">
<template #default="{ row }">
{{ getIntensityText(row.intensity) }}
</template>
</el-table-column>
<el-table-column prop="note" label="备注" show-overflow-tooltip />
<el-table-column v-if="!readOnly" label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="recordForm" label-width="120px">
<el-form-item label="记录日期" required>
<el-date-picker
v-model="recordForm.record_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
class="w-full"
/>
</el-form-item>
<el-form-item label="运动类型" required>
<el-select v-model="recordForm.exercise_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-form-item label="运动时长(分钟)" required>
<el-input-number
v-model="recordForm.duration"
:min="0"
:max="300"
placeholder="请输入运动时长"
/>
</el-form-item>
<el-form-item label="运动强度" required>
<el-select v-model="recordForm.intensity" placeholder="请选择运动强度" class="w-full">
<el-option label="低强度" :value="1" />
<el-option label="中强度" :value="2" />
<el-option label="高强度" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="运动图片">
<material-picker
v-model="recordForm.images"
:limit="3"
type="image"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="recordForm.note"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { exerciseRecordAdd, exerciseRecordEdit, exerciseRecordDelete, getExerciseRecordsByPatient } from '@/api/tcm'
import feedback from '@/utils/feedback'
const props = defineProps({
diagnosisId: {
type: Number,
default: 0
},
patientId: {
type: Number,
default: 0
},
readOnly: {
type: Boolean,
default: false
}
})
const recordList = ref([])
const dialogVisible = ref(false)
const submitting = ref(false)
const dialogTitle = computed(() => recordForm.value.id ? '编辑记录' : '添加记录')
const recordForm = ref({
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
exercise_type: '',
duration: 0,
intensity: 1,
images: [],
note: ''
})
const intensityOptions = [
{ label: '低强度', value: 1 },
{ label: '中强度', value: 2 },
{ label: '高强度', value: 3 }
]
const getIntensityText = (intensity: number) => {
const option = intensityOptions.find(opt => opt.value === intensity)
return option ? option.label : '未知'
}
const getRecords = async () => {
if (!props.diagnosisId && !props.patientId) {
return
}
try {
const data = await getExerciseRecordsByPatient({
diagnosis_id: props.diagnosisId,
patient_id: props.patientId
})
recordList.value = data
} catch (error) {
console.error('获取记录失败:', error)
}
}
const handleAdd = () => {
recordForm.value = {
id: '',
diagnosis_id: props.diagnosisId,
patient_id: props.patientId,
record_date: '',
exercise_type: '',
duration: 0,
intensity: 1,
images: [],
note: ''
}
dialogVisible.value = true
}
const handleEdit = (row: any) => {
recordForm.value = { ...row }
dialogVisible.value = true
}
const handleDelete = async (row: any) => {
await feedback.confirm('确定要删除这条记录吗?')
try {
await exerciseRecordDelete({ id: row.id })
feedback.msgSuccess('删除成功')
getRecords()
} catch (error) {
console.error('删除失败:', error)
}
}
const handleSubmit = async () => {
if (!recordForm.value.record_date) {
feedback.msgWarning('请选择记录日期')
return
}
if (!recordForm.value.exercise_type) {
feedback.msgWarning('请选择运动类型')
return
}
if (!recordForm.value.duration) {
feedback.msgWarning('请输入运动时长')
return
}
submitting.value = true
try {
if (recordForm.value.id) {
await exerciseRecordEdit(recordForm.value)
feedback.msgSuccess('编辑成功')
} else {
await exerciseRecordAdd(recordForm.value)
feedback.msgSuccess('添加成功')
}
dialogVisible.value = false
getRecords()
} catch (error) {
console.error('提交失败:', error)
} finally {
submitting.value = false
}
}
onMounted(() => {
getRecords()
})
watch(() => [props.diagnosisId, props.patientId], () => {
getRecords()
})
</script>
<style lang="scss" scoped>
.exercise-record-list {
padding: 20px;
}
</style>
@@ -1,293 +1,293 @@
<template>
<div class="im-chat-record-panel">
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
<p class="panel-tip">
展示单聊记录已合并患者
<strong>所有医生 / 医助账号</strong>分别产生的会话按时间排序
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档点击下方按钮可立即触发后台同步
</p>
</el-alert>
<div class="toolbar mb-3">
<el-button type="primary" link :loading="syncing" @click="triggerSync">
<el-icon class="mr-1"><Promotion /></el-icon>
同步最新后台异步
</el-button>
<el-button type="primary" link :loading="loading" @click="reloadArchived">
<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="item in rows"
:key="item.raw.msg_id"
class="chat-row"
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
>
<div class="meta">
<span class="name">{{ senderLabel(item.raw) }}</span>
<span class="time">{{ formatTime(item.raw.time) }}</span>
<el-tag v-if="item.tag" size="small" type="info" class="ml-2">
{{ item.tag }}
</el-tag>
</div>
<div class="bubble">
<template v-if="item.raw.msg_type === 'image' && item.raw.image_url">
<el-image
:src="item.raw.image_url"
:preview-src-list="[item.raw.image_url]"
fit="contain"
class="chat-img"
/>
</template>
<template v-else-if="(item.raw.msg_type === 'file' || item.raw.msg_type === 'sound' || item.raw.msg_type === 'video') && item.raw.file_url">
<el-link :href="item.raw.file_url" target="_blank" type="primary">
{{ item.raw.file_name || '打开文件' }}
</el-link>
</template>
<template v-else>
<div v-if="item.friendly" class="friendly-text">
<div class="friendly-main">{{ item.friendly.main }}</div>
<div v-if="item.friendly.sub" class="friendly-sub">{{ item.friendly.sub }}</div>
</div>
<div v-else-if="item.raw.msg_type === 'text' && item.raw.text" class="text-content">
{{ item.raw.text }}
</div>
<div v-else-if="item.raw.text" class="text-content">{{ item.raw.text }}</div>
<span v-else class="muted">无法展示该消息类型</span>
</template>
</div>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, watch, computed } from 'vue'
import dayjs from 'dayjs'
import { Refresh, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getImChatMessages, triggerImChatSync } 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
}>()
const loading = ref(false)
const syncing = ref(false)
const rawRows = shallowRef<any[]>([])
const patientImId = ref('')
const patientName = ref('')
const patientImHint = computed(() => patientImId.value || 'patient_*')
interface EnrichedRow {
raw: any
friendly: FriendlyParse | null
tag: string
}
const MSG_TYPE_LABELS: Record<string, string> = {
text: '',
image: '图片',
file: '文件',
sound: '语音',
video: '视频',
location: '位置',
custom: '自定义',
face: '表情',
other: ''
}
const rows = computed<EnrichedRow[]>(() =>
rawRows.value.map((row) => {
const fr = parseImFriendly(row)
let tag = ''
if (fr?.tag) {
tag = fr.tag
} else {
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
return { raw: row, friendly: fr, tag }
})
)
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')
}
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 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,
only_archived: 1
})) as {
lists?: any[]
patient_im_id?: string
patient_name?: string
}
rawRows.value = res?.lists || []
patientImId.value = res?.patient_im_id || ''
patientName.value = res?.patient_name || ''
} catch (e) {
console.error(e)
rawRows.value = []
} finally {
loading.value = false
}
}
function reloadArchived() {
load()
}
async function triggerSync() {
if (!props.diagnosisId) return
syncing.value = true
try {
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
} catch (e) {
console.error(e)
ElMessage.error('发起同步失败')
} finally {
syncing.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load()
},
{ immediate: true }
)
defineExpose({ refresh: reloadArchived })
</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>
<template>
<div class="im-chat-record-panel">
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
<p class="panel-tip">
展示单聊记录已合并患者
<strong>所有医生 / 医助账号</strong>分别产生的会话按时间排序
数据由后台定时任务从腾讯云 IM 漫游消息同步至本地归档点击下方按钮可立即触发后台同步
</p>
</el-alert>
<div class="toolbar mb-3">
<el-button type="primary" link :loading="syncing" @click="triggerSync">
<el-icon class="mr-1"><Promotion /></el-icon>
同步最新后台异步
</el-button>
<el-button type="primary" link :loading="loading" @click="reloadArchived">
<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="item in rows"
:key="item.raw.msg_id"
class="chat-row"
:class="item.raw.is_from_doctor ? 'from-doctor' : 'from-patient'"
>
<div class="meta">
<span class="name">{{ senderLabel(item.raw) }}</span>
<span class="time">{{ formatTime(item.raw.time) }}</span>
<el-tag v-if="item.tag" size="small" type="info" class="ml-2">
{{ item.tag }}
</el-tag>
</div>
<div class="bubble">
<template v-if="item.raw.msg_type === 'image' && item.raw.image_url">
<el-image
:src="item.raw.image_url"
:preview-src-list="[item.raw.image_url]"
fit="contain"
class="chat-img"
/>
</template>
<template v-else-if="(item.raw.msg_type === 'file' || item.raw.msg_type === 'sound' || item.raw.msg_type === 'video') && item.raw.file_url">
<el-link :href="item.raw.file_url" target="_blank" type="primary">
{{ item.raw.file_name || '打开文件' }}
</el-link>
</template>
<template v-else>
<div v-if="item.friendly" class="friendly-text">
<div class="friendly-main">{{ item.friendly.main }}</div>
<div v-if="item.friendly.sub" class="friendly-sub">{{ item.friendly.sub }}</div>
</div>
<div v-else-if="item.raw.msg_type === 'text' && item.raw.text" class="text-content">
{{ item.raw.text }}
</div>
<div v-else-if="item.raw.text" class="text-content">{{ item.raw.text }}</div>
<span v-else class="muted">无法展示该消息类型</span>
</template>
</div>
</div>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无 IM 聊天记录" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, watch, computed } from 'vue'
import dayjs from 'dayjs'
import { Refresh, Promotion } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getImChatMessages, triggerImChatSync } 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
}>()
const loading = ref(false)
const syncing = ref(false)
const rawRows = shallowRef<any[]>([])
const patientImId = ref('')
const patientName = ref('')
const patientImHint = computed(() => patientImId.value || 'patient_*')
interface EnrichedRow {
raw: any
friendly: FriendlyParse | null
tag: string
}
const MSG_TYPE_LABELS: Record<string, string> = {
text: '',
image: '图片',
file: '文件',
sound: '语音',
video: '视频',
location: '位置',
custom: '自定义',
face: '表情',
other: ''
}
const rows = computed<EnrichedRow[]>(() =>
rawRows.value.map((row) => {
const fr = parseImFriendly(row)
let tag = ''
if (fr?.tag) {
tag = fr.tag
} else {
tag = MSG_TYPE_LABELS[row.msg_type] || (row.msg_type !== 'other' ? row.msg_type : '')
}
return { raw: row, friendly: fr, tag }
})
)
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')
}
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 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,
only_archived: 1
})) as {
lists?: any[]
patient_im_id?: string
patient_name?: string
}
rawRows.value = res?.lists || []
patientImId.value = res?.patient_im_id || ''
patientName.value = res?.patient_name || ''
} catch (e) {
console.error(e)
rawRows.value = []
} finally {
loading.value = false
}
}
function reloadArchived() {
load()
}
async function triggerSync() {
if (!props.diagnosisId) return
syncing.value = true
try {
await triggerImChatSync({ diagnosis_id: props.diagnosisId })
ElMessage.success('已发起后台同步,几秒后请点击"重新加载已归档"查看新消息')
} catch (e) {
console.error(e)
ElMessage.error('发起同步失败')
} finally {
syncing.value = false
}
}
watch(
() => props.diagnosisId,
() => {
load()
},
{ immediate: true }
)
defineExpose({ refresh: reloadArchived })
</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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff