Files
zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue
T
long 4941ea3e21 Update DailyMatrix.vue
更新血糖折线图显示
2026-05-14 15:54:30 +08:00

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>