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 = '
暂无类型数据
' 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 = `
${name}
${item.type}
${valueStr}${item.unit ? ` · ${item.unit}` : ''} ` 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 = '
暂无趋势数据
' return } items.forEach(item => { const node = document.createElement('div') node.className = 'list-item' node.innerHTML = `${item.title}${fmtNum(item.value, 1)} ${item.unit}` 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 = '
暂无异常高值
' return } items.slice(-10).reverse().forEach(item => { const node = document.createElement('div') node.className = 'list-item' node.innerHTML = `${item.type} ${fmtDate(item.date)}${fmtNum(item.value, 1)} ${item.unit}` 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 = '
暂无深度数据
' return } workouts.forEach(r => { const node = document.createElement('div') node.className = 'list-item' node.innerHTML = `训练 ${fmtDate(r.start_time)}${fmtNum(r.duration_min, 1)} 分钟 · ${fmtNum(r.distance_km, 2)} km` box.appendChild(node) }) routes.forEach(r => { const node = document.createElement('div') node.className = 'list-item' node.innerHTML = `路线 ${fmtDate(r.route_time)}${fmtNum(r.distance_km, 2)} km · ${fmtNum(r.duration_min, 1)} 分钟` box.appendChild(node) }) ecg.forEach(r => { const node = document.createElement('div') node.className = 'list-item' node.innerHTML = `ECG ${fmtDate(r.record_time)}${r.classification || '未知'} · ${fmtNum(r.sampling_rate, 0)} Hz` 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()