1188 lines
40 KiB
Vue
1188 lines
40 KiB
Vue
<template>
|
|
<div class="daily-matrix" v-loading="loading">
|
|
<div class="daily-matrix__toolbar">
|
|
<div class="daily-matrix__toolbar-left">
|
|
<el-radio-group v-model="rangeMode" @change="handleRangeModeChange">
|
|
<el-radio-button value="7">最近7天</el-radio-button>
|
|
<el-radio-button value="30">最近30天</el-radio-button>
|
|
<el-radio-button value="custom">自定义</el-radio-button>
|
|
</el-radio-group>
|
|
|
|
<el-date-picker
|
|
v-if="rangeMode === 'custom'"
|
|
v-model="customRange"
|
|
type="daterange"
|
|
value-format="YYYY-MM-DD"
|
|
range-separator="至"
|
|
start-placeholder="开始日期"
|
|
end-placeholder="结束日期"
|
|
:clearable="false"
|
|
:disabled-date="disabledFutureDate"
|
|
@change="handleCustomRangeChange"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="canEditDailyRecord" class="daily-matrix__toolbar-right">
|
|
<el-button type="primary" @click="openQuickAdd('blood')">+血糖</el-button>
|
|
<el-button @click="openQuickAdd('diet')">+饮食</el-button>
|
|
<el-button @click="openQuickAdd('exercise')">+运动</el-button>
|
|
<el-button @click="openQuickAdd('tracking')">+备注</el-button>
|
|
<el-button @click="fetchTracking">刷新</el-button>
|
|
</div>
|
|
<div v-else class="daily-matrix__toolbar-right">
|
|
<el-button @click="fetchTracking">刷新</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref="tableWrapRef" class="daily-matrix__table-wrap">
|
|
<el-table
|
|
:data="matrixRows"
|
|
border
|
|
stripe
|
|
row-key="key"
|
|
class="daily-matrix__table"
|
|
empty-text="暂无记录"
|
|
>
|
|
<el-table-column prop="label" label="指标" fixed="left" width="120" />
|
|
<el-table-column
|
|
v-for="date in visibleDates"
|
|
:key="date"
|
|
:label="formatColumnDate(date)"
|
|
:min-width="dateColumnMinWidth"
|
|
align="center"
|
|
>
|
|
<template #default="{ row }">
|
|
<div
|
|
class="daily-matrix__cell"
|
|
:class="{
|
|
'is-clickable': isCellClickable(row.key, date),
|
|
'is-empty': !getCell(row.key, date).hasRecord,
|
|
'is-high': getCell(row.key, date).isHigh
|
|
}"
|
|
@click="handleCellClick(row.key, date)"
|
|
>
|
|
<template v-if="row.key === 'tracking' && getTrackingContent(date)">
|
|
<el-tooltip placement="top" effect="light" :show-after="150">
|
|
<template #content>
|
|
<div class="daily-matrix__tracking-tooltip">
|
|
<div
|
|
v-for="(line, index) in splitTrackingLines(getTrackingContent(date))"
|
|
:key="`${date}-${index}`"
|
|
class="daily-matrix__tracking-tooltip-line"
|
|
>
|
|
{{ line }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="daily-matrix__tracking-preview">
|
|
{{ getTrackingPreview(getTrackingContent(date)) }}
|
|
</div>
|
|
</el-tooltip>
|
|
</template>
|
|
<template v-else-if="getCell(row.key, date).value">
|
|
<span>{{ getCell(row.key, date).value }}</span>
|
|
<span v-if="getCell(row.key, date).isHigh" class="daily-matrix__cell-up">↑</span>
|
|
</template>
|
|
<template v-else>—</template>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
|
|
<div class="daily-matrix__chart">
|
|
<div class="daily-matrix__section-title">血糖趋势</div>
|
|
<v-charts
|
|
v-if="bloodTrendHasData"
|
|
class="daily-matrix__chart-canvas"
|
|
:option="bloodTrendOption"
|
|
autoresize
|
|
/>
|
|
<el-empty v-else description="当前时间范围暂无血糖数据" :image-size="84" />
|
|
</div>
|
|
|
|
<div v-if="canViewTodoList" class="daily-matrix__todo">
|
|
<div class="daily-matrix__section-title">待办事项</div>
|
|
<DiagnosisTodoList
|
|
:diagnosis-id="diagnosisId"
|
|
:patient-id="patientId"
|
|
:patient-name="patientName"
|
|
:read-only="readOnly"
|
|
/>
|
|
</div>
|
|
|
|
<el-dialog v-model="bloodDialogVisible" :title="bloodDialogTitle" width="600px" :close-on-click-modal="false">
|
|
<el-form :model="bloodForm" label-width="120px">
|
|
<el-form-item label="记录日期" required>
|
|
<el-date-picker
|
|
v-model="bloodForm.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="bloodForm.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="bloodForm.fasting_blood_sugar" :precision="2" :step="0.1" :min="0" :max="50" />
|
|
<span class="ml-2">mmol/L</span>
|
|
</el-form-item>
|
|
<el-form-item label="餐后2小时血糖">
|
|
<el-input-number v-model="bloodForm.postprandial_blood_sugar" :precision="2" :step="0.1" :min="0" :max="50" />
|
|
<span class="ml-2">mmol/L</span>
|
|
</el-form-item>
|
|
<el-form-item label="其他血糖">
|
|
<el-input v-model="bloodForm.other_blood_sugar" type="number" style="width: 200px" />
|
|
<span class="ml-2">mmol/L</span>
|
|
</el-form-item>
|
|
<el-form-item label="收缩压(高压)">
|
|
<el-input-number v-model="bloodForm.systolic_pressure" :min="0" :max="300" />
|
|
<span class="ml-2">mmHg</span>
|
|
</el-form-item>
|
|
<el-form-item label="舒张压(低压)">
|
|
<el-input-number v-model="bloodForm.diastolic_pressure" :min="0" :max="200" />
|
|
<span class="ml-2">mmHg</span>
|
|
</el-form-item>
|
|
<el-form-item label="西药">
|
|
<el-input v-model="bloodForm.western_medicine" />
|
|
</el-form-item>
|
|
<el-form-item label="胰岛素">
|
|
<el-input v-model="bloodForm.insulin" />
|
|
</el-form-item>
|
|
<el-form-item label="备注">
|
|
<el-input v-model="bloodForm.remark" type="textarea" :rows="3" />
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="bloodDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="bloodSubmitting" :disabled="!canEditDailyRecord" @click="submitBlood">确定</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="dietDialogVisible" :title="dietDialogTitle" width="800px" :close-on-click-modal="false">
|
|
<el-form :model="dietForm" label-width="100px">
|
|
<el-form-item label="记录日期" required>
|
|
<el-date-picker
|
|
v-model="dietForm.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="dietForm.breakfast_foods" type="textarea" :rows="2" placeholder="请输入早餐食物" />
|
|
</el-form-item>
|
|
<el-form-item label="图片">
|
|
<material-picker v-model="dietForm.breakfast_images" :limit="3" type="image" />
|
|
</el-form-item>
|
|
<el-divider content-position="left">午餐</el-divider>
|
|
<el-form-item label="食物描述">
|
|
<el-input v-model="dietForm.lunch_foods" type="textarea" :rows="2" placeholder="请输入午餐食物" />
|
|
</el-form-item>
|
|
<el-form-item label="图片">
|
|
<material-picker v-model="dietForm.lunch_images" :limit="3" type="image" />
|
|
</el-form-item>
|
|
<el-divider content-position="left">晚餐</el-divider>
|
|
<el-form-item label="食物描述">
|
|
<el-input v-model="dietForm.dinner_foods" type="textarea" :rows="2" placeholder="请输入晚餐食物" />
|
|
</el-form-item>
|
|
<el-form-item label="图片">
|
|
<material-picker v-model="dietForm.dinner_images" :limit="3" type="image" />
|
|
</el-form-item>
|
|
<el-form-item label="备注">
|
|
<el-input v-model="dietForm.note" type="textarea" :rows="3" placeholder="请输入备注" />
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="dietDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="dietSubmitting" :disabled="!canEditDailyRecord" @click="submitDiet">确定</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="exerciseDialogVisible" :title="exerciseDialogTitle" width="600px" :close-on-click-modal="false">
|
|
<el-form :model="exerciseForm" label-width="120px">
|
|
<el-form-item label="记录日期" required>
|
|
<el-date-picker
|
|
v-model="exerciseForm.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="exerciseForm.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="exerciseForm.duration" :min="0" :max="300" />
|
|
</el-form-item>
|
|
<el-form-item label="运动强度" required>
|
|
<el-select v-model="exerciseForm.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="exerciseForm.images" :limit="3" type="image" />
|
|
</el-form-item>
|
|
<el-form-item label="备注">
|
|
<el-input v-model="exerciseForm.note" type="textarea" :rows="3" />
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="exerciseDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="exerciseSubmitting" :disabled="!canEditDailyRecord" @click="submitExercise">确定</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="trackingDialogVisible" :title="trackingDialogTitle" width="600px" :close-on-click-modal="false">
|
|
<el-form :model="trackingForm" label-width="100px">
|
|
<el-form-item label="记录日期" required>
|
|
<el-date-picker
|
|
v-model="trackingForm.note_date"
|
|
type="date"
|
|
placeholder="选择日期"
|
|
value-format="YYYY-MM-DD"
|
|
class="w-full"
|
|
:disabled="true"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item v-if="trackingForm.existing_content" label="已有备注">
|
|
<div class="daily-matrix__tracking-existing">
|
|
<div
|
|
v-for="(line, index) in splitTrackingLines(trackingForm.existing_content)"
|
|
:key="`${trackingForm.note_date}-${index}`"
|
|
class="daily-matrix__tracking-line"
|
|
>
|
|
{{ line }}
|
|
</div>
|
|
</div>
|
|
</el-form-item>
|
|
<el-form-item :label="trackingCanSubmit ? '追加备注' : '备注内容'" required>
|
|
<el-input
|
|
v-model="trackingForm.content"
|
|
type="textarea"
|
|
:rows="5"
|
|
maxlength="1000"
|
|
show-word-limit
|
|
:disabled="!trackingCanSubmit"
|
|
:placeholder="trackingCanSubmit ? '请输入跟踪备注' : '历史跟踪备注仅支持查看'"
|
|
/>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="trackingDialogVisible = false">取消</el-button>
|
|
<el-button v-if="trackingCanSubmit" type="primary" :loading="trackingSubmitting" @click="submitTracking">确定</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import vCharts from 'vue-echarts'
|
|
import DiagnosisTodoList from './DiagnosisTodoList.vue'
|
|
import { hasPermission } from '@/utils/perm'
|
|
import {
|
|
bloodRecordAdd,
|
|
bloodRecordEdit,
|
|
diagnosisAddTrackingNote,
|
|
diagnosisTrackingWindow,
|
|
diagnosisTrackingNotes,
|
|
dietRecordAdd,
|
|
dietRecordEdit,
|
|
exerciseRecordAdd,
|
|
exerciseRecordEdit
|
|
} from '@/api/tcm'
|
|
import {
|
|
formatBp,
|
|
isHighBloodPressure,
|
|
isHighFastingBloodSugar,
|
|
isHighOtherBloodSugar,
|
|
isHighPostprandialBloodSugar
|
|
} from '@/utils/blood-thresholds'
|
|
import feedback from '@/utils/feedback'
|
|
|
|
interface Props {
|
|
diagnosisId: number
|
|
patientId: number
|
|
age?: number
|
|
readOnly?: boolean
|
|
patientName?: string
|
|
}
|
|
|
|
interface BloodRecord {
|
|
id?: number | string
|
|
diagnosis_id?: number
|
|
patient_id?: number
|
|
record_date?: string
|
|
record_time?: string
|
|
record_date_ts?: number
|
|
fasting_blood_sugar?: any
|
|
postprandial_blood_sugar?: any
|
|
other_blood_sugar?: any
|
|
systolic_pressure?: any
|
|
diastolic_pressure?: any
|
|
western_medicine?: string
|
|
insulin?: string
|
|
remark?: string
|
|
[key: string]: any
|
|
}
|
|
|
|
interface DietRecord {
|
|
id?: number | string
|
|
diagnosis_id?: number
|
|
patient_id?: number
|
|
record_date?: string
|
|
breakfast?: string
|
|
breakfast_foods?: string
|
|
breakfast_images?: string[]
|
|
lunch?: string
|
|
lunch_foods?: string
|
|
lunch_images?: string[]
|
|
dinner?: string
|
|
dinner_foods?: string
|
|
dinner_images?: string[]
|
|
note?: string
|
|
[key: string]: any
|
|
}
|
|
|
|
interface ExerciseRecord {
|
|
id?: number | string
|
|
diagnosis_id?: number
|
|
patient_id?: number
|
|
record_date?: string
|
|
exercise_type?: string
|
|
duration?: number
|
|
intensity?: number
|
|
intensity_text?: string
|
|
images?: string[]
|
|
note?: string
|
|
[key: string]: any
|
|
}
|
|
|
|
interface TrackingNoteRecord {
|
|
id?: number | string
|
|
note_date?: string
|
|
content?: string | null
|
|
}
|
|
|
|
type RangeMode = '7' | '30' | 'custom'
|
|
type DialogKind = 'blood' | 'diet' | 'exercise' | 'tracking'
|
|
const DAILY_RECORD_PERMS = ['tcm.diagnosis/dailyRecord']
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
age: undefined,
|
|
readOnly: false,
|
|
patientName: ''
|
|
})
|
|
|
|
const diagnosisId = computed(() => Number(props.diagnosisId || 0))
|
|
const patientId = computed(() => Number(props.patientId || 0))
|
|
const readOnly = computed(() => !!props.readOnly)
|
|
const patientName = computed(() => props.patientName || '')
|
|
const hasDailyRecordPermission = computed(() => hasPermission(DAILY_RECORD_PERMS))
|
|
const canEditDailyRecord = computed(() => hasDailyRecordPermission.value && !readOnly.value)
|
|
const canViewTodoList = computed(() => hasDailyRecordPermission.value)
|
|
|
|
const loading = ref(false)
|
|
const rangeMode = ref<RangeMode>('7')
|
|
const customRange = ref<[string, string] | null>(null)
|
|
const bloodRecords = ref<BloodRecord[]>([])
|
|
const dietRecords = ref<DietRecord[]>([])
|
|
const exerciseRecords = ref<ExerciseRecord[]>([])
|
|
const trackingNotes = ref<TrackingNoteRecord[]>([])
|
|
|
|
const bloodDialogVisible = ref(false)
|
|
const dietDialogVisible = ref(false)
|
|
const exerciseDialogVisible = ref(false)
|
|
const trackingDialogVisible = ref(false)
|
|
const bloodSubmitting = ref(false)
|
|
const dietSubmitting = ref(false)
|
|
const exerciseSubmitting = ref(false)
|
|
const trackingSubmitting = ref(false)
|
|
|
|
const bloodForm = ref<BloodRecord>(createBloodForm())
|
|
const dietForm = ref<DietRecord>(createDietForm())
|
|
const exerciseForm = ref<ExerciseRecord>(createExerciseForm())
|
|
const trackingForm = ref({
|
|
note_date: '',
|
|
existing_content: '',
|
|
content: ''
|
|
})
|
|
|
|
const bloodDialogTitle = computed(() => (bloodForm.value.id ? '编辑血糖血压记录' : '新增血糖血压记录'))
|
|
const dietDialogTitle = computed(() => (dietForm.value.id ? '编辑饮食记录' : '新增饮食记录'))
|
|
const exerciseDialogTitle = computed(() => (exerciseForm.value.id ? '编辑运动记录' : '新增运动记录'))
|
|
const todayDate = computed(() => formatDate(new Date()))
|
|
const trackingCanSubmit = computed(() => !readOnly.value && trackingForm.value.note_date === todayDate.value)
|
|
const trackingDialogTitle = computed(() => (trackingCanSubmit.value ? '新增/追加跟踪备注' : '查看跟踪备注'))
|
|
const tableWrapRef = ref<HTMLElement | null>(null)
|
|
const tableWrapWidth = ref(0)
|
|
|
|
const metricRows = [
|
|
{ key: 'fasting', label: '空腹血糖' },
|
|
{ key: 'postprandial', label: '餐后2h血糖' },
|
|
{ key: 'other', label: '其他血糖' },
|
|
{ key: 'bp', label: '血压' },
|
|
{ key: 'western', label: '西药' },
|
|
{ key: 'insulin', label: '胰岛素' },
|
|
{ key: 'breakfast', label: '早餐' },
|
|
{ key: 'lunch', label: '午餐' },
|
|
{ key: 'dinner', label: '晚餐' },
|
|
{ key: 'exercise', label: '运动' },
|
|
{ key: 'tracking', label: '跟踪备注' }
|
|
] as const
|
|
|
|
const matrixRows = computed(() => [...metricRows])
|
|
const fixedLabelWidth = 120
|
|
const dateColumnMinWidth = computed(() => {
|
|
const count = visibleDates.value.length
|
|
if (count <= 0) return 100
|
|
if (count > 7) return 100
|
|
const usableWidth = Math.max(tableWrapWidth.value - fixedLabelWidth - 2, 0)
|
|
const dynamicWidth = usableWidth > 0 ? Math.floor(usableWidth / count) : 100
|
|
return Math.max(100, dynamicWidth)
|
|
})
|
|
|
|
let requestSeq = 0
|
|
|
|
function formatDate(date: Date): string {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
function parseDate(date: string): Date {
|
|
return new Date(`${date}T00:00:00`)
|
|
}
|
|
|
|
function updateTableWrapWidth() {
|
|
tableWrapWidth.value = tableWrapRef.value?.clientWidth || 0
|
|
}
|
|
|
|
function createBloodForm(date = ''): BloodRecord {
|
|
return {
|
|
id: '',
|
|
diagnosis_id: diagnosisId.value,
|
|
patient_id: patientId.value,
|
|
record_date: date,
|
|
record_time: '',
|
|
fasting_blood_sugar: 0,
|
|
postprandial_blood_sugar: 0,
|
|
other_blood_sugar: '',
|
|
systolic_pressure: 0,
|
|
diastolic_pressure: 0,
|
|
western_medicine: '',
|
|
insulin: '',
|
|
remark: ''
|
|
}
|
|
}
|
|
|
|
function createDietForm(date = ''): DietRecord {
|
|
return {
|
|
id: '',
|
|
diagnosis_id: diagnosisId.value,
|
|
patient_id: patientId.value,
|
|
record_date: date,
|
|
breakfast_foods: '',
|
|
breakfast_images: [],
|
|
lunch_foods: '',
|
|
lunch_images: [],
|
|
dinner_foods: '',
|
|
dinner_images: [],
|
|
note: ''
|
|
}
|
|
}
|
|
|
|
function createExerciseForm(date = ''): ExerciseRecord {
|
|
return {
|
|
id: '',
|
|
diagnosis_id: diagnosisId.value,
|
|
patient_id: patientId.value,
|
|
record_date: date,
|
|
exercise_type: '',
|
|
duration: 0,
|
|
intensity: 1,
|
|
images: [],
|
|
note: ''
|
|
}
|
|
}
|
|
|
|
function disabledFutureDate(date: Date) {
|
|
return date.getTime() > Date.now()
|
|
}
|
|
|
|
function computeRange(mode: RangeMode): { start: string; end: string } {
|
|
if (mode === 'custom') {
|
|
const [start, end] = customRange.value || ['', '']
|
|
return { start: start || '', end: end || '' }
|
|
}
|
|
const days = mode === '7' ? 7 : 30
|
|
const end = new Date()
|
|
end.setHours(0, 0, 0, 0)
|
|
const start = new Date(end)
|
|
start.setDate(start.getDate() - (days - 1))
|
|
return { start: formatDate(start), end: formatDate(end) }
|
|
}
|
|
|
|
const currentRange = computed(() => computeRange(rangeMode.value))
|
|
|
|
const visibleDates = computed(() => {
|
|
const { start, end } = currentRange.value
|
|
if (!start || !end) return []
|
|
const dates: string[] = []
|
|
const cursor = parseDate(end)
|
|
const lower = parseDate(start).getTime()
|
|
while (cursor.getTime() >= lower) {
|
|
dates.push(formatDate(cursor))
|
|
cursor.setDate(cursor.getDate() - 1)
|
|
}
|
|
return dates
|
|
})
|
|
|
|
const bloodByDate = computed(() => {
|
|
const map = new Map<string, BloodRecord>()
|
|
for (const record of bloodRecords.value) {
|
|
const date = String(record.record_date || '')
|
|
if (!date) continue
|
|
if (!map.has(date)) {
|
|
map.set(date, {
|
|
id: record.id,
|
|
diagnosis_id: record.diagnosis_id,
|
|
patient_id: record.patient_id,
|
|
record_date: date,
|
|
record_time: record.record_time || '',
|
|
record_date_ts: record.record_date_ts
|
|
})
|
|
}
|
|
const merged = map.get(date)!
|
|
const fields: Array<keyof BloodRecord> = [
|
|
'fasting_blood_sugar',
|
|
'postprandial_blood_sugar',
|
|
'other_blood_sugar',
|
|
'systolic_pressure',
|
|
'diastolic_pressure',
|
|
'western_medicine',
|
|
'insulin',
|
|
'remark'
|
|
]
|
|
for (const field of fields) {
|
|
if (
|
|
(merged[field] === undefined || merged[field] === null || merged[field] === '') &&
|
|
record[field] !== undefined &&
|
|
record[field] !== null &&
|
|
record[field] !== ''
|
|
) {
|
|
;(merged as any)[field] = record[field]
|
|
}
|
|
}
|
|
}
|
|
return map
|
|
})
|
|
|
|
const dietByDate = computed(() => {
|
|
const map = new Map<string, DietRecord>()
|
|
for (const record of dietRecords.value) {
|
|
const date = String(record.record_date || '')
|
|
if (!date || map.has(date)) continue
|
|
map.set(date, record)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const exerciseByDate = computed(() => {
|
|
const map = new Map<string, ExerciseRecord>()
|
|
for (const record of exerciseRecords.value) {
|
|
const date = String(record.record_date || '')
|
|
if (!date || map.has(date)) continue
|
|
map.set(date, record)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const trackingByDate = computed(() => {
|
|
const map = new Map<string, TrackingNoteRecord>()
|
|
for (const record of trackingNotes.value) {
|
|
const date = String(record.note_date || '')
|
|
if (!date || map.has(date)) continue
|
|
map.set(date, record)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const chartDates = computed(() => [...visibleDates.value].reverse())
|
|
|
|
const bloodTrendSeries = computed(() => {
|
|
return chartDates.value.reduce(
|
|
(acc, date) => {
|
|
const blood = bloodByDate.value.get(date)
|
|
acc.fasting.push(parseTrendNumber(blood?.fasting_blood_sugar))
|
|
acc.postprandial.push(parseTrendNumber(blood?.postprandial_blood_sugar))
|
|
return acc
|
|
},
|
|
{
|
|
fasting: [] as Array<number | null>,
|
|
postprandial: [] as Array<number | null>
|
|
}
|
|
)
|
|
})
|
|
|
|
const bloodTrendHasData = computed(() => {
|
|
return [...bloodTrendSeries.value.fasting, ...bloodTrendSeries.value.postprandial].some((value) => value != null)
|
|
})
|
|
|
|
const bloodTrendOption = computed(() => ({
|
|
tooltip: {
|
|
trigger: 'axis'
|
|
},
|
|
legend: {
|
|
top: 0,
|
|
data: ['空腹血糖', '餐后血糖']
|
|
},
|
|
grid: {
|
|
left: 24,
|
|
right: 24,
|
|
top: 52,
|
|
bottom: 20,
|
|
containLabel: true
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
boundaryGap: false,
|
|
data: chartDates.value.map((date) => formatColumnDate(date))
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
name: 'mmol/L',
|
|
splitLine: {
|
|
lineStyle: {
|
|
color: '#e5e7eb'
|
|
}
|
|
}
|
|
},
|
|
series: [
|
|
{
|
|
name: '空腹血糖',
|
|
type: 'line',
|
|
smooth: true,
|
|
connectNulls: true,
|
|
data: bloodTrendSeries.value.fasting,
|
|
symbolSize: 7,
|
|
itemStyle: {
|
|
color: '#2563eb'
|
|
},
|
|
lineStyle: {
|
|
width: 3,
|
|
color: '#2563eb'
|
|
}
|
|
},
|
|
{
|
|
name: '餐后血糖',
|
|
type: 'line',
|
|
smooth: true,
|
|
connectNulls: true,
|
|
data: bloodTrendSeries.value.postprandial,
|
|
symbolSize: 7,
|
|
itemStyle: {
|
|
color: '#f97316'
|
|
},
|
|
lineStyle: {
|
|
width: 3,
|
|
color: '#f97316'
|
|
}
|
|
}
|
|
]
|
|
}))
|
|
|
|
function formatColumnDate(date: string) {
|
|
return date.slice(5)
|
|
}
|
|
|
|
function parseTrendNumber(value: unknown): number | null {
|
|
if (value === '' || value == null) return null
|
|
const parsed = Number(value)
|
|
if (!Number.isFinite(parsed) || parsed === 0) return null
|
|
return parsed
|
|
}
|
|
|
|
function getCell(metric: string, date: string): { value: string; isHigh: boolean; hasRecord: boolean } {
|
|
const blood = bloodByDate.value.get(date)
|
|
const diet = dietByDate.value.get(date)
|
|
const exercise = exerciseByDate.value.get(date)
|
|
const tracking = trackingByDate.value.get(date)
|
|
switch (metric) {
|
|
case 'fasting':
|
|
return {
|
|
value: blood?.fasting_blood_sugar != null && blood.fasting_blood_sugar !== '' ? String(blood.fasting_blood_sugar) : '',
|
|
isHigh: isHighFastingBloodSugar(blood?.fasting_blood_sugar, props.age),
|
|
hasRecord: !!blood && blood.fasting_blood_sugar != null && blood.fasting_blood_sugar !== ''
|
|
}
|
|
case 'postprandial':
|
|
return {
|
|
value: blood?.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== '' ? String(blood.postprandial_blood_sugar) : '',
|
|
isHigh: isHighPostprandialBloodSugar(blood?.postprandial_blood_sugar, props.age),
|
|
hasRecord: !!blood && blood.postprandial_blood_sugar != null && blood.postprandial_blood_sugar !== ''
|
|
}
|
|
case 'other':
|
|
return {
|
|
value: blood?.other_blood_sugar != null && blood.other_blood_sugar !== '' ? String(blood.other_blood_sugar) : '',
|
|
isHigh: isHighOtherBloodSugar(blood?.other_blood_sugar, props.age),
|
|
hasRecord: !!blood && blood.other_blood_sugar != null && blood.other_blood_sugar !== ''
|
|
}
|
|
case 'bp':
|
|
return {
|
|
value: blood && (blood.systolic_pressure || blood.diastolic_pressure) ? formatBp(blood) : '',
|
|
isHigh: isHighBloodPressure(blood),
|
|
hasRecord: !!blood && !!(blood.systolic_pressure || blood.diastolic_pressure)
|
|
}
|
|
case 'western':
|
|
return {
|
|
value: blood?.western_medicine || '',
|
|
isHigh: false,
|
|
hasRecord: !!blood?.western_medicine
|
|
}
|
|
case 'insulin':
|
|
return {
|
|
value: blood?.insulin || '',
|
|
isHigh: false,
|
|
hasRecord: !!blood?.insulin
|
|
}
|
|
case 'breakfast':
|
|
return {
|
|
value: diet?.breakfast || diet?.breakfast_foods ? '✓' : '',
|
|
isHigh: false,
|
|
hasRecord: !!(diet?.breakfast || diet?.breakfast_foods)
|
|
}
|
|
case 'lunch':
|
|
return {
|
|
value: diet?.lunch || diet?.lunch_foods ? '✓' : '',
|
|
isHigh: false,
|
|
hasRecord: !!(diet?.lunch || diet?.lunch_foods)
|
|
}
|
|
case 'dinner':
|
|
return {
|
|
value: diet?.dinner || diet?.dinner_foods ? '✓' : '',
|
|
isHigh: false,
|
|
hasRecord: !!(diet?.dinner || diet?.dinner_foods)
|
|
}
|
|
case 'exercise':
|
|
return {
|
|
value: exercise?.duration ? `${exercise.duration}min` : '',
|
|
isHigh: false,
|
|
hasRecord: !!exercise?.id
|
|
}
|
|
case 'tracking':
|
|
return {
|
|
value: tracking?.content ? summarizeTracking(tracking.content) : '',
|
|
isHigh: false,
|
|
hasRecord: !!tracking?.content
|
|
}
|
|
default:
|
|
return { value: '', isHigh: false, hasRecord: false }
|
|
}
|
|
}
|
|
|
|
async function fetchTracking() {
|
|
if (!diagnosisId.value) {
|
|
bloodRecords.value = []
|
|
dietRecords.value = []
|
|
exerciseRecords.value = []
|
|
trackingNotes.value = []
|
|
return
|
|
}
|
|
const { start, end } = currentRange.value
|
|
if (rangeMode.value === 'custom' && (!start || !end)) return
|
|
const seq = ++requestSeq
|
|
loading.value = true
|
|
try {
|
|
const data: any = await diagnosisTrackingWindow({
|
|
id: diagnosisId.value,
|
|
start_date: start,
|
|
end_date: end
|
|
})
|
|
const noteData: any = await diagnosisTrackingNotes({
|
|
diagnosis_id: diagnosisId.value
|
|
})
|
|
if (seq !== requestSeq) return
|
|
bloodRecords.value = Array.isArray(data?.blood_records) ? data.blood_records : []
|
|
dietRecords.value = Array.isArray(data?.diet_records) ? data.diet_records : []
|
|
exerciseRecords.value = Array.isArray(data?.exercise_records) ? data.exercise_records : []
|
|
trackingNotes.value = Array.isArray(noteData)
|
|
? noteData.filter((item: any) => visibleDates.value.includes(String(item?.note_date || '')))
|
|
: []
|
|
} catch {
|
|
if (seq === requestSeq) {
|
|
bloodRecords.value = []
|
|
dietRecords.value = []
|
|
exerciseRecords.value = []
|
|
trackingNotes.value = []
|
|
}
|
|
} finally {
|
|
if (seq === requestSeq) loading.value = false
|
|
}
|
|
}
|
|
|
|
function handleRangeModeChange() {
|
|
if (rangeMode.value === 'custom' && !customRange.value) {
|
|
const fallback = computeRange('7')
|
|
customRange.value = [fallback.start, fallback.end]
|
|
}
|
|
fetchTracking()
|
|
void nextTick(updateTableWrapWidth)
|
|
}
|
|
|
|
function handleCustomRangeChange() {
|
|
if (rangeMode.value === 'custom') {
|
|
fetchTracking()
|
|
}
|
|
}
|
|
|
|
function openQuickAdd(kind: DialogKind) {
|
|
if (!canEditDailyRecord.value) return
|
|
const date = visibleDates.value[0] || formatDate(new Date())
|
|
if (kind === 'blood') {
|
|
bloodForm.value = createBloodForm(date)
|
|
bloodDialogVisible.value = true
|
|
return
|
|
}
|
|
if (kind === 'diet') {
|
|
dietForm.value = createDietForm(date)
|
|
dietDialogVisible.value = true
|
|
return
|
|
}
|
|
if (kind === 'tracking') {
|
|
const today = todayDate.value
|
|
const source = trackingByDate.value.get(today)
|
|
trackingForm.value = {
|
|
note_date: today,
|
|
existing_content: String(source?.content || ''),
|
|
content: ''
|
|
}
|
|
trackingDialogVisible.value = true
|
|
return
|
|
}
|
|
exerciseForm.value = createExerciseForm(date)
|
|
exerciseDialogVisible.value = true
|
|
}
|
|
|
|
function handleCellClick(metric: string, date: string) {
|
|
if (!canEditDailyRecord.value) return
|
|
if (['fasting', 'postprandial', 'other', 'bp', 'western', 'insulin'].includes(metric)) {
|
|
const source = bloodByDate.value.get(date)
|
|
bloodForm.value = source ? { ...createBloodForm(date), ...source } : createBloodForm(date)
|
|
bloodDialogVisible.value = true
|
|
return
|
|
}
|
|
if (['breakfast', 'lunch', 'dinner'].includes(metric)) {
|
|
const source = dietByDate.value.get(date)
|
|
dietForm.value = source ? { ...createDietForm(date), ...source } : createDietForm(date)
|
|
dietDialogVisible.value = true
|
|
return
|
|
}
|
|
if (metric === 'tracking') {
|
|
const source = trackingByDate.value.get(date)
|
|
if (!source && date !== todayDate.value) return
|
|
trackingForm.value = {
|
|
note_date: date,
|
|
existing_content: String(source?.content || ''),
|
|
content: ''
|
|
}
|
|
trackingDialogVisible.value = true
|
|
return
|
|
}
|
|
const source = exerciseByDate.value.get(date)
|
|
exerciseForm.value = source ? { ...createExerciseForm(date), ...source } : createExerciseForm(date)
|
|
exerciseDialogVisible.value = true
|
|
}
|
|
|
|
async function submitBlood() {
|
|
if (!bloodForm.value.record_date) {
|
|
feedback.msgWarning('请选择记录日期')
|
|
return
|
|
}
|
|
bloodSubmitting.value = true
|
|
try {
|
|
if (bloodForm.value.id) {
|
|
await bloodRecordEdit(bloodForm.value)
|
|
} else {
|
|
await bloodRecordAdd(bloodForm.value)
|
|
}
|
|
bloodDialogVisible.value = false
|
|
await fetchTracking()
|
|
} finally {
|
|
bloodSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
async function submitDiet() {
|
|
if (!dietForm.value.record_date) {
|
|
feedback.msgWarning('请选择记录日期')
|
|
return
|
|
}
|
|
dietSubmitting.value = true
|
|
try {
|
|
if (dietForm.value.id) {
|
|
await dietRecordEdit(dietForm.value)
|
|
} else {
|
|
await dietRecordAdd(dietForm.value)
|
|
}
|
|
dietDialogVisible.value = false
|
|
await fetchTracking()
|
|
} finally {
|
|
dietSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
async function submitExercise() {
|
|
if (!exerciseForm.value.record_date) {
|
|
feedback.msgWarning('请选择记录日期')
|
|
return
|
|
}
|
|
if (!exerciseForm.value.exercise_type) {
|
|
feedback.msgWarning('请选择运动类型')
|
|
return
|
|
}
|
|
if (!exerciseForm.value.duration) {
|
|
feedback.msgWarning('请输入运动时长')
|
|
return
|
|
}
|
|
exerciseSubmitting.value = true
|
|
try {
|
|
if (exerciseForm.value.id) {
|
|
await exerciseRecordEdit(exerciseForm.value)
|
|
} else {
|
|
await exerciseRecordAdd(exerciseForm.value)
|
|
}
|
|
exerciseDialogVisible.value = false
|
|
await fetchTracking()
|
|
} finally {
|
|
exerciseSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
async function submitTracking() {
|
|
if (!trackingForm.value.note_date) {
|
|
feedback.msgWarning('请选择记录日期')
|
|
return
|
|
}
|
|
if (!trackingCanSubmit.value) {
|
|
feedback.msgWarning('仅支持给今天追加跟踪备注')
|
|
return
|
|
}
|
|
const content = trackingForm.value.content.trim()
|
|
if (!content) {
|
|
feedback.msgWarning('请输入跟踪备注')
|
|
return
|
|
}
|
|
trackingSubmitting.value = true
|
|
try {
|
|
await diagnosisAddTrackingNote({
|
|
diagnosis_id: diagnosisId.value,
|
|
tracking_content: content
|
|
})
|
|
trackingDialogVisible.value = false
|
|
await fetchTracking()
|
|
} finally {
|
|
trackingSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
function summarizeTracking(content: string) {
|
|
return splitTrackingLines(content)[0] || ''
|
|
}
|
|
|
|
function splitTrackingLines(content: string) {
|
|
return content.split('\n').map((line) => line.trim()).filter(Boolean)
|
|
}
|
|
|
|
function getTrackingContent(date: string) {
|
|
return String(trackingByDate.value.get(date)?.content || '')
|
|
}
|
|
|
|
function getTrackingPreview(content: string) {
|
|
return summarizeTracking(content)
|
|
}
|
|
|
|
function isCellClickable(metric: string, date: string) {
|
|
if (!canEditDailyRecord.value) return false
|
|
if (metric !== 'tracking') return true
|
|
return date === todayDate.value || !!getTrackingContent(date)
|
|
}
|
|
|
|
watch(
|
|
() => props.diagnosisId,
|
|
(value) => {
|
|
if (value) {
|
|
fetchTracking()
|
|
}
|
|
void nextTick(updateTableWrapWidth)
|
|
}
|
|
)
|
|
|
|
onMounted(() => {
|
|
if (props.diagnosisId) {
|
|
fetchTracking()
|
|
}
|
|
void nextTick(updateTableWrapWidth)
|
|
window.addEventListener('resize', updateTableWrapWidth)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', updateTableWrapWidth)
|
|
})
|
|
|
|
defineExpose({ refresh: fetchTracking })
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.daily-matrix {
|
|
padding: 16px;
|
|
|
|
&__toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
&__toolbar-left,
|
|
&__toolbar-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
&__table {
|
|
width: 100%;
|
|
}
|
|
|
|
&__table-wrap {
|
|
width: 100%;
|
|
}
|
|
|
|
&__chart {
|
|
margin-top: 16px;
|
|
padding: 16px 18px;
|
|
border: 1px solid var(--el-border-color-lighter);
|
|
border-radius: 10px;
|
|
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
|
}
|
|
|
|
&__chart-canvas {
|
|
height: 280px;
|
|
width: 100%;
|
|
}
|
|
|
|
&__cell {
|
|
min-height: 32px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 2px;
|
|
width: 100%;
|
|
color: var(--el-text-color-regular);
|
|
|
|
&.is-clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
&.is-empty {
|
|
color: var(--el-text-color-placeholder);
|
|
}
|
|
|
|
&.is-high {
|
|
color: #dc2626;
|
|
font-weight: 700;
|
|
}
|
|
}
|
|
|
|
&__cell-up {
|
|
color: #dc2626;
|
|
font-size: 13px;
|
|
}
|
|
|
|
&__todo {
|
|
margin-top: 16px;
|
|
}
|
|
|
|
&__section-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
margin-bottom: 12px;
|
|
color: var(--el-text-color-primary);
|
|
}
|
|
|
|
&__tracking-existing {
|
|
width: 100%;
|
|
max-height: 180px;
|
|
overflow: auto;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--el-border-color);
|
|
border-radius: 6px;
|
|
background: var(--el-fill-color-light);
|
|
}
|
|
|
|
&__tracking-line {
|
|
font-size: 12.5px;
|
|
line-height: 1.6;
|
|
color: var(--el-text-color-regular);
|
|
word-break: break-word;
|
|
}
|
|
|
|
&__tracking-preview {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 12px;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
|
|
&__tracking-tooltip {
|
|
max-width: 320px;
|
|
}
|
|
|
|
&__tracking-tooltip-line {
|
|
font-size: 12.5px;
|
|
line-height: 1.6;
|
|
word-break: break-word;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.daily-matrix {
|
|
padding: 12px;
|
|
|
|
&__chart {
|
|
padding: 12px;
|
|
}
|
|
|
|
&__chart-canvas {
|
|
height: 240px;
|
|
}
|
|
}
|
|
}
|
|
</style>
|