chore: 初始提交 - 移除所有硬编码凭据,统一从环境变量读取
This commit is contained in:
@@ -0,0 +1,980 @@
|
||||
const el = (id) => document.getElementById(id)
|
||||
|
||||
const fmtNum = (v, d = 1) => {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return '-'
|
||||
return Number(v).toFixed(d)
|
||||
}
|
||||
|
||||
const fmtDate = (s) => {
|
||||
if (!s) return '-'
|
||||
return String(s).slice(0, 10)
|
||||
}
|
||||
|
||||
const loadScript = (src) => new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = src
|
||||
s.async = true
|
||||
s.onload = () => resolve()
|
||||
s.onerror = () => reject()
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
|
||||
const ensureChart = async () => !!window.Chart
|
||||
|
||||
const state = {
|
||||
charts: {},
|
||||
lastData: null,
|
||||
activeTab: 'default'
|
||||
}
|
||||
|
||||
const setText = (id, value) => {
|
||||
const node = el(id)
|
||||
if (node) node.textContent = value
|
||||
}
|
||||
|
||||
const avgOf = (arr) => {
|
||||
const nums = (arr || []).filter(v => Number.isFinite(v))
|
||||
if (!nums.length) return null
|
||||
return nums.reduce((a, b) => a + b, 0) / nums.length
|
||||
}
|
||||
|
||||
const sumOf = (arr) => (arr || []).reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||
|
||||
const firstValue = (arr) => {
|
||||
for (let i = 0; i < arr.length; i += 1) {
|
||||
if (Number.isFinite(arr[i])) return arr[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getTrendData = (data) => ({
|
||||
days: Number(el('daysRange').value || 90),
|
||||
labels: data.trends.dates || [],
|
||||
hr: data.trends.hr_avg || [],
|
||||
resp: data.trends.resp_avg || [],
|
||||
weight: data.trends.weight || [],
|
||||
distance: data.trends.distance || [],
|
||||
sleep: data.trends.sleep_hours || [],
|
||||
stand: data.trends.stand_hours || [],
|
||||
workout: data.trends.workout_minutes || [],
|
||||
hrv: data.trends.hrv_sdnn || [],
|
||||
restingHr: data.trends.resting_hr || [],
|
||||
walkingHr: data.trends.walking_hr || [],
|
||||
sleepRem: data.trends.sleep_rem || [],
|
||||
sleepCore: data.trends.sleep_core || [],
|
||||
sleepDeep: data.trends.sleep_deep || [],
|
||||
sleepAwake: data.trends.sleep_awake || []
|
||||
})
|
||||
|
||||
const getTypeSummary = (data) => {
|
||||
const shortType = (t) => String(t || '').replace('HKQuantityTypeIdentifier', '').replace('HKCategoryTypeIdentifier', '')
|
||||
const types = data.compare.types || []
|
||||
return {
|
||||
typeLabels: types.map(t => shortType(t.type)),
|
||||
typeValues: types.map(t => t.record_count || 0)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSummary = (data) => {
|
||||
setText('statRecords', data.summary.total_records || 0)
|
||||
setText('statTypes', data.summary.type_count || 0)
|
||||
setText('statRoutes', data.summary.route_count || 0)
|
||||
setText('statEcg', data.summary.ecg_count || 0)
|
||||
const dateRange = data.summary.date_min ? `${fmtDate(data.summary.date_min)} ~ ${fmtDate(data.summary.date_max)}` : '-'
|
||||
setText('statDateRange', dateRange)
|
||||
setText('statLastImport', data.summary.last_import ? `最近同步 ${fmtDate(data.summary.last_import)}` : '-')
|
||||
setText('statAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||
setText('statAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||
setText('statDistance', fmtNum(data.cards.total_distance_km, 1))
|
||||
const weightUnit = data.latest.weight_unit || 'kg'
|
||||
const heightUnitRaw = data.latest.height_unit || 'cm'
|
||||
let heightUnit = heightUnitRaw
|
||||
let heightValue = data.latest.height_cm
|
||||
if (heightUnitRaw === 'm' && Number.isFinite(heightValue)) {
|
||||
heightValue = heightValue * 100
|
||||
heightUnit = 'cm'
|
||||
}
|
||||
setText('statWeight', fmtNum(data.latest.weight_kg, 1))
|
||||
setText('statHeight', fmtNum(heightValue, 1))
|
||||
setText('statWeightUnit', weightUnit)
|
||||
setText('statHeightUnit', heightUnit)
|
||||
setText('statSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||
setText('statStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||
setText('statWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||
}
|
||||
|
||||
const destroyCharts = () => {
|
||||
Object.values(state.charts).forEach(c => {
|
||||
if (c && typeof c.destroy === 'function') c.destroy()
|
||||
})
|
||||
state.charts = {}
|
||||
}
|
||||
|
||||
const drawSimpleLine = (canvas, labels, series, colors) => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.clientWidth || canvas.width
|
||||
const height = canvas.clientHeight || canvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
const allValues = series.flat().filter(v => Number.isFinite(v))
|
||||
if (!allValues.length) return false
|
||||
const min = Math.min(...allValues)
|
||||
const max = Math.max(...allValues)
|
||||
const pad = 24
|
||||
const xStep = (width - pad * 2) / Math.max(1, labels.length - 1)
|
||||
const scaleY = (v) => {
|
||||
if (max === min) return height / 2
|
||||
return height - pad - ((v - min) / (max - min)) * (height - pad * 2)
|
||||
}
|
||||
series.forEach((values, idx) => {
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = colors[idx] || '#38bdf8'
|
||||
ctx.lineWidth = 2
|
||||
values.forEach((v, i) => {
|
||||
if (!Number.isFinite(v)) return
|
||||
const x = pad + xStep * i
|
||||
const y = scaleY(v)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.stroke()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const drawSimpleBars = (canvas, labels, values) => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.clientWidth || canvas.width
|
||||
const height = canvas.clientHeight || canvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
const nums = values.filter(v => Number.isFinite(v))
|
||||
if (!nums.length) return false
|
||||
const max = Math.max(...nums, 1)
|
||||
const pad = 24
|
||||
const barWidth = (width - pad * 2) / Math.max(1, values.length)
|
||||
values.forEach((v, i) => {
|
||||
if (!Number.isFinite(v)) return
|
||||
const h = ((v / max) * (height - pad * 2))
|
||||
const x = pad + i * barWidth + barWidth * 0.2
|
||||
const y = height - pad - h
|
||||
ctx.fillStyle = '#60a5fa'
|
||||
ctx.fillRect(x, y, barWidth * 0.6, h)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const drawSimplePie = (canvas, values, colors) => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.clientWidth || canvas.width
|
||||
const height = canvas.clientHeight || canvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
const total = values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||
if (!total) return false
|
||||
let start = -Math.PI / 2
|
||||
values.forEach((v, i) => {
|
||||
const value = Number.isFinite(v) ? v : 0
|
||||
const angle = (value / total) * Math.PI * 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(width / 2, height / 2)
|
||||
ctx.fillStyle = colors[i % colors.length]
|
||||
ctx.arc(width / 2, height / 2, Math.min(width, height) / 2 - 6, start, start + angle)
|
||||
ctx.fill()
|
||||
start += angle
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const drawSimpleScatter = (canvas, points, color = '#38bdf8') => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const width = canvas.clientWidth || canvas.width
|
||||
const height = canvas.clientHeight || canvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
if (!points.length) return false
|
||||
const xs = points.map(p => p.x).filter(v => Number.isFinite(v))
|
||||
const ys = points.map(p => p.y).filter(v => Number.isFinite(v))
|
||||
if (!xs.length || !ys.length) return false
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
const minY = Math.min(...ys)
|
||||
const maxY = Math.max(...ys)
|
||||
const pad = 24
|
||||
const scaleX = (v) => {
|
||||
if (maxX === minX) return width / 2
|
||||
return pad + ((v - minX) / (maxX - minX)) * (width - pad * 2)
|
||||
}
|
||||
const scaleY = (v) => {
|
||||
if (maxY === minY) return height / 2
|
||||
return height - pad - ((v - minY) / (maxY - minY)) * (height - pad * 2)
|
||||
}
|
||||
ctx.fillStyle = color
|
||||
points.forEach(p => {
|
||||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) return
|
||||
const x = scaleX(p.x)
|
||||
const y = scaleY(p.y)
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const renderDefaultCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { typeLabels, typeValues } = getTypeSummary(data)
|
||||
const { days, labels, hr, resp, weight, distance, sleep, stand, workout, hrv, restingHr, walkingHr, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const vCtx = el('chartVitals').getContext('2d')
|
||||
state.charts.vitals = new Chart(vCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: { x: { ticks: { maxTicksLimit: 6 } } }
|
||||
}
|
||||
})
|
||||
const wCtx = el('chartWeight').getContext('2d')
|
||||
state.charts.weight = new Chart(wCtx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: '体重', data: weight, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const dCtx = el('chartDistance').getContext('2d')
|
||||
state.charts.distance = new Chart(dCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#60a5fa' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const tCtx = el('chartTypes').getContext('2d')
|
||||
state.charts.types = new Chart(tCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: typeLabels,
|
||||
datasets: [{ data: typeValues, backgroundColor: ['#38bdf8', '#60a5fa', '#a78bfa', '#f472b6', '#34d399', '#facc15', '#fb7185', '#c084fc', '#22d3ee', '#f97316', '#4ade80', '#eab308'] }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||
})
|
||||
const sCtx = el('chartSleep').getContext('2d')
|
||||
state.charts.sleep = new Chart(sCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '睡眠小时', data: sleep, borderColor: '#34d399', backgroundColor: 'rgba(52,211,153,0.2)', tension: 0.35, pointRadius: 2 },
|
||||
{ label: '站立小时', data: stand, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.2)', tension: 0.35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const wCtx2 = el('chartWorkout').getContext('2d')
|
||||
state.charts.workout = new Chart(wCtx2, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const ssCtx = el('chartSleepStages').getContext('2d')
|
||||
state.charts.sleepStages = new Chart(ssCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 }, stacked: true },
|
||||
y: { stacked: true, title: { display: true, text: '小时' } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const hhCtx = el('chartHeartHealth').getContext('2d')
|
||||
state.charts.heartHealth = new Chart(hhCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'bpm' } },
|
||||
y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
drawSimpleLine(el('chartVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||
drawSimpleLine(el('chartWeight'), labels, [weight], ['#a78bfa'])
|
||||
drawSimpleBars(el('chartDistance'), labels, distance)
|
||||
drawSimplePie(el('chartTypes'), typeValues, ['#38bdf8', '#60a5fa', '#a78bfa', '#f472b6', '#34d399', '#facc15', '#fb7185', '#c084fc', '#22d3ee', '#f97316', '#4ade80', '#eab308'])
|
||||
drawSimpleLine(el('chartSleep'), labels, [sleep, stand], ['#34d399', '#f59e0b'])
|
||||
drawSimpleBars(el('chartWorkout'), labels, workout)
|
||||
}
|
||||
setText('chartVitalsMeta', `${days}天内平均心率与呼吸率`)
|
||||
setText('chartWeightMeta', `${days}天体重变化`)
|
||||
setText('chartDistanceMeta', `${days}天步行距离`)
|
||||
setText('chartTypesMeta', `最多记录的 ${typeLabels.length} 类数据`)
|
||||
setText('chartSleepMeta', `${days}天睡眠小时与站立小时`)
|
||||
setText('chartWorkoutMeta', `${days}天运动分钟`)
|
||||
setText('chartSleepStagesMeta', `${days}天睡眠分期 (REM/核心/深睡/清醒)`)
|
||||
setText('chartHeartHealthMeta', `${days}天静息/步行心率与 HRV 趋势`)
|
||||
}
|
||||
|
||||
const typeNameMap = {
|
||||
HKQuantityTypeIdentifierStepCount: '步数',
|
||||
HKQuantityTypeIdentifierActiveEnergyBurned: '活动能量',
|
||||
HKQuantityTypeIdentifierAppleExerciseTime: '锻炼时间',
|
||||
HKQuantityTypeIdentifierStandTime: '站立时间',
|
||||
HKQuantityTypeIdentifierBasalEnergyBurned: '基础能量',
|
||||
HKQuantityTypeIdentifierDistanceWalkingRunning: '步行跑步距离',
|
||||
HKQuantityTypeIdentifierHeartRate: '心率',
|
||||
HKQuantityTypeIdentifierRespiratoryRate: '呼吸率',
|
||||
HKQuantityTypeIdentifierBodyMass: '体重',
|
||||
HKQuantityTypeIdentifierHeight: '身高',
|
||||
HKCategoryTypeIdentifierAppleStandHour: '站立小时',
|
||||
HKCategoryTypeIdentifierSleepAnalysis: '睡眠'
|
||||
}
|
||||
|
||||
const typeDisplayConfig = {
|
||||
HKQuantityTypeIdentifierStepCount: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierActiveEnergyBurned: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierBasalEnergyBurned: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierDistanceWalkingRunning: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierAppleExerciseTime: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierStandTime: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierFlightsClimbed: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierDietaryEnergyConsumed: { mode: 'daily_avg', label: '日均' },
|
||||
HKQuantityTypeIdentifierHeartRate: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierRestingHeartRate: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierWalkingHeartRateAverage: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierHeartRateVariabilitySDNN: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierRespiratoryRate: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierOxygenSaturation: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierBodyMass: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierHeight: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierBodyTemperature: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierEnvironmentalAudioExposure: { mode: 'avg', label: '平均' },
|
||||
HKQuantityTypeIdentifierHeadphoneAudioExposure: { mode: 'avg', label: '平均' }
|
||||
}
|
||||
|
||||
const renderTypeList = (data, targetId = 'typeList') => {
|
||||
const box = el(targetId)
|
||||
if (!box) return
|
||||
box.innerHTML = ''
|
||||
const types = data.compare.types || []
|
||||
if (!types.length) {
|
||||
box.innerHTML = '<div class="list-item"><span class="muted">暂无类型数据</span></div>'
|
||||
return
|
||||
}
|
||||
types.slice(0, 12).forEach(item => {
|
||||
const name = typeNameMap[item.type] || item.type.replace('HKQuantityTypeIdentifier', '').replace('HKCategoryTypeIdentifier', '')
|
||||
const config = typeDisplayConfig[item.type] || { mode: 'count' }
|
||||
let valueStr = ''
|
||||
if (config.mode === 'daily_avg' && item.total_value && item.day_count) {
|
||||
const val = item.total_value / Math.max(1, item.day_count)
|
||||
valueStr = `${config.label} ${fmtNum(val, 1)}`
|
||||
} else if (config.mode === 'avg' && item.avg_value) {
|
||||
valueStr = `${config.label} ${fmtNum(item.avg_value, 1)}`
|
||||
} else {
|
||||
valueStr = `${item.record_count} 次`
|
||||
}
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `
|
||||
<div class="list-row">
|
||||
<div class="list-title">${name}</div>
|
||||
<div class="list-meta">${item.type}</div>
|
||||
</div>
|
||||
<span class="tag">${valueStr}${item.unit ? ` · ${item.unit}` : ''}</span>
|
||||
`
|
||||
box.appendChild(node)
|
||||
})
|
||||
}
|
||||
|
||||
const lastValue = (arr) => {
|
||||
for (let i = arr.length - 1; i >= 0; i -= 1) {
|
||||
if (Number.isFinite(arr[i])) return arr[i]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const renderTrendList = (data, targetId = 'trendList') => {
|
||||
const box = el(targetId)
|
||||
if (!box) return
|
||||
box.innerHTML = ''
|
||||
const hr = data.trends.hr_avg || []
|
||||
const resp = data.trends.resp_avg || []
|
||||
const weight = data.trends.weight || []
|
||||
const distance = data.trends.distance || []
|
||||
const sleep = data.trends.sleep_hours || []
|
||||
const stand = data.trends.stand_hours || []
|
||||
const workout = data.trends.workout_minutes || []
|
||||
const days = Math.max(1, data.trends.dates.length || 1)
|
||||
const totalDistance = distance.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
|
||||
const avgDistance = totalDistance / days
|
||||
const maxHr = Math.max(...hr.filter(v => Number.isFinite(v)), 0)
|
||||
const lastHr = lastValue(hr)
|
||||
const lastResp = lastValue(resp)
|
||||
const lastWeight = lastValue(weight)
|
||||
const lastSleep = lastValue(sleep)
|
||||
const lastStand = lastValue(stand)
|
||||
const lastWorkout = lastValue(workout)
|
||||
const items = [
|
||||
{ title: '最近心率', value: lastHr, unit: 'bpm' },
|
||||
{ title: '最近呼吸率', value: lastResp, unit: '次/分钟' },
|
||||
{ title: '最近体重', value: lastWeight, unit: 'kg' },
|
||||
{ title: '最高心率', value: maxHr, unit: 'bpm' },
|
||||
{ title: '日均步行距离', value: avgDistance, unit: 'km' },
|
||||
{ title: '最近睡眠', value: lastSleep, unit: '小时' },
|
||||
{ title: '最近站立', value: lastStand, unit: '小时' },
|
||||
{ title: '最近运动', value: lastWorkout, unit: '分钟' }
|
||||
]
|
||||
const hasData = hr.some(v => Number.isFinite(v)) || resp.some(v => Number.isFinite(v)) || weight.some(v => Number.isFinite(v)) || distance.some(v => Number.isFinite(v)) || sleep.some(v => Number.isFinite(v)) || stand.some(v => Number.isFinite(v)) || workout.some(v => Number.isFinite(v))
|
||||
if (!hasData) {
|
||||
box.innerHTML = '<div class="list-item"><span class="muted">暂无趋势数据</span></div>'
|
||||
return
|
||||
}
|
||||
items.forEach(item => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>${item.title}</span><span class="tag">${fmtNum(item.value, 1)} ${item.unit}</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
}
|
||||
|
||||
const renderAnomalies = (data, targetId = 'anomalyList') => {
|
||||
const box = el(targetId)
|
||||
if (!box) return
|
||||
box.innerHTML = ''
|
||||
const items = []
|
||||
data.anomalies.hr.forEach(a => items.push({ type: '心率', date: a.date, value: a.value, unit: 'bpm' }))
|
||||
data.anomalies.resp.forEach(a => items.push({ type: '呼吸率', date: a.date, value: a.value, unit: '次/分钟' }))
|
||||
if (!items.length) {
|
||||
box.innerHTML = '<div class="list-item"><span class="muted">暂无异常高值</span></div>'
|
||||
return
|
||||
}
|
||||
items.slice(-10).reverse().forEach(item => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>${item.type} ${fmtDate(item.date)}</span><span class="tag">${fmtNum(item.value, 1)} ${item.unit}</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
}
|
||||
|
||||
const renderDeep = (data, targetId = 'deepList') => {
|
||||
const box = el(targetId)
|
||||
if (!box) return
|
||||
box.innerHTML = ''
|
||||
const routes = data.deep.routes || []
|
||||
const ecg = data.deep.ecg || []
|
||||
const workouts = data.deep.workouts || []
|
||||
if (!routes.length && !ecg.length && !workouts.length) {
|
||||
box.innerHTML = '<div class="list-item"><span class="muted">暂无深度数据</span></div>'
|
||||
return
|
||||
}
|
||||
workouts.forEach(r => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>训练 ${fmtDate(r.start_time)}</span><span class="tag">${fmtNum(r.duration_min, 1)} 分钟 · ${fmtNum(r.distance_km, 2)} km</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
routes.forEach(r => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>路线 ${fmtDate(r.route_time)}</span><span class="tag">${fmtNum(r.distance_km, 2)} km · ${fmtNum(r.duration_min, 1)} 分钟</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
ecg.forEach(r => {
|
||||
const node = document.createElement('div')
|
||||
node.className = 'list-item'
|
||||
node.innerHTML = `<span>ECG ${fmtDate(r.record_time)}</span><span class="tag">${r.classification || '未知'} · ${fmtNum(r.sampling_rate, 0)} Hz</span>`
|
||||
box.appendChild(node)
|
||||
})
|
||||
}
|
||||
|
||||
const renderHddSummary = (data) => {
|
||||
const trends = getTrendData(data)
|
||||
setText('hddAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||
setText('hddAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||
setText('hddAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||
setText('hddAvgStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||
setText('hddAvgWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||
setText('hddDistance', fmtNum(sumOf(trends.distance), 1))
|
||||
setText('hddRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||
setText('hddHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||
}
|
||||
|
||||
const renderAhaSummary = (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const sleepTotal = sumOf(trends.sleepRem) + sumOf(trends.sleepCore) + sumOf(trends.sleepDeep) + sumOf(trends.sleepAwake)
|
||||
const remPct = sleepTotal ? (sumOf(trends.sleepRem) / sleepTotal) * 100 : null
|
||||
const deepPct = sleepTotal ? (sumOf(trends.sleepDeep) / sleepTotal) * 100 : null
|
||||
const weightFirst = firstValue(trends.weight)
|
||||
const weightLast = lastValue(trends.weight)
|
||||
const weightDelta = Number.isFinite(weightFirst) && Number.isFinite(weightLast) ? weightLast - weightFirst : null
|
||||
setText('ahaRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||
setText('ahaWalkingHr', fmtNum(avgOf(trends.walkingHr), 1))
|
||||
setText('ahaHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||
setText('ahaRemPct', fmtNum(remPct, 1))
|
||||
setText('ahaDeepPct', fmtNum(deepPct, 1))
|
||||
setText('ahaWeightDelta', fmtNum(weightDelta, 2))
|
||||
setText('ahaWorkoutTotal', fmtNum(sumOf(trends.workout), 0))
|
||||
setText('ahaAnomalyCount', data.anomalies.hr ? data.anomalies.hr.length : 0)
|
||||
}
|
||||
|
||||
const renderGrafanaSummary = (data) => {
|
||||
const trends = getTrendData(data)
|
||||
setText('grafanaAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||
setText('grafanaAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||
setText('grafanaAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||
setText('grafanaAvgWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
|
||||
setText('grafanaAvgStand', fmtNum(data.cards.avg_stand_7d_h, 1))
|
||||
setText('grafanaDistance', fmtNum(sumOf(trends.distance), 1))
|
||||
setText('grafanaRoutes', data.summary.route_count || 0)
|
||||
setText('grafanaEcg', data.summary.ecg_count || 0)
|
||||
}
|
||||
|
||||
const renderDhdSummary = (data) => {
|
||||
const trends = getTrendData(data)
|
||||
setText('dhdAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
|
||||
setText('dhdAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
|
||||
setText('dhdRestingHr', fmtNum(avgOf(trends.restingHr), 1))
|
||||
setText('dhdWalkingHr', fmtNum(avgOf(trends.walkingHr), 1))
|
||||
setText('dhdHrv', fmtNum(avgOf(trends.hrv), 1))
|
||||
setText('dhdDistance', fmtNum(data.cards.total_distance_km, 1))
|
||||
setText('dhdWeight', fmtNum(data.latest.weight_kg, 1))
|
||||
setText('dhdWeightUnit', data.latest.weight_unit || 'kg')
|
||||
setText('dhdAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
|
||||
}
|
||||
|
||||
const renderHddCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { days, labels, hr, resp, workout, distance, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const vCtx = el('chartHddVitals').getContext('2d')
|
||||
state.charts.hddVitals = new Chart(vCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const aCtx = el('chartHddActivity').getContext('2d')
|
||||
state.charts.hddActivity = new Chart(aCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const sCtx = el('chartHddSleepStages').getContext('2d')
|
||||
state.charts.hddSleepStages = new Chart(sCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||
})
|
||||
const dCtx = el('chartHddDistance').getContext('2d')
|
||||
state.charts.hddDistance = new Chart(dCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#60a5fa' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
} else {
|
||||
drawSimpleLine(el('chartHddVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||
drawSimpleBars(el('chartHddActivity'), labels, workout)
|
||||
drawSimpleBars(el('chartHddDistance'), labels, distance)
|
||||
}
|
||||
setText('chartHddVitalsMeta', `${days}天心率与呼吸率`)
|
||||
setText('chartHddActivityMeta', `${days}天运动分钟`)
|
||||
setText('chartHddSleepStagesMeta', `${days}天睡眠分期`)
|
||||
setText('chartHddDistanceMeta', `${days}天步行距离`)
|
||||
}
|
||||
|
||||
const renderAhaCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { days, labels, weight, workout, hrv, restingHr, walkingHr, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const hCtx = el('chartAhaHeart').getContext('2d')
|
||||
state.charts.ahaHeart = new Chart(hCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'bpm' } },
|
||||
y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const sCtx = el('chartAhaSleepStages').getContext('2d')
|
||||
state.charts.ahaSleepStages = new Chart(sCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||
})
|
||||
const wCtx = el('chartAhaWeight').getContext('2d')
|
||||
state.charts.ahaWeight = new Chart(wCtx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: '体重', data: weight, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const aCtx = el('chartAhaWorkout').getContext('2d')
|
||||
state.charts.ahaWorkout = new Chart(aCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
} else {
|
||||
drawSimpleLine(el('chartAhaHeart'), labels, [restingHr, walkingHr], ['#f43f5e', '#ec4899'])
|
||||
drawSimpleLine(el('chartAhaWeight'), labels, [weight], ['#a78bfa'])
|
||||
drawSimpleBars(el('chartAhaWorkout'), labels, workout)
|
||||
}
|
||||
setText('chartAhaHeartMeta', `${days}天心脏恢复趋势`)
|
||||
setText('chartAhaSleepStagesMeta', `${days}天睡眠结构`)
|
||||
setText('chartAhaWeightMeta', `${days}天体重变化`)
|
||||
setText('chartAhaWorkoutMeta', `${days}天运动分钟`)
|
||||
}
|
||||
|
||||
const renderGrafanaCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { days, labels, workout, distance, sleepRem, sleepCore, sleepDeep, sleepAwake, restingHr, walkingHr, hrv } = trends
|
||||
const routes = (data.deep && data.deep.routes) ? data.deep.routes : []
|
||||
const { typeLabels, typeValues } = getTypeSummary(data)
|
||||
const sleepTotals = [sumOf(sleepRem), sumOf(sleepCore), sumOf(sleepDeep), sumOf(sleepAwake)]
|
||||
const sleepLabels = ['REM', '核心', '深睡', '清醒']
|
||||
const routePoints = routes.map(r => ({
|
||||
x: Number(r.distance_km || 0),
|
||||
y: Number(r.duration_min || 0)
|
||||
})).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const aCtx = el('chartGrafanaActivity').getContext('2d')
|
||||
state.charts.grafanaActivity = new Chart(aCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ type: 'bar', label: '运动分钟', data: workout, backgroundColor: '#fb7185', yAxisID: 'y' },
|
||||
{ type: 'line', label: '步行距离', data: distance, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { title: { display: true, text: '分钟' } },
|
||||
y1: { position: 'right', title: { display: true, text: 'km' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const sCtx = el('chartGrafanaSleepStages').getContext('2d')
|
||||
state.charts.grafanaSleep = new Chart(sCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
|
||||
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
|
||||
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
|
||||
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
|
||||
})
|
||||
const hCtx = el('chartGrafanaHeart').getContext('2d')
|
||||
state.charts.grafanaHeart = new Chart(hCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { type: 'linear', position: 'left', title: { display: true, text: 'bpm' } },
|
||||
y1: { type: 'linear', position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const tCtx = el('chartGrafanaTypes').getContext('2d')
|
||||
state.charts.grafanaTypes = new Chart(tCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: typeLabels.slice(0, 8),
|
||||
datasets: [{ data: typeValues.slice(0, 8), backgroundColor: ['#38bdf8', '#f472b6', '#facc15', '#34d399', '#a78bfa', '#fb7185', '#f97316', '#22d3ee'] }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||
})
|
||||
const rCtx = el('chartGrafanaRouteMap').getContext('2d')
|
||||
state.charts.grafanaRoutes = new Chart(rCtx, {
|
||||
type: 'scatter',
|
||||
data: {
|
||||
datasets: [{ label: '路线', data: routePoints, backgroundColor: '#22d3ee' }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: '距离 km' } },
|
||||
y: { title: { display: true, text: '时长 min' } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const pCtx = el('chartGrafanaSleepPct').getContext('2d')
|
||||
state.charts.grafanaSleepPct = new Chart(pCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sleepLabels,
|
||||
datasets: [{ data: sleepTotals, backgroundColor: ['#60a5fa', '#34d399', '#818cf8', '#fb923c'] }]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
|
||||
})
|
||||
} else {
|
||||
drawSimpleBars(el('chartGrafanaActivity'), labels, workout)
|
||||
drawSimpleBars(el('chartGrafanaSleepStages'), labels, sleepRem)
|
||||
drawSimpleLine(el('chartGrafanaHeart'), labels, [restingHr, walkingHr], ['#f43f5e', '#ec4899'])
|
||||
drawSimplePie(el('chartGrafanaTypes'), typeValues.slice(0, 8), ['#38bdf8', '#f472b6', '#facc15', '#34d399', '#a78bfa', '#fb7185', '#f97316', '#22d3ee'])
|
||||
drawSimpleScatter(el('chartGrafanaRouteMap'), routePoints, '#22d3ee')
|
||||
drawSimplePie(el('chartGrafanaSleepPct'), sleepTotals, ['#60a5fa', '#34d399', '#818cf8', '#fb923c'])
|
||||
}
|
||||
setText('chartGrafanaActivityMeta', `${days}天活动趋势`)
|
||||
setText('chartGrafanaSleepStagesMeta', `${days}天睡眠分期`)
|
||||
setText('chartGrafanaHeartMeta', `${days}天心脏恢复`)
|
||||
setText('chartGrafanaTypesMeta', '高频指标分布')
|
||||
setText('chartGrafanaRouteMapMeta', routePoints.length ? '路线距离/时长分布' : '暂无路线数据')
|
||||
setText('chartGrafanaSleepPctMeta', '睡眠分期占比')
|
||||
}
|
||||
|
||||
const renderDhdCharts = async (data) => {
|
||||
const trends = getTrendData(data)
|
||||
const { days, labels, hr, resp, restingHr, hrv, sleep, distance } = trends
|
||||
destroyCharts()
|
||||
const hasChart = await ensureChart()
|
||||
if (hasChart) {
|
||||
const vCtx = el('chartDhdVitals').getContext('2d')
|
||||
state.charts.dhdVitals = new Chart(vCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
|
||||
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const rCtx = el('chartDhdRecovery').getContext('2d')
|
||||
state.charts.dhdRecovery = new Chart(rCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
|
||||
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { ticks: { maxTicksLimit: 6 } },
|
||||
y: { title: { display: true, text: 'bpm' } },
|
||||
y1: { position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
const sCtx = el('chartDhdSleep').getContext('2d')
|
||||
state.charts.dhdSleep = new Chart(sCtx, {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: '睡眠时长', data: sleep, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.2)', tension: 0.35, pointRadius: 2 }] },
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
const dCtx = el('chartDhdDistance').getContext('2d')
|
||||
state.charts.dhdDistance = new Chart(dCtx, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#22d3ee' }] },
|
||||
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
|
||||
})
|
||||
} else {
|
||||
drawSimpleLine(el('chartDhdVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
|
||||
drawSimpleLine(el('chartDhdRecovery'), labels, [restingHr, hrv], ['#f43f5e', '#a855f7'])
|
||||
drawSimpleLine(el('chartDhdSleep'), labels, [sleep], ['#60a5fa'])
|
||||
drawSimpleBars(el('chartDhdDistance'), labels, distance)
|
||||
}
|
||||
setText('chartDhdVitalsMeta', `${days}天心肺趋势`)
|
||||
setText('chartDhdRecoveryMeta', `${days}天恢复指标`)
|
||||
setText('chartDhdSleepMeta', `${days}天睡眠时长`)
|
||||
setText('chartDhdDistanceMeta', `${days}天步行距离`)
|
||||
}
|
||||
|
||||
const renderActiveTab = async () => {
|
||||
if (!state.lastData) return
|
||||
if (state.activeTab === 'grafana') {
|
||||
await renderGrafanaCharts(state.lastData)
|
||||
return
|
||||
}
|
||||
if (state.activeTab === 'dhd') {
|
||||
await renderDhdCharts(state.lastData)
|
||||
return
|
||||
}
|
||||
if (state.activeTab === 'hdd') {
|
||||
await renderHddCharts(state.lastData)
|
||||
return
|
||||
}
|
||||
if (state.activeTab === 'aha') {
|
||||
await renderAhaCharts(state.lastData)
|
||||
return
|
||||
}
|
||||
await renderDefaultCharts(state.lastData)
|
||||
}
|
||||
|
||||
const setupTabs = () => {
|
||||
const tabs = Array.from(document.querySelectorAll('.tab-btn'))
|
||||
const pages = Array.from(document.querySelectorAll('.tab-page'))
|
||||
if (!tabs.length || !pages.length) return
|
||||
const activate = (key) => {
|
||||
tabs.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === key))
|
||||
pages.forEach(page => page.classList.toggle('active', page.dataset.tabPage === key))
|
||||
state.activeTab = key
|
||||
renderActiveTab()
|
||||
}
|
||||
tabs.forEach(btn => btn.addEventListener('click', () => activate(btn.dataset.tab)))
|
||||
const initialKey = tabs.find(btn => btn.classList.contains('active'))?.dataset.tab || tabs[0].dataset.tab
|
||||
activate(initialKey)
|
||||
}
|
||||
|
||||
const loadDashboard = async () => {
|
||||
const overlay = el('loadingOverlay')
|
||||
if (overlay) overlay.classList.add('visible')
|
||||
|
||||
try {
|
||||
const days = Number(el('daysRange').value || 90)
|
||||
const res = await fetch(`/api/apple_watch/dashboard?days=${days}`)
|
||||
const json = await res.json()
|
||||
if (!json.ok) throw new Error(json.error || 'load_failed')
|
||||
state.lastData = json.data
|
||||
updateSummary(json.data)
|
||||
renderHddSummary(json.data)
|
||||
renderAhaSummary(json.data)
|
||||
renderGrafanaSummary(json.data)
|
||||
renderDhdSummary(json.data)
|
||||
renderTypeList(json.data)
|
||||
renderTrendList(json.data)
|
||||
renderTypeList(json.data, 'hddTypeList')
|
||||
renderTrendList(json.data, 'hddTrendList')
|
||||
renderTypeList(json.data, 'grafanaTypeList')
|
||||
renderTrendList(json.data, 'grafanaTrendList')
|
||||
renderTypeList(json.data, 'dhdTypeList')
|
||||
renderAnomalies(json.data)
|
||||
renderDeep(json.data)
|
||||
renderAnomalies(json.data, 'ahaAnomalyList')
|
||||
renderDeep(json.data, 'ahaDeepList')
|
||||
renderAnomalies(json.data, 'dhdAnomalyList')
|
||||
await renderActiveTab()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('加载失败: ' + e.message)
|
||||
} finally {
|
||||
if (overlay) overlay.classList.remove('visible')
|
||||
}
|
||||
}
|
||||
|
||||
const runImport = async () => {
|
||||
const res = await fetch('/api/apple_watch/import', { method: 'POST' })
|
||||
const json = await res.json()
|
||||
if (!json.ok) throw new Error(json.error || 'import_failed')
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
const refresh = async () => {
|
||||
setText('statLastImport', '加载中')
|
||||
await loadDashboard()
|
||||
}
|
||||
setupTabs()
|
||||
el('btnRefresh').addEventListener('click', refresh)
|
||||
el('daysRange').addEventListener('change', refresh)
|
||||
el('btnImport').addEventListener('click', async () => {
|
||||
setText('statLastImport', '同步中')
|
||||
try {
|
||||
await runImport()
|
||||
await loadDashboard()
|
||||
} catch {
|
||||
setText('statLastImport', '同步失败')
|
||||
}
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
|
||||
init()
|
||||
Reference in New Issue
Block a user