feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+980
View File
@@ -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()
+565
View File
@@ -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>
+219
View File
@@ -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;
}
}