核心要点
- 完整数据集成: 睡眠、活动、心率、体温等 Oura Ring API 数据的完整集成
- 实时数据更新: 使用 WebSocket 和轮询实现数据的实时更新
- 智能数据洞察: 基于机器学习的健康建议和趋势分析
- 响应式设计: 支持桌面、平板和移动设备的响应式 UI
- 数据导出功能: 支持 PDF 和 Excel 格式的健康报告导出
Oura Ring 是目前市场上最先进的智能戒指设备之一,能够全天候追踪用户的睡眠、活动、心率等健康数据。本教程将指导您构建一个功能完整的健康仪表板,将 Oura Ring API 数据转化为直观、可操作的健康洞察。
项目架构
技术栈选择
前端框架: Next.js 14+ (App Router) 状态管理: Zustand 数据可视化: Recharts + D3.js UI 组件: shadcn/ui 样式: Tailwind CSS API 客户端: Axios 认证: NextAuth.js 数据库: PostgreSQL (Prisma) 缓存: Redis
系统架构图
正在渲染图表...
graph TB
A[用户设备] --> B[Next.js 前端]
B --> C[API 路由层]
C --> D[认证中间件]
D --> E[Oura Ring API]
C --> F[数据缓存层]
F --> G[Redis]
C --> H[数据持久层]
H --> I[PostgreSQL]
B --> J[状态管理]
J --> K[数据可视化]
K --> L[睡眠图表]
K --> M[活动图表]
K --> N[心率图表]
K --> O[健康评分]
style B fill:#4f46e5,stroke:#333,stroke-width:2px
style C fill:#06b6d4,stroke:#333,stroke-width:2px
style K fill:#10b981,stroke:#333,stroke-width:2px项目初始化
1. 创建 Next.js 项目
code
npx create-next-app@latest oura-dashboard --typescript --tailwind --app
cd oura-dashboard
Code collapsed
2. 安装依赖
code
# UI 和状态管理
npm install zustand @tanstack/react-query
# 数据可视化
npm install recharts d3 @types/d3
# UI 组件
npx shadcn-ui@latest init
npx shadcn-ui@latest add card
npx shadcn-ui@latest add button
npx shadcn-ui@latest add tabs
# API 和工具
npm install axios date-fns zod
# 认证
npm install next-auth @auth/prisma-adapter
# 数据库
npm install prisma @prisma/client
npx prisma init
Code collapsed
3. 环境配置
code
# .env.local
# Oura Ring API
OURA_CLIENT_ID=your_client_id
OURA_CLIENT_SECRET=your_client_secret
OURA_REDIRECT_URI=http://localhost:3000/api/auth/callback/oura
# 数据库
DATABASE_URL=postgresql://user:password@localhost:5432/oura_dashboard
# NextAuth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_nextauth_secret
# Redis
REDIS_URL=redis://localhost:6379
Code collapsed
数据库设计
Prisma Schema
code
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
ouraUserId String? @unique
accessToken String? @db.Text
refreshToken String? @db.Text
tokenExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sleepData SleepData[]
activityData ActivityData[]
heartRateData HeartRateData[]
}
model SleepData {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
timestamp DateTime
score Int
duration Int // 分钟
efficiency Int
latency Int
// 睡眠阶段(分钟)
rem Int
deep Int
light Int
awake Int
// 心率数据
hrLowest Int?
hrAverage Int?
rmssd Int? // HRV
// 温度
tempDeviation Float?
bedTimeStart DateTime
bedTimeEnd DateTime
createdAt DateTime @default(now())
@@unique([userId, timestamp])
@@index([userId, timestamp])
}
model ActivityData {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
timestamp DateTime
score Int
steps Int
calories Int
activeCalories Int
// 活动时间(分钟)
lowActivity Int
mediumActivity Int
highActivity Int
nonWearTime Int
restTime Int
sedentaryTime Int
equivalentWalkingDistance Float?
createdAt DateTime @default(now())
@@unique([userId, timestamp])
@@index([userId, timestamp])
}
model HeartRateData {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
timestamp DateTime
heartRate Int
source String // 'watch' or 'ring'
createdAt DateTime @default(now())
@@unique([userId, timestamp])
@@index([userId, timestamp])
}
Code collapsed
API 集成层
Oura Ring API 客户端
code
// lib/oura-client.ts
import axios, { AxiosInstance } from 'axios';
import type { OuraDailySleep, OuraDailyActivity, OuraHeartRate } from '@/types/oura';
export class OuraClient {
private client: AxiosInstance;
private accessToken: string;
constructor(accessToken: string) {
this.accessToken = accessToken;
this.client = axios.create({
baseURL: 'https://api.ouraring.com/v2',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
}
/**
* 获取每日睡眠数据
*/
async getDailySleep(startDate: Date, endDate: Date): Promise<OuraDailySleep[]> {
const response = await this.client.get('/usercollection/daily_sleep', {
params: {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
},
});
return response.data.data;
}
/**
* 获取每日活动数据
*/
async getDailyActivity(startDate: Date, endDate: Date): Promise<OuraDailyActivity[]> {
const response = await this.client.get('/usercollection/daily_activity', {
params: {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
},
});
return response.data.data;
}
/**
* 获取心率数据
*/
async getHeartRate(startDate: Date, endDate: Date): Promise<OuraHeartRate[]> {
const response = await this.client.get('/usercollection/heartrate', {
params: {
start_datetime: startDate.toISOString(),
end_datetime: endDate.toISOString(),
},
});
return response.data.data;
}
/**
* 获取健康摘要
*/
async getDailySummary(date: Date) {
const [sleep, activity] = await Promise.all([
this.getDailySleep(date, date),
this.getDailyActivity(date, date),
]);
return {
sleep: sleep[0] || null,
activity: activity[0] || null,
date: date.toISOString().split('T')[0],
};
}
}
Code collapsed
数据同步服务
code
// lib/data-sync.ts
import { OuraClient } from './oura-client';
import { prisma } from './db';
export class DataSyncService {
/**
* 同步用户的 Oura Ring 数据
*/
static async syncUserData(userId: string, accessToken: string) {
const client = new OuraClient(accessToken);
// 获取最近 7 天的数据
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
try {
// 并行获取所有数据
const [sleepData, activityData, heartRateData] = await Promise.all([
client.getDailySleep(startDate, endDate),
client.getDailyActivity(startDate, endDate),
client.getHeartRate(startDate, endDate),
]);
// 保存到数据库
await this.saveSleepData(userId, sleepData);
await this.saveActivityData(userId, activityData);
await this.saveHeartRateData(userId, heartRateData);
return {
success: true,
synced: {
sleep: sleepData.length,
activity: activityData.length,
heartRate: heartRateData.length,
},
};
} catch (error) {
console.error('Data sync failed:', error);
throw error;
}
}
/**
* 保存睡眠数据
*/
private static async saveSleepData(userId: string, data: OuraDailySleep[]) {
const operations = data.map((item) =>
prisma.sleepData.upsert({
where: {
userId_timestamp: {
userId,
timestamp: new Date(item.timestamp),
},
},
create: {
userId,
timestamp: new Date(item.timestamp),
score: item.score,
duration: item.duration,
efficiency: item.efficiency,
latency: item.latency,
rem: item.rem,
deep: item.deep,
light: item.light,
awake: item.awake,
hrLowest: item.hr_lowest,
hrAverage: item.hr_average,
rmssd: item.rmssd,
tempDeviation: item.temperature_deviation,
bedTimeStart: new Date(item.bedtime_start),
bedTimeEnd: new Date(item.bedtime_end),
},
update: {
score: item.score,
duration: item.duration,
efficiency: item.efficiency,
// ... 更新其他字段
},
})
);
await Promise.all(operations);
}
/**
* 保存活动数据
*/
private static async saveActivityData(userId: string, data: OuraDailyActivity[]) {
// 类似睡眠数据的实现
}
/**
* 保存心率数据
*/
private static async saveHeartRateData(userId: string, data: OuraHeartRate[]) {
// 类似实现
}
}
Code collapsed
状态管理
Zustand Store
code
// store/use-dashboard-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface DashboardState {
// 选中的日期范围
dateRange: {
from: Date;
to: Date;
};
// 选中的数据类型
selectedMetrics: ('sleep' | 'activity' | 'heartRate')[];
// UI 状态
sidebarOpen: boolean;
isLoading: boolean;
// Actions
setDateRange: (from: Date, to: Date) => void;
toggleMetric: (metric: 'sleep' | 'activity' | 'heartRate') => void;
toggleSidebar: () => void;
setLoading: (loading: boolean) => void;
}
export const useDashboardStore = create<DashboardState>()(
persist(
(set) => ({
// 初始状态
dateRange: {
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
to: new Date(),
},
selectedMetrics: ['sleep', 'activity', 'heartRate'],
sidebarOpen: true,
isLoading: false,
// Actions
setDateRange: (from, to) =>
set({ dateRange: { from, to } }),
toggleMetric: (metric) =>
set((state) => ({
selectedMetrics: state.selectedMetrics.includes(metric)
? state.selectedMetrics.filter((m) => m !== metric)
: [...state.selectedMetrics, metric],
})),
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setLoading: (loading) =>
set({ isLoading: loading }),
}),
{
name: 'dashboard-storage',
}
)
);
Code collapsed
数据可视化组件
睡眠质量图表
code
// components/charts/SleepQualityChart.tsx
'use client';
import { useMemo } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
interface SleepQualityChartProps {
data: Array<{
timestamp: Date;
score: number;
duration: number;
rem: number;
deep: number;
light: number;
}>;
}
export function SleepQualityChart({ data }: SleepQualityChartProps) {
const chartData = useMemo(() => {
return data.map((item) => ({
date: format(item.timestamp, 'MM/dd', { locale: zhCN }),
score: item.score,
duration: Math.round(item.duration / 60), // 转换为小时
rem: Math.round(item.rem),
deep: Math.round(item.deep),
light: Math.round(item.light),
}));
}, [data]);
return (
<ResponsiveContainer width: "100%" height={300}>
<LineChart data={chartData}>
<XAxis
dataKey: "date"
stroke: "#94a3b8"
fontSize={12}
/>
<YAxis
stroke: "#94a3b8"
fontSize={12}
domain={[0, 100]}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(15, 23, 42, 0.9)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
}}
/>
<Line
type: "monotone"
dataKey: "score"
stroke: "#8b5cf6"
strokeWidth={2}
dot={{ fill: '#8b5cf6', r: 4 }}
activeDot={{ r: 6 }}
name: "睡眠评分"
/>
</LineChart>
</ResponsiveContainer>
);
}
Code collapsed
活动环形图
code
// components/charts/ActivityRingChart.tsx
'use client';
import { useMemo } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts';
interface ActivityRingChartProps {
data: {
lowActivity: number;
mediumActivity: number;
highActivity: number;
};
}
const COLORS = {
low: '#22c55e',
medium: '#eab308',
high: '#ef4444',
};
export function ActivityRingChart({ data }: ActivityRingChartProps) {
const chartData = useMemo(() => {
return [
{ name: '低强度活动', value: data.lowActivity, color: COLORS.low },
{ name: '中等强度活动', value: data.mediumActivity, color: COLORS.medium },
{ name: '高强度活动', value: data.highActivity, color: COLORS.high },
];
}, [data]);
return (
<ResponsiveContainer width: "100%" height={250}>
<PieChart>
<Pie
data={chartData}
cx: "50%"
cy: "50%"
innerRadius={60}
outerRadius={100}
paddingAngle={5}
dataKey: "value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Legend
verticalAlign: "bottom"
height={36}
iconType: "circle"
/>
<Tooltip
formatter={(value: number) => `${Math.round(value)} 分钟`}
/>
</PieChart>
</ResponsiveContainer>
);
}
Code collapsed
心率图表
code
// components/charts/HeartRateChart.tsx
'use client';
import { useMemo } from 'react';
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { format } from 'date-fns';
interface HeartRateChartProps {
data: Array<{
timestamp: Date;
heartRate: number;
}>;
}
export function HeartRateChart({ data }: HeartRateChartProps) {
const chartData = useMemo(() => {
return data.map((item) => ({
time: format(item.timestamp, 'HH:mm'),
heartRate: item.heartRate,
}));
}, [data]);
return (
<ResponsiveContainer width: "100%" height={200}>
<AreaChart data={chartData}>
<XAxis
dataKey: "time"
stroke: "#94a3b8"
fontSize={12}
/>
<YAxis
stroke: "#94a3b8"
fontSize={12}
domain={['dataMin - 10', 'dataMax + 10']}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(15, 23, 42, 0.9)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
}}
formatter={(value: number) => [`${value} bpm`, '心率']}
/>
<Area
type: "monotone"
dataKey: "heartRate"
stroke: "#f43f5e"
fill: "#f43f5e"
fillOpacity={0.3}
/>
</AreaChart>
</ResponsiveContainer>
);
}
Code collapsed
主仪表板组件
code
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { getDashboardData } from '@/lib/dashboard-data';
import { DashboardHeader } from '@/components/dashboard/DashboardHeader';
import { SleepCard } from '@/components/dashboard/SleepCard';
import { ActivityCard } from '@/components/dashboard/ActivityCard';
import { HeartRateCard } from '@/components/dashboard/HeartRateCard';
import { HealthScoreCard } from '@/components/dashboard/HealthScoreCard';
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
const dashboardData = await getDashboardData(session.user.id);
return (
<div className: "min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<DashboardHeader user={session.user} />
<main className: "container mx-auto px-4 py-8">
{/* 概览卡片 */}
<div className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<HealthScoreCard
title: "健康评分"
score={dashboardData.healthScore}
trend={dashboardData.healthScoreTrend}
/>
<SleepCard
score={dashboardData.sleepScore}
duration={dashboardData.sleepDuration}
efficiency={dashboardData.sleepEfficiency}
/>
<ActivityCard
score={dashboardData.activityScore}
steps={dashboardData.steps}
calories={dashboardData.calories}
/>
<HeartRateCard
resting={dashboardData.restingHeartRate}
variability={dashboardData.hrv}
/>
</div>
{/* 详细图表 */}
<div className: "grid grid-cols-1 lg:grid-cols-2 gap-6">
<SleepChart data={dashboardData.sleepData} />
<ActivityChart data={dashboardData.activityData} />
<HeartRateChart data={dashboardData.heartRateData} />
<TrendsChart data={dashboardData.trendsData} />
</div>
</main>
</div>
);
}
Code collapsed
数据导出功能
PDF 报告生成
code
// lib/export-pdf.ts
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
export async function generateHealthReport(data: DashboardData) {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([600, 800]);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
let yPosition = 750;
// 标题
page.drawText('健康报告', {
x: 300,
y: yPosition,
size: 24,
font,
color: rgb(0, 0, 0),
align: 'center',
});
yPosition -= 50;
// 日期范围
page.drawText(`报告期: ${data.dateRange.from} - ${data.dateRange.to}`, {
x: 50,
y: yPosition,
size: 12,
font,
});
yPosition -= 40;
// 睡眠数据
page.drawText('睡眠质量', {
x: 50,
y: yPosition,
size: 16,
font,
});
yPosition -= 25;
page.drawText(`评分: ${data.sleepScore}/100`, {
x: 70,
y: yPosition,
size: 12,
font,
});
yPosition -= 20;
page.drawText(`平均时长: ${data.sleepDuration} 小时`, {
x: 70,
y: yPosition,
size: 12,
font,
});
yPosition -= 40;
// 活动数据
page.drawText('活动数据', {
x: 50,
y: yPosition,
size: 16,
font,
});
yPosition -= 25;
page.drawText(`评分: ${data.activityScore}/100`, {
x: 70,
y: yPosition,
size: 12,
font,
});
yPosition -= 20;
page.drawText(`平均步数: ${data.steps}`, {
x: 70,
y: yPosition,
size: 12,
font,
});
const pdfBytes = await pdfDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
Code collapsed
总结
本教程详细介绍了如何使用 Oura Ring API 构建功能完整的健康仪表板。通过正确的架构设计、数据可视化和用户体验优化,可以创建一个专业、可靠的健康数据平台。
关键要点:
- 使用 Next.js App Router 实现高性能 SSR
- Zustand 轻量级状态管理
- Recharts 实现美观的数据可视化
- 定期数据同步保证数据新鲜度
- 数据导出功能提升用户体验