康心伴Logo
康心伴WellAlly
PWA 开发

构建健康数据 PWA:Next.js + Chart.js 完整指南

学习如何使用 Next.js 14 和 Chart.js 构建渐进式 Web 应用(PWA),实现离线健康数据追踪、后台同步和推送通知。包含完整的 PWA 配置和性能优化策略。

W
WellAlly 开发团队
2026-03-08
20 分钟阅读

关键要点

  • PWA 核心三要素:Service Worker、Web App Manifest 和 HTTPS 是构建 PWA 的基础
  • next-pwa 配置简化:使用 next-pwa 插件自动生成 Service Worker 和处理缓存策略
  • Chart.js 响应式图表:创建适配各种屏幕尺寸的健康数据可视化
  • 离线数据存储:使用 IndexedDB 或本地存储管理离线健康数据
  • 后台同步策略:使用 Background Sync API 在网络恢复时自动同步数据

渐进式 Web 应用(PWA)结合了 Web 和原生应用的优点。对于健康数据追踪应用,PWA 的离线能力让用户可以随时随地记录健康指标,并在有网络时自动同步。

前置条件:

  • Next.js 14 基础
  • Chart.js 了解
  • Service Worker 基本概念

步骤 1:项目初始化

code
npx create-next-app@latest health-pwa --typescript --tailwind --app
cd health-pwa
npm install chart.js react-chartjs-2 next-pwa
npm install @types/chart.js
npm install date-fns clsx tailwind-merge
Code collapsed

步骤 2:配置 PWA

创建 next.config.js

code
// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
  disable: process.env.NODE_ENV === 'development',
  buildExcludes: ['middleware-manifest.json'],
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
};

module.exports = withPWA(nextConfig);
Code collapsed

创建 Manifest 文件 public/manifest.json

code
{
  "name": "康心伴 - 健康数据追踪",
  "short_name": "康心伴",
  "description": "追踪您的健康数据,包括心率、步数、睡眠等指标",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "categories": ["health", "fitness", "medical"],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "540x720",
      "type": "image/png"
    }
  ]
}
Code collapsed

步骤 3:创建离线存储服务

code
// lib/healthStorage.ts
import { openDB } from 'idb';
import { format, subDays, startOfDay } from 'date-fns';

export interface HealthMetric {
  id: string;
  userId: string;
  type: 'heart_rate' | 'steps' | 'weight' | 'sleep' | 'calories';
  value: number;
  unit: string;
  timestamp: number;
  synced: boolean;
}

export interface DailyStats {
  date: string;
  steps: number;
  calories: number;
  activeMinutes: number;
}

const DB_NAME = 'health-pwa-db';
const DB_VERSION = 1;

class HealthStorageService {
  private db: any;

  async init() {
    this.db = await openDB(DB_NAME, DB_VERSION, {
      upgrade(db) {
        // 健康指标存储
        if (!db.objectStoreNames.contains('metrics')) {
          const metricsStore = db.createObjectStore('metrics', {
            keyPath: 'id',
          });
          metricsStore.createIndex('userId', 'userId');
          metricsStore.createIndex('type', 'type');
          metricsStore.createIndex('timestamp', 'timestamp');
          metricsStore.createIndex('synced', 'synced');
        }

        // 每日统计缓存
        if (!db.objectStoreNames.contains('dailyStats')) {
          const statsStore = db.createObjectStore('dailyStats', {
            keyPath: 'date',
          });
          statsStore.createIndex('userId', 'userId');
        }

        // 同步队列
        if (!db.objectStoreNames.contains('syncQueue')) {
          const syncStore = db.createObjectStore('syncQueue', {
            keyPath: 'id',
            autoIncrement: true,
          });
        }
      },
    });
  }

  // 保存健康指标
  async saveMetric(metric: Omit<HealthMetric, 'id' | 'synced'>): Promise<string> {
    await this.init();

    const id = `${metric.type}_${metric.timestamp}_${Date.now()}`;
    const newMetric: HealthMetric = {
      ...metric,
      id,
      synced: false,
    };

    await this.db.put('metrics', newMetric);

    // 添加到同步队列
    await this.addToSyncQueue({
      type: 'metric',
      action: 'create',
      data: newMetric,
    });

    return id;
  }

