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()
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enable_auth": true,
|
||||
"max_age_days": 30,
|
||||
"cookieName": "apple_watch_gate"
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Apple Watch 数据洞察</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.loading-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(56, 189, 248, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #38bdf8;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
.loading-text {
|
||||
color: #e2e8f0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loadingOverlay" class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-text">正在处理数据...</div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<header class="header">
|
||||
<div class="title">
|
||||
<div class="title-main">Apple Watch 数据洞察</div>
|
||||
<div class="title-sub">Apple Health 导出数据 · 近况与趋势</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<select id="daysRange">
|
||||
<option value="30">近30天</option>
|
||||
<option value="90" selected>近90天</option>
|
||||
<option value="180">近180天</option>
|
||||
<option value="365">近365天</option>
|
||||
</select>
|
||||
<button id="btnRefresh" class="primary">刷新</button>
|
||||
<button id="btnImport" class="ghost">同步导出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs-bar">
|
||||
<button class="tab-btn active" data-tab="default">Default Health</button>
|
||||
<button class="tab-btn" data-tab="hdd">Health Data Dashboard</button>
|
||||
<button class="tab-btn" data-tab="aha">Apple Health Analytics</button>
|
||||
<button class="tab-btn" data-tab="grafana">Grafana Health</button>
|
||||
<button class="tab-btn" data-tab="dhd">Digital Health Data</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-page active" data-tab-page="default">
|
||||
<section class="cards" id="summaryCards">
|
||||
<div class="card">
|
||||
<div class="card-label">记录总量</div>
|
||||
<div class="card-value" id="statRecords">0</div>
|
||||
<div class="card-meta" id="statDateRange">-</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">数据类型</div>
|
||||
<div class="card-value" id="statTypes">0</div>
|
||||
<div class="card-meta" id="statLastImport">-</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">运动路线</div>
|
||||
<div class="card-value" id="statRoutes">0</div>
|
||||
<div class="card-meta">GPS 轨迹</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">ECG 记录</div>
|
||||
<div class="card-value" id="statEcg">0</div>
|
||||
<div class="card-meta">心电采样</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均心率</div>
|
||||
<div class="card-value" id="statAvgHr">0</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均呼吸率</div>
|
||||
<div class="card-value" id="statAvgResp">0</div>
|
||||
<div class="card-meta">次/分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">累计步行距离</div>
|
||||
<div class="card-value" id="statDistance">0</div>
|
||||
<div class="card-meta">公里</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">最近体重</div>
|
||||
<div class="card-value" id="statWeight">-</div>
|
||||
<div class="card-meta" id="statWeightUnit">kg</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">最近身高</div>
|
||||
<div class="card-value" id="statHeight">-</div>
|
||||
<div class="card-meta" id="statHeightUnit">cm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均睡眠</div>
|
||||
<div class="card-value" id="statSleep">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日站立小时</div>
|
||||
<div class="card-value" id="statStand">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日运动分钟</div>
|
||||
<div class="card-value" id="statWorkout">-</div>
|
||||
<div class="card-meta">分钟</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心率与呼吸率趋势</div>
|
||||
<canvas id="chartVitals"></canvas>
|
||||
<div class="panel-foot" id="chartVitalsMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">体重趋势</div>
|
||||
<canvas id="chartWeight"></canvas>
|
||||
<div class="panel-foot" id="chartWeightMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">步行距离</div>
|
||||
<canvas id="chartDistance"></canvas>
|
||||
<div class="panel-foot" id="chartDistanceMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">高频数据类型</div>
|
||||
<canvas id="chartTypes"></canvas>
|
||||
<div class="panel-foot" id="chartTypesMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠与站立</div>
|
||||
<canvas id="chartSleep"></canvas>
|
||||
<div class="panel-foot" id="chartSleepMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">运动分钟趋势</div>
|
||||
<canvas id="chartWorkout"></canvas>
|
||||
<div class="panel-foot" id="chartWorkoutMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠分期</div>
|
||||
<canvas id="chartSleepStages"></canvas>
|
||||
<div class="panel-foot" id="chartSleepStagesMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">心脏健康</div>
|
||||
<canvas id="chartHeartHealth"></canvas>
|
||||
<div class="panel-foot" id="chartHeartHealthMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">类型榜单</div>
|
||||
<div class="list" id="typeList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">趋势概览</div>
|
||||
<div class="list" id="trendList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">异常提醒</div>
|
||||
<div class="list" id="anomalyList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">深度数据</div>
|
||||
<div class="list" id="deepList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" data-tab-page="hdd">
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均心率</div>
|
||||
<div class="card-value" id="hddAvgHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均呼吸率</div>
|
||||
<div class="card-value" id="hddAvgResp">-</div>
|
||||
<div class="card-meta">次/分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均睡眠</div>
|
||||
<div class="card-value" id="hddAvgSleep">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日站立小时</div>
|
||||
<div class="card-value" id="hddAvgStand">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日运动分钟</div>
|
||||
<div class="card-value" id="hddAvgWorkout">-</div>
|
||||
<div class="card-meta">分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期步行距离</div>
|
||||
<div class="card-value" id="hddDistance">-</div>
|
||||
<div class="card-meta">公里</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期静息心率</div>
|
||||
<div class="card-value" id="hddRestingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期 HRV</div>
|
||||
<div class="card-value" id="hddHrv">-</div>
|
||||
<div class="card-meta">ms</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心肺趋势</div>
|
||||
<canvas id="chartHddVitals"></canvas>
|
||||
<div class="panel-foot" id="chartHddVitalsMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">活动趋势</div>
|
||||
<canvas id="chartHddActivity"></canvas>
|
||||
<div class="panel-foot" id="chartHddActivityMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠分期</div>
|
||||
<canvas id="chartHddSleepStages"></canvas>
|
||||
<div class="panel-foot" id="chartHddSleepStagesMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">步行距离</div>
|
||||
<canvas id="chartHddDistance"></canvas>
|
||||
<div class="panel-foot" id="chartHddDistanceMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">类型榜单</div>
|
||||
<div class="list" id="hddTypeList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">趋势概览</div>
|
||||
<div class="list" id="hddTrendList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" data-tab-page="aha">
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期静息心率</div>
|
||||
<div class="card-value" id="ahaRestingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期步行心率</div>
|
||||
<div class="card-value" id="ahaWalkingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期 HRV</div>
|
||||
<div class="card-value" id="ahaHrv">-</div>
|
||||
<div class="card-meta">ms</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">REM 占比</div>
|
||||
<div class="card-value" id="ahaRemPct">-</div>
|
||||
<div class="card-meta">%</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">深睡占比</div>
|
||||
<div class="card-value" id="ahaDeepPct">-</div>
|
||||
<div class="card-meta">%</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">体重变化</div>
|
||||
<div class="card-value" id="ahaWeightDelta">-</div>
|
||||
<div class="card-meta">kg</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">运动分钟总量</div>
|
||||
<div class="card-value" id="ahaWorkoutTotal">-</div>
|
||||
<div class="card-meta">分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">异常心率次数</div>
|
||||
<div class="card-value" id="ahaAnomalyCount">-</div>
|
||||
<div class="card-meta">次</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心脏恢复趋势</div>
|
||||
<canvas id="chartAhaHeart"></canvas>
|
||||
<div class="panel-foot" id="chartAhaHeartMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠结构</div>
|
||||
<canvas id="chartAhaSleepStages"></canvas>
|
||||
<div class="panel-foot" id="chartAhaSleepStagesMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">体重趋势</div>
|
||||
<canvas id="chartAhaWeight"></canvas>
|
||||
<div class="panel-foot" id="chartAhaWeightMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">运动分钟</div>
|
||||
<canvas id="chartAhaWorkout"></canvas>
|
||||
<div class="panel-foot" id="chartAhaWorkoutMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">异常提醒</div>
|
||||
<div class="list" id="ahaAnomalyList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">深度数据</div>
|
||||
<div class="list" id="ahaDeepList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" data-tab-page="grafana">
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均心率</div>
|
||||
<div class="card-value" id="grafanaAvgHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均呼吸率</div>
|
||||
<div class="card-value" id="grafanaAvgResp">-</div>
|
||||
<div class="card-meta">次/分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均睡眠</div>
|
||||
<div class="card-value" id="grafanaAvgSleep">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日运动分钟</div>
|
||||
<div class="card-value" id="grafanaAvgWorkout">-</div>
|
||||
<div class="card-meta">分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日站立小时</div>
|
||||
<div class="card-value" id="grafanaAvgStand">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期步行距离</div>
|
||||
<div class="card-value" id="grafanaDistance">-</div>
|
||||
<div class="card-meta">公里</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">运动路线</div>
|
||||
<div class="card-value" id="grafanaRoutes">-</div>
|
||||
<div class="card-meta">GPS 轨迹</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">ECG 记录</div>
|
||||
<div class="card-value" id="grafanaEcg">-</div>
|
||||
<div class="card-meta">心电采样</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">整体活动</div>
|
||||
<canvas id="chartGrafanaActivity"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaActivityMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠分期</div>
|
||||
<canvas id="chartGrafanaSleepStages"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaSleepStagesMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心脏恢复</div>
|
||||
<canvas id="chartGrafanaHeart"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaHeartMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">指标分布</div>
|
||||
<canvas id="chartGrafanaTypes"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaTypesMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">路线地图</div>
|
||||
<canvas id="chartGrafanaRouteMap"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaRouteMapMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠占比</div>
|
||||
<canvas id="chartGrafanaSleepPct"></canvas>
|
||||
<div class="panel-foot" id="chartGrafanaSleepPctMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">类型榜单</div>
|
||||
<div class="list" id="grafanaTypeList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">趋势概览</div>
|
||||
<div class="list" id="grafanaTrendList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-page" data-tab-page="dhd">
|
||||
<section class="cards">
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均心率</div>
|
||||
<div class="card-value" id="dhdAvgHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均呼吸率</div>
|
||||
<div class="card-value" id="dhdAvgResp">-</div>
|
||||
<div class="card-meta">次/分钟</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期静息心率</div>
|
||||
<div class="card-value" id="dhdRestingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期步行心率</div>
|
||||
<div class="card-value" id="dhdWalkingHr">-</div>
|
||||
<div class="card-meta">bpm</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近周期 HRV</div>
|
||||
<div class="card-value" id="dhdHrv">-</div>
|
||||
<div class="card-meta">ms</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">累计步行距离</div>
|
||||
<div class="card-value" id="dhdDistance">-</div>
|
||||
<div class="card-meta">公里</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">最近体重</div>
|
||||
<div class="card-value" id="dhdWeight">-</div>
|
||||
<div class="card-meta" id="dhdWeightUnit">kg</div>
|
||||
</div>
|
||||
<div class="card accent">
|
||||
<div class="card-label">近7日平均睡眠</div>
|
||||
<div class="card-value" id="dhdAvgSleep">-</div>
|
||||
<div class="card-meta">小时</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">心肺趋势</div>
|
||||
<canvas id="chartDhdVitals"></canvas>
|
||||
<div class="panel-foot" id="chartDhdVitalsMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">恢复趋势</div>
|
||||
<canvas id="chartDhdRecovery"></canvas>
|
||||
<div class="panel-foot" id="chartDhdRecoveryMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">睡眠时长</div>
|
||||
<canvas id="chartDhdSleep"></canvas>
|
||||
<div class="panel-foot" id="chartDhdSleepMeta"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">步行距离</div>
|
||||
<canvas id="chartDhdDistance"></canvas>
|
||||
<div class="panel-foot" id="chartDhdDistanceMeta"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">类型榜单</div>
|
||||
<div class="list" id="dhdTypeList"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">异常提醒</div>
|
||||
<div class="list" id="dhdAnomalyList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,219 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft Yahei", sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 60px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-main {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title-sub {
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #1e293b;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tab-page {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-page.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
select, button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #1e293b;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
background: #111827;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.card.accent {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(15, 118, 110, 0.2));
|
||||
border-color: rgba(37, 99, 235, 0.5);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #0b1220;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #1f2937;
|
||||
padding: 16px;
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 15px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin-top: auto;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-meta {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 20px 16px 40px;
|
||||
}
|
||||
.actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tabs-bar {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user