关于 pwa-setup
pwa-setup 是一项用于配置渐进式 Web 应用的开发技能,适合希望提供可安装、类应用浏览体验的 Web 团队。内容涵盖清单、移动端显示、安全区域和安装提示,并提供 Next.js 示例。
主要功能
- 01配置 Web App Manifest
- 02设置移动端元数据
- 03处理刘海屏安全区域
- 04安装提示 Hook 与横幅
- 05PWA 图标要求
适用场景
- 01让 Web 应用支持安装
- 02为移动用户提供主屏幕入口
- 03适配刘海屏布局
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ dadbodgeoff/ drift --skill pwa-setup安装后在 Claude Code 中输入/pwa-setup确认已加载。
详细介绍
PWA 配置
配置渐进式 Web 应用,带来类应用的浏览器体验。
何时使用此技能
- 用户想要“安装”你的 Web 应用
- 移动端用户需要从主屏幕访问
- 需要实现类应用行为,但不想开发原生应用
- 需要支持带刘海的设备(iPhone 等)
核心概念
PWA 需要:
- Web 应用清单 - 应用元数据和图标
- 移动端 meta 标签 - 视口和主题配置
- 安全区域 - 适配带刘海的设备
- 安装提示 - 自定义安装体验
实现
TypeScript(Next.js)
// app/manifest.ts
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: "My SaaS App",
short_name: "MySaaS",
description: "Your app description here",
start_url: '/dashboard',
display: 'standalone',
background_color: '#0f172a',
theme_color: '#14b8a6',
orientation: 'portrait-primary',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{ name: 'Dashboard', url: '/dashboard', description: 'Go to dashboard' },
{ name: 'Settings', url: '/settings', description: 'App settings' },
],
categories: ['productivity', 'utilities'],
};
}
根布局元数据
// app/layout.tsx
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
title: "My SaaS App",
description: "Your app description",
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: "MySaaS",
},
applicationName: "MySaaS",
openGraph: {
title: "My SaaS App",
description: "Your app description",
type: 'website',
siteName: "MySaaS",
},
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: '#14b8a6',
viewportFit: 'cover',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-neutral-900 text-neutral-cream min-h-screen">
{children}
</body>
</html>
);
}
安全区域 CSS
/* globals.css */
/* Safe area for bottom navigation (notched devices) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* Safe area for top header */
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
/* Full safe area padding */
.safe-area-all {
padding-top: env(safe-area-inset-top, 0);
padding-right: env(safe-area-inset-right, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
}
安装提示 Hook
// hooks/useInstallPrompt.ts
'use client';
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function useInstallPrompt() {
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const promptInstall = async () => {
if (!installPrompt) return false;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsInstalled(true);
setInstallPrompt(null);
}
return outcome === 'accepted';
};
return {
canInstall: !!installPrompt && !isInstalled,
isInstalled,
promptInstall,
};
}
安装横幅组件
// components/InstallBanner.tsx
'use client';
import { useInstallPrompt } from '@/hooks/useInstallPrompt';
export function InstallBanner() {
const { canInstall, promptInstall } = useInstallPrompt();
if (!canInstall) return null;
return (
<div className="fixed bottom-20 left-4 right-4 bg-primary-600 text-white p-4 rounded-lg shadow-lg md:hidden">
<p className="text-sm mb-2">Install our app for a better experience</p>
<button
onClick={promptInstall}
className="w-full py-2 bg-white text-primary-600 rounded font-medium"
>
Install App
</button>
</div>
);
}
带安全区域的移动端导航
// components/MobileNav.tsx
export function MobileNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 bg-neutral-800 border-t border-neutral-700 z-30 md:hidden safe-area-bottom">
<div className="flex justify-around py-2">
<NavItem href="/dashboard" icon={HomeIcon} label="Home" />
<NavItem href="/search" icon={SearchIcon} label="Search" />
<NavItem href="/settings" icon={SettingsIcon} label="Settings" />
</div>
</nav>
);
}
图标要求
public/
├── icons/
│ ├── icon-192.png # Standard icon
│ ├── icon-512.png # Large icon
│ └── icon-maskable.png # Maskable (with safe zone padding)
├── favicon.ico
└── apple-touch-icon.png # 180x180 for iOS
可遮罩图标安全区域
┌─────────────────────┐
│ │
│ ┌───────────┐ │
│ │ LOGO │ │ ← Content in center 80%
│ └───────────┘ │
│ │
└─────────────────────┘
使用示例
测试 PWA
- Chrome DevTools → Application → Manifest
- Lighthouse → PWA 审核
- 移动端 → 添加到主屏幕
检测独立模式
function isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}
最佳实践
- 使用带安全区域的可遮罩图标
- 设置 theme_color 以匹配品牌
- 为带刘海的设备处理安全区域
- 在合适的时机提供安装提示
- 在真实移动设备上进行测试
常见错误
- 缺少可遮罩图标(在 Android 上显示难看)
- 未处理安全区域(内容延伸到刘海下方)
- 立即显示安装提示(令人厌烦)
- start_url 错误(打开了错误的页面)
- 缺少 apple-touch-icon(iOS 回退方案)
相关模式
- design-tokens - 统一主题风格
- mobile-components - 响应式组件
评论
支持 Markdown