  // 获取指定时间范围的指标
  async getMetrics(
    userId: string,
    type: string,
    startDate: Date,
    endDate: Date
  ): Promise<HealthMetric[]> {
    await this.init();

    return await this.db.getAllFromIndex(
      'metrics',
      'userId',
      IDBKeyRange.only(userId),
      (metric: HealthMetric) =>
        metric.type === type &&
        metric.timestamp >= startDate.getTime() &&
        metric.timestamp <= endDate.getTime()
    );
  }

  // 获取未同步的指标
  async getUnsyncedMetrics(): Promise<HealthMetric[]> {
    await this.init();

    return await this.db.getAllFromIndex(
      'metrics',
      'synced',
      IDBKeyRange.only(false)
    );
  }

  // 标记为已同步
  async markAsSynced(id: string): Promise<void> {
    await this.init();

    const metric = await this.db.get('metrics', id);
    if (metric) {
      metric.synced = true;
      await this.db.put('metrics', metric);
    }
  }

  // 保存每日统计
  async saveDailyStats(stats: DailyStats): Promise<void> {
    await this.init();
    await this.db.put('dailyStats', stats);
  }

  // 获取每日统计
  async getDailyStats(date: string): Promise<DailyStats | undefined> {
    await this.init();
    return await this.db.get('dailyStats', date);
  }

  // 获取本周统计
  async getWeeklyStats(userId: string): Promise<DailyStats[]> {
    await this.init();

    const today = startOfDay(new Date());
    const stats: DailyStats[] = [];

    for (let i = 6; i >= 0; i--) {
      const date = format(subDays(today, i), 'yyyy-MM-dd');
      const dayStats = await this.getDailyStats(date);
      stats.push(
        dayStats || {
          date,
          steps: 0,
          calories: 0,
          activeMinutes: 0,
        }
      );
    }

    return stats;
  }

  // 同步队列管理
  private async addToSyncQueue(item: any): Promise<void> {
    await this.init();
    await this.db.put('syncQueue', item);
  }

  async getSyncQueue(): Promise<any[]> {
    await this.init();
    return await this.db.getAll('syncQueue');
  }

  async clearSyncQueue(): Promise<void> {
    await this.init();
    await this.db.clear('syncQueue');
  }
}

export const healthStorage = new HealthStorageService();
Code collapsed

步骤 4:创建 Chart.js 组件

code
// components/HealthLineChart.tsx
'use client';

import React from 'react';
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
  Filler,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import { format } from 'date-fns';

ChartJS.register(
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
  Filler
);

interface HealthLineChartProps {
  data: Array<{ date: string; value: number }>;
  label: string;
  color?: string;
  unit?: string;
  fill?: boolean;
}

export function HealthLineChart({
  data,
  label,
  color = '#3b82f6',
  unit = '',
  fill = false,
}: HealthLineChartProps) {
  const chartData = {
    labels: data.map((d) => format(new Date(d.date), 'M/d')),
    datasets: [
      {
        label,
        data: data.map((d) => d.value),
        borderColor: color,
        backgroundColor: fill
          ? `${color}20`
          : color,
        borderWidth: 2,
        tension: 0.4,
        fill: fill,
        pointRadius: 4,
        pointHoverRadius: 6,
        pointBackgroundColor: color,
        pointBorderColor: '#fff',
        pointBorderWidth: 2,
      },
    ],
  };

  const options = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: {
        display: true,
        position: 'top' as const,
      },
      tooltip: {
        callbacks: {
          label: function (context: any) {
            return `${context.dataset.label}: ${context.parsed.y} ${unit}`;
          },
        },
      },
    },
    scales: {
      y: {
        beginAtZero: true,
        grid: {
          color: 'rgba(0, 0, 0, 0.05)',
        },
      },
      x: {
        grid: {
          display: false,
        },
      },
    },
    interaction: {
      intersect: false,
      mode: 'index' as const,
    },
  };

  return (
    <div className="relative h-64 w-full">
      <Line data={chartData} options={options} />
    </div>
  );
}
Code collapsed
code
// components/HealthBarChart.tsx
'use client';

import React from 'react';
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  BarElement,
  Title,
  Tooltip,
  Legend,
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
import { format } from 'date-fns';

ChartJS.register(
  CategoryScale,
  LinearScale,
  BarElement,
  Title,
  Tooltip,
  Legend
);

interface HealthBarChartProps {
  data: Array<{ date: string; value: number }>;
  label: string;
  color?: string;
  unit?: string;
  goal?: number;
}

export function HealthBarChart({
  data,
  label,
  color = '#22c55e',
  unit = '',
  goal,
}: HealthBarChartProps) {
  const chartData = {
    labels: data.map((d) => format(new Date(d.date), 'M/d')),
    datasets: [
      {
        label,
        data: data.map((d) => d.value),
        backgroundColor: data.map((d) =>
          goal && d.value >= goal ? color : `${color}80`
        ),
        borderRadius: 6,
        borderSkipped: false,
      },
    ],
  };

  const options = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: {
      legend: {
        display: false,
      },
      tooltip: {
        callbacks: {
          label: function (context: any) {
            let label = `${context.dataset.label}: ${context.parsed.y} ${unit}`;
            if (goal) {
              const percentage = Math.round((context.parsed.y / goal) * 100);
              label += ` (${percentage}%)`;
            }
            return label;
          },
        },
      },
    },
    scales: {
      y: {
        beginAtZero: true,
        grid: {
          color: 'rgba(0, 0, 0, 0.05)',
        },
      },
      x: {
        grid: {
          display: false,
        },
      },
    },
  };

  return (
    <div className="relative h-64 w-full">
      <Bar data={chartData} options={options} />
    </div>
  );
}
Code collapsed

步骤 5:创建 PWA 提示组件

code
// components/PWAInstallPrompt.tsx
'use client';

import React, { useEffect, useState } from 'react';

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

export function PWAInstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] =
    useState<BeforeInstallPromptEvent | null>(null);
  const [showPrompt, setShowPrompt] = useState(false);

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e as BeforeInstallPromptEvent);
      setShowPrompt(true);
    };

    window.addEventListener('beforeinstallprompt', handler);

    return () => {
      window.removeEventListener('beforeinstallprompt', handler);
    };
  }, []);

  const handleInstall = async () => {
    if (!deferredPrompt) return;

    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;

    if (outcome === 'accepted') {
      setShowPrompt(false);
    }

    setDeferredPrompt(null);
  };

  const handleDismiss = () => {
    setShowPrompt(false);
  };

  if (!showPrompt || !deferredPrompt) {
    return null;
  }

  return (
    <div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-lg p-4 z-50 border border-gray-200">
      <div className="flex items-start gap-4">
        <div className="flex-shrink-0">
          <div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
            <svg
              className="w-6 h-6 text-blue-600"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
              />
            </svg>
          </div>
        </div>
        <div className="flex-1">
          <h3 className="font-semibold text-gray-900">安装应用</h3>
          <p className="text-sm text-gray-600 mt-1">
            安装康心伴到您的设备,获得更好的体验
          </p>
        </div>
        <button
          onClick={handleDismiss}
          className="text-gray-400 hover:text-gray-600"
        >
          <svg
            className="w-5 h-5"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M6 18L18 6M6 6l12 12"
            />
          </svg>
        </button>
      </div>
      <button
        onClick={handleInstall}
        className="w-full mt-4 bg-blue-600 text-white py-2 rounded-lg font-medium hover:bg-blue-700 transition"
      >
        立即安装
      </button>
    </div>
  );
}
Code collapsed

步骤 6:创建同步状态组件

code
// components/SyncStatus.tsx
'use client';

import React, { useEffect, useState } from 'react';

type SyncStatus = 'synced' | 'syncing' | 'error' | 'offline';

export function SyncStatus() {
  const [status, setStatus] = useState<SyncStatus>('synced');
  const [pendingCount, setPendingCount] = useState(0);

  useEffect(() => {
    // 监听在线状态
    const handleOnline = () => {
      setStatus('syncing');
      syncPendingData();
    };

    const handleOffline = () => {
      setStatus('offline');
    };

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    // 检查初始状态
    if (!navigator.onLine) {
      setStatus('offline');
    }

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  const syncPendingData = async () => {
    try {
      // 获取待同步数据
      const unsyncedMetrics = await getUnsyncedMetrics();
      setPendingCount(unsyncedMetrics.length);

      // 同步到服务器
      for (const metric of unsyncedMetrics) {
        await syncMetricToServer(metric);
        await markAsSynced(metric.id);
      }

      setStatus('synced');
      setPendingCount(0);
    } catch (error) {
      console.error('Sync failed:', error);
      setStatus('error');
    }
  };

  const statusConfig = {
    synced: {
      icon: '✓',
      text: '已同步',
      color: 'text-green-600',
      bgColor: 'bg-green-50',
    },
    syncing: {
      icon: '⟳',
      text: '同步中...',
      color: 'text-blue-600',
      bgColor: 'bg-blue-50',
    },
    error: {
      icon: '✕',
      text: '同步失败',
      color: 'text-red-600',
      bgColor: 'bg-red-50',
    },
    offline: {
      icon: '⚠',
      text: `离线 (${pendingCount} 条待同步)`,
      color: 'text-yellow-600',
      bgColor: 'bg-yellow-50',
    },
  };

  const config = statusConfig[status];

  return (
    <div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm ${config.bgColor} ${config.color}`}>
      <span className="animate-pulse" style={{ animationDuration: status === 'syncing' ? '1s' : '0s' }}>
        {config.icon}
      </span>
      <span>{config.text}</span>
    </div>
  );
}

// 辅助函数(实际实现中需要从 storage 模块导入)
async function getUnsyncedMetrics() {
  return [];
}

async function syncMetricToServer(metric: any) {
  // 实现同步逻辑
}

async function markAsSynced(id: string) {
  // 实现标记逻辑
}
Code collapsed

步骤 7:创建主页面

code
// app/page.tsx
import { HealthLineChart } from '@/components/HealthLineChart';
import { HealthBarChart } from '@/components/HealthBarChart';
import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
import { SyncStatus } from '@/components/SyncStatus';
import { healthStorage } from '@/lib/healthStorage';
import { format, subDays } from 'date-fns';

async function getHealthData() {
  const today = new Date();
  const lastWeek = subDays(today, 7);

  const heartRateData = await healthStorage.getMetrics(
    'user-1',
    'heart_rate',
    lastWeek,
    today
  );

  const stepsData = await healthStorage.getWeeklyStats('user-1');

  return {
    heartRate: heartRateData.map((d) => ({
      date: new Date(d.timestamp).toISOString(),
      value: d.value,
    })),
    steps: stepsData,
  };
}

export default async function HomePage() {
  const data = await getHealthData();

  return (
    <div className="min-h-screen bg-gray-50">
      {/* PWA 安装提示 */}
      <PWAInstallPrompt />

      {/* 头部 */}
      <header className="bg-white shadow-sm sticky top-0 z-10">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
          <h1 className="text-2xl font-bold text-gray-900">康心伴</h1>
          <SyncStatus />
        </div>
      </header>

      {/* 主内容 */}
      <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
        {/* 概览卡片 */}
        <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
          <MetricCard
            title="今日步数"
            value={data.steps[6]?.steps.toLocaleString() || '0'}
            unit="步"
            goal={10000}
            color="green"
          />
          <MetricCard
            title="消耗卡路里"
            value={data.steps[6]?.calories.toLocaleString() || '0'}
            unit="kcal"
            goal={2000}
            color="orange"
          />
          <MetricCard
            title="活动时间"
            value={data.steps[6]?.activeMinutes || '0'}
            unit="分钟"
            goal={60}
            color="blue"
          />
        </div>

        {/* 图表 */}
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
          <ChartCard
            title="本周心率趋势"
            chart={
              <HealthLineChart
                data={data.heartRate}
                label="心率 (BPM)"
                color="#ef4444"
                unit="BPM"
                fill
              />
            }
          />
          <ChartCard
            title="本周步数统计"
            chart={
              <HealthBarChart
                data={data.steps.map((d) => ({
                  date: d.date,
                  value: d.steps,
                }))}
                label="步数"
                color="#22c55e"
                unit="步"
                goal={10000}
              />
            }
          />
        </div>
      </main>
    </div>
  );
}

function MetricCard({
  title,
  value,
  unit,
  goal,
  color,
}: {
  title: string;
  value: string;
  unit: string;
  goal: number;
  color: string;
}) {
  const currentValue = parseInt(value.replace(/,/g, '')) || 0;
  const percentage = Math.min((currentValue / goal) * 100, 100);

  const colorClasses = {
    green: 'bg-green-50 text-green-600',
    orange: 'bg-orange-50 text-orange-600',
    blue: 'bg-blue-50 text-blue-600',
  };

  return (
    <div className="bg-white rounded-xl shadow-sm p-6">
      <div className="flex items-center justify-between mb-4">
        <h3 className="text-sm font-medium text-gray-600">{title}</h3>
        <div className={`w-10 h-10 rounded-lg ${colorClasses[color as keyof typeof colorClasses]} flex items-center justify-center`}>
          {/* Icon */}
        </div>
      </div>
      <div className="mb-2">
        <span className="text-3xl font-bold text-gray-900">{value}</span>
        <span className="text-sm text-gray-500 ml-1">{unit}</span>
      </div>
      <div className="w-full bg-gray-200 rounded-full h-2">
        <div
          className="h-2 rounded-full transition-all"
          style={{
            width: `${percentage}%`,
            backgroundColor: color === 'green' ? '#22c55e' : color === 'orange' ? '#f59e0b' : '#3b82f6',
          }}
        />
      </div>
      <p className="text-xs text-gray-500 mt-2">目标: {goal.toLocaleString()} {unit} ({Math.round(percentage)}%)</p>
    </div>
  );
}

function ChartCard({
  title,
  chart,
}: {
  title: string;
  chart: React.ReactNode;
}) {
  return (
    <div className="bg-white rounded-xl shadow-sm p-6">
      <h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
      {chart}
    </div>
  );
}
Code collapsed

步骤 8:配置 Layout

code
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: '康心伴 - 健康数据追踪',
  description: '追踪您的健康数据,包括心率、步数、睡眠等指标',
  manifest: '/manifest.json',
  themeColor: '#3b82f6',
  appleWebApp: {
    capable: true,
    statusBarStyle: 'default',
    title: '康心伴',
  },
  viewport: {
    width: 'device-width',
    initialScale: 1,
    maximumScale: 1,
    userScalable: false,
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-CN">
      <head>
        <link rel="icon" href="/favicon.ico" />
        <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
      </head>
      <body className={inter.className}>{children}</body>
    </html>
  );
}
Code collapsed

步骤 9:添加后台同步

code
// lib/backgroundSync.ts
import { healthStorage } from './healthStorage';

export class BackgroundSyncService {
  private swRegistration: ServiceWorkerRegistration | null = null;

  async init() {
    if ('serviceWorker' in navigator) {
      this.swRegistration = await navigator.serviceWorker.ready;
    }
  }

  async registerSync() {
    if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
      try {
        await this.swRegistration?.sync.register('health-data-sync');
      } catch (error) {
        console.error('Background sync registration failed:', error);
      }
    }
  }

  async syncPendingData() {
    const unsyncedMetrics = await healthStorage.getUnsyncedMetrics();

    if (unsyncedMetrics.length === 0) {
      return;
    }

    try {
      // 批量同步数据
      await fetch('/api/health/sync', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ metrics: unsyncedMetrics }),
      });

      // 标记为已同步
      for (const metric of unsyncedMetrics) {
        await healthStorage.markAsSynced(metric.id);
      }
    } catch (error) {
      console.error('Sync failed:', error);
      throw error;
    }
  }
}

export const backgroundSync = new BackgroundSyncService();
Code collapsed

步骤 10:Service Worker 事件处理

public/sw.js 中添加后台同步处理:

code
// public/sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'health-data-sync') {
    event.waitUntil(
      (async () => {
        try {
          // 从 IndexedDB 获取待同步数据
          const pendingData = await getPendingSyncData();

          // 发送到服务器
          await fetch('/api/health/sync', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(pendingData),
          });

          // 清除同步队列
          await clearSyncQueue();

          // 通知客户端同步完成
          self.clients.matchAll().then((clients) => {
            clients.forEach((client) => {
              client.postMessage({
                type: 'SYNC_COMPLETE',
                data: pendingData.length,
              });
            });
          });
        } catch (error) {
          console.error('Background sync failed:', error);
          throw error;
        }
      })()
    );
  }
});

async function getPendingSyncData() {
  // 实现从 IndexedDB 获取待同步数据
  return [];
}

async function clearSyncQueue() {
  // 实现清除同步队列
}
Code collapsed

总结

通过本教程,你学会了如何构建一个完整的健康数据 PWA:

  1. 使用 next-pwa 配置 Service Worker
  2. 创建 Web App Manifest
  3. 使用 IndexedDB 存储离线数据
  4. 集成 Chart.js 创建响应式图表
  5. 实现后台同步功能

扩展功能

  • 添加推送通知提醒
  • 实现数据导出功能
  • 集成可穿戴设备 API
  • 添加数据分享功能

参考资料

相关文章

#

文章标签

nextjs
pwa
chartjs
健康数据
离线支持

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