483 lines
15 KiB
Vue
483 lines
15 KiB
Vue
<template>
|
||
<div class="tracking-matrix" v-loading="loading">
|
||
<div class="toolbar">
|
||
<el-radio-group v-model="rangeMode" @change="onRangeModeChange">
|
||
<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"
|
||
size="default"
|
||
:disabled-date="disabledFutureDate"
|
||
@change="onCustomRangeChange"
|
||
/>
|
||
|
||
<span class="hint">
|
||
超阈值红字↑(年龄分段:<50:空7/餐9,≥50:空8/餐10)
|
||
</span>
|
||
</div>
|
||
|
||
<el-empty
|
||
v-if="!loading && visibleDates.length === 0"
|
||
description="所选区间内无任何打卡记录"
|
||
:image-size="80"
|
||
/>
|
||
|
||
<el-table
|
||
v-else
|
||
:data="rows"
|
||
border
|
||
stripe
|
||
class="matrix-table"
|
||
empty-text="暂无数据"
|
||
row-key="date"
|
||
max-height="640"
|
||
>
|
||
<el-table-column prop="dateLabel" label="日期" width="120" fixed="left" />
|
||
<el-table-column label="血糖 (mmol/L)" min-width="180">
|
||
<template #default="{ row }">
|
||
<div v-if="row.bloodAny" class="cell-blood">
|
||
<span :class="{ 'cell-high': row.fastingHigh }">
|
||
空
|
||
<span class="value">{{ row.fasting ?? '—' }}</span>
|
||
<span v-if="row.fastingHigh" class="cell-up">↑</span>
|
||
</span>
|
||
<span :class="{ 'cell-high': row.postprandialHigh }">
|
||
餐
|
||
<span class="value">{{ row.postprandial ?? '—' }}</span>
|
||
<span v-if="row.postprandialHigh" class="cell-up">↑</span>
|
||
</span>
|
||
<span :class="{ 'cell-high': row.otherHigh }">
|
||
其他
|
||
<span class="value">{{ row.other ?? '—' }}</span>
|
||
<span v-if="row.otherHigh" class="cell-up">↑</span>
|
||
</span>
|
||
</div>
|
||
<span v-else class="cell-empty">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="血压 (mmHg)" width="120" align="center">
|
||
<template #default="{ row }">
|
||
<span v-if="row.bp" :class="{ 'cell-high': row.bpHigh }">
|
||
{{ row.bp }}
|
||
<span v-if="row.bpHigh" class="cell-up">↑</span>
|
||
</span>
|
||
<span v-else class="cell-empty">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="西药" min-width="120" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span v-if="row.westernMedicine">{{ row.westernMedicine }}</span>
|
||
<span v-else class="cell-empty">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="胰岛素" min-width="120" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<span v-if="row.insulin">{{ row.insulin }}</span>
|
||
<span v-else class="cell-empty">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="饮食打卡" width="110" align="center">
|
||
<template #default="{ row }">
|
||
<el-tooltip
|
||
v-if="row.diet"
|
||
placement="top"
|
||
:disabled="!row.diet"
|
||
effect="light"
|
||
>
|
||
<template #content>
|
||
<div class="diet-tip">
|
||
<div>早:{{ row.diet.breakfast || '—' }}</div>
|
||
<div>午:{{ row.diet.lunch || '—' }}</div>
|
||
<div>晚:{{ row.diet.dinner || '—' }}</div>
|
||
</div>
|
||
</template>
|
||
<span class="cell-checked">已打卡</span>
|
||
</el-tooltip>
|
||
<span v-else class="cell-empty">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="运动打卡" min-width="160" align="center">
|
||
<template #default="{ row }">
|
||
<el-tooltip v-if="row.exercise" placement="top" effect="light">
|
||
<template #content>
|
||
<div class="exercise-tip">
|
||
<div>强度:{{ row.exercise.intensityText || '—' }}</div>
|
||
<div>时长:{{ row.exercise.duration ? row.exercise.duration + ' min' : '—' }}</div>
|
||
<div v-if="row.exercise.remark">备注:{{ row.exercise.remark }}</div>
|
||
</div>
|
||
</template>
|
||
<span class="cell-checked">
|
||
已打卡
|
||
<span v-if="row.exercise.duration" class="meta">· {{ row.exercise.duration }}min</span>
|
||
<span v-if="row.exercise.intensityText" class="meta">· {{ row.exercise.intensityText }}</span>
|
||
</span>
|
||
</el-tooltip>
|
||
<span v-else class="cell-empty">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import {
|
||
isHighFastingBloodSugar,
|
||
isHighPostprandialBloodSugar,
|
||
isHighOtherBloodSugar,
|
||
isHighBloodPressure,
|
||
formatBp
|
||
} from '@/utils/blood-thresholds'
|
||
import { diagnosisTrackingWindow } from '@/api/tcm'
|
||
|
||
interface BloodRecord {
|
||
record_date?: 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
|
||
[key: string]: any
|
||
}
|
||
|
||
interface DietRecord {
|
||
record_date?: string
|
||
breakfast?: string
|
||
lunch?: string
|
||
dinner?: string
|
||
[key: string]: any
|
||
}
|
||
|
||
interface ExerciseRecord {
|
||
record_date?: string
|
||
duration?: number
|
||
intensity?: number
|
||
intensity_text?: string
|
||
remark?: string
|
||
[key: string]: any
|
||
}
|
||
|
||
interface Props {
|
||
diagnosisId: number
|
||
age?: number | null
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
age: null
|
||
})
|
||
|
||
type RangeMode = '7' | '30' | 'custom'
|
||
const rangeMode = ref<RangeMode>('30')
|
||
const customRange = ref<[string, string] | null>(null)
|
||
|
||
const loading = ref(false)
|
||
const bloodRecords = ref<BloodRecord[]>([])
|
||
const dietRecords = ref<DietRecord[]>([])
|
||
const exerciseRecords = ref<ExerciseRecord[]>([])
|
||
|
||
let requestSeq = 0
|
||
|
||
/** 把今天本地时间格式成 Y-m-d,避免 toISOString 的时区漂移 */
|
||
const formatDate = (d: Date): string =>
|
||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
|
||
const computeRange = (mode: RangeMode): { start: string; end: string } => {
|
||
if (mode === 'custom') {
|
||
const [s, e] = customRange.value || ['', '']
|
||
return { start: s || '', end: e || '' }
|
||
}
|
||
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 disabledFutureDate = (date: Date) => date.getTime() > Date.now()
|
||
|
||
const fetchTracking = async () => {
|
||
if (!props.diagnosisId || props.diagnosisId <= 0) {
|
||
bloodRecords.value = []
|
||
dietRecords.value = []
|
||
exerciseRecords.value = []
|
||
return
|
||
}
|
||
const range = computeRange(rangeMode.value)
|
||
if (rangeMode.value === 'custom' && (!range.start || !range.end)) {
|
||
// 等待用户选完日期
|
||
return
|
||
}
|
||
|
||
const seq = ++requestSeq
|
||
loading.value = true
|
||
try {
|
||
const res: any = await diagnosisTrackingWindow({
|
||
id: props.diagnosisId,
|
||
start_date: range.start,
|
||
end_date: range.end
|
||
})
|
||
if (seq !== requestSeq) return
|
||
bloodRecords.value = res?.blood_records || []
|
||
dietRecords.value = res?.diet_records || []
|
||
exerciseRecords.value = res?.exercise_records || []
|
||
} catch {
|
||
if (seq === requestSeq) {
|
||
bloodRecords.value = []
|
||
dietRecords.value = []
|
||
exerciseRecords.value = []
|
||
}
|
||
} finally {
|
||
if (seq === requestSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
const onRangeModeChange = () => {
|
||
if (rangeMode.value === 'custom') {
|
||
// 默认填入「最近 30 天」作为初值,让用户在此基础上调整
|
||
if (!customRange.value) {
|
||
const r = computeRange('30')
|
||
customRange.value = [r.start, r.end]
|
||
}
|
||
}
|
||
fetchTracking()
|
||
}
|
||
|
||
const onCustomRangeChange = () => {
|
||
if (rangeMode.value === 'custom') fetchTracking()
|
||
}
|
||
|
||
watch(
|
||
() => props.diagnosisId,
|
||
(id) => {
|
||
if (id && id > 0) fetchTracking()
|
||
}
|
||
)
|
||
|
||
onMounted(() => {
|
||
if (props.diagnosisId && props.diagnosisId > 0) fetchTracking()
|
||
})
|
||
|
||
/**
|
||
* 把同一天可能多条的血糖记录聚合:每段取第一条非空值(与 BloodRecordLogic 趋势图一致)
|
||
*/
|
||
const bloodByDate = computed(() => {
|
||
const map = new Map<string, BloodRecord>()
|
||
for (const r of bloodRecords.value) {
|
||
const d = String(r.record_date || '')
|
||
if (!d) continue
|
||
if (!map.has(d)) {
|
||
map.set(d, {
|
||
record_date: d,
|
||
record_date_ts: r.record_date_ts
|
||
})
|
||
}
|
||
const acc = map.get(d)!
|
||
const fields: Array<keyof BloodRecord> = [
|
||
'fasting_blood_sugar',
|
||
'postprandial_blood_sugar',
|
||
'other_blood_sugar',
|
||
'systolic_pressure',
|
||
'diastolic_pressure',
|
||
'western_medicine',
|
||
'insulin'
|
||
]
|
||
for (const f of fields) {
|
||
if (
|
||
(acc[f] === undefined || acc[f] === null || acc[f] === '') &&
|
||
r[f] !== undefined &&
|
||
r[f] !== null &&
|
||
r[f] !== ''
|
||
) {
|
||
;(acc as any)[f] = r[f]
|
||
}
|
||
}
|
||
}
|
||
return map
|
||
})
|
||
|
||
const dietByDate = computed(() => {
|
||
const map = new Map<string, DietRecord>()
|
||
for (const r of dietRecords.value) {
|
||
const d = String(r.record_date || '')
|
||
if (!d) continue
|
||
if (!map.has(d)) map.set(d, r)
|
||
}
|
||
return map
|
||
})
|
||
|
||
const exerciseByDate = computed(() => {
|
||
const map = new Map<string, ExerciseRecord>()
|
||
for (const r of exerciseRecords.value) {
|
||
const d = String(r.record_date || '')
|
||
if (!d) continue
|
||
if (!map.has(d)) map.set(d, r)
|
||
}
|
||
return map
|
||
})
|
||
|
||
/**
|
||
* 已经由后端按区间过滤,前端只需取三类记录的日期并集 + desc 排序
|
||
*/
|
||
const visibleDates = computed<string[]>(() => {
|
||
const set = new Set<string>()
|
||
for (const r of bloodRecords.value) if (r.record_date) set.add(String(r.record_date))
|
||
for (const r of dietRecords.value) if (r.record_date) set.add(String(r.record_date))
|
||
for (const r of exerciseRecords.value) if (r.record_date) set.add(String(r.record_date))
|
||
const list = Array.from(set).filter((d) => d.length === 10)
|
||
list.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
|
||
return list
|
||
})
|
||
|
||
|
||
const rows = computed(() =>
|
||
visibleDates.value.map((date) => {
|
||
const blood = bloodByDate.value.get(date)
|
||
const diet = dietByDate.value.get(date)
|
||
const exercise = exerciseByDate.value.get(date)
|
||
|
||
const fasting = blood?.fasting_blood_sugar ?? null
|
||
const postprandial = blood?.postprandial_blood_sugar ?? null
|
||
const other = blood?.other_blood_sugar ?? null
|
||
const sys = blood?.systolic_pressure ?? null
|
||
const dia = blood?.diastolic_pressure ?? null
|
||
|
||
const bloodAny =
|
||
(fasting !== null && fasting !== '') ||
|
||
(postprandial !== null && postprandial !== '') ||
|
||
(other !== null && other !== '')
|
||
|
||
const bp = sys || dia ? formatBp({ systolic_pressure: sys, diastolic_pressure: dia }) : ''
|
||
|
||
return {
|
||
date,
|
||
dateLabel: `${date}`,
|
||
// 血糖
|
||
bloodAny,
|
||
fasting: fasting ?? '—',
|
||
postprandial: postprandial ?? '—',
|
||
other: other ?? '—',
|
||
fastingHigh: isHighFastingBloodSugar(fasting, props.age),
|
||
postprandialHigh: isHighPostprandialBloodSugar(postprandial, props.age),
|
||
otherHigh: isHighOtherBloodSugar(other, props.age),
|
||
// 血压
|
||
bp,
|
||
bpHigh: isHighBloodPressure({ systolic_pressure: sys, diastolic_pressure: dia }),
|
||
// 用药
|
||
westernMedicine: blood?.western_medicine || '',
|
||
insulin: blood?.insulin || '',
|
||
// 饮食
|
||
diet: diet
|
||
? {
|
||
breakfast: diet.breakfast || '',
|
||
lunch: diet.lunch || '',
|
||
dinner: diet.dinner || ''
|
||
}
|
||
: null,
|
||
// 运动
|
||
exercise: exercise
|
||
? {
|
||
duration: exercise.duration || 0,
|
||
intensityText: exercise.intensity_text || '',
|
||
remark: exercise.remark || ''
|
||
}
|
||
: null
|
||
}
|
||
})
|
||
)
|
||
|
||
defineExpose({ refresh: fetchTracking })
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.tracking-matrix {
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
flex-wrap: wrap;
|
||
|
||
.hint {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
}
|
||
|
||
.matrix-table {
|
||
width: 100%;
|
||
}
|
||
|
||
.cell-blood {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
font-size: 12.5px;
|
||
line-height: 1.55;
|
||
|
||
> span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
|
||
.value {
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
}
|
||
|
||
.cell-high {
|
||
color: #dc2626;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.cell-up {
|
||
font-size: 13px;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.cell-empty {
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
|
||
.cell-checked {
|
||
color: #2563eb;
|
||
font-weight: 500;
|
||
|
||
.meta {
|
||
color: var(--el-text-color-secondary);
|
||
font-weight: 400;
|
||
margin-left: 2px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.diet-tip,
|
||
.exercise-tip {
|
||
font-size: 12.5px;
|
||
line-height: 1.55;
|
||
|
||
> div {
|
||
margin-bottom: 2px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
}
|
||
</style>
|