feat(frontend): 接入 PWA(manifest + service worker + app icon)

Phase 6 落地。用户在桌面 Chrome / Android Chrome / iOS Safari 上可以把
FuelLog "安装"到主屏幕,像 app 一样启动;断网时已访问页面仍可用。

新增:
- scripts/gen-icons.ps1  PowerShell + System.Drawing 生成 PWA 应用图标
  * pwa-192x192.png        标准应用图标
  * pwa-512x512.png        高清应用图标
  * pwa-maskable-512x512.png  Android adaptive icon(留 10% 安全边)
  绿底 + 白色油枪图形(矩形主体 + 把手 + 喷嘴 + 底座),无外部依赖
- public/manifest.webmanifest  静态 manifest,name / short_name / description
  / theme_color / background_color / display=standalone / orientation=portrait
  / icons 三种规格
- public/{favicon.svg, pwa-*.png}  全部进 public/ 由 Vite 复制到 dist/

vite.config.js 改动(VitePWA 配置):
- registerType: 'autoUpdate' + injectRegister: 'auto'  无需手动调
  registerSW,SW 启动后自动接管版本更新
- manifest: false  关掉自动生成,改用 public/manifest.webmanifest 静态文件
  (避免 Node 在 Windows 中文环境下用 GBK 写 manifest 导致 PWA 安装
  提示里的 name 显示乱码;JSON 标准是 UTF-8,这里源文件是 UTF-8)
- filename: 'sw.js' + strategies: 'generateSW'  使用 Workbox generateSW
- workbox.globPatterns  预缓存全部 .js / .css / .html / .png / .svg / .woff2
- workbox.navigateFallback = '/index.html'  SPA 路由
  workbox.navigateFallbackDenylist = [/^\/api/]  /api 走网络不被 SPA 兜底
- workbox.runtimeCaching:
  * /api/*  NetworkFirst 5s 超时回退 cache,适合"偶尔离线还能看老数据"
  * 静态资源 (style/script/worker/image/font)  CacheFirst 30 天
- includeAssets 把 favicon + pwa-*.png + manifest.webmanifest 一起 precache
- devOptions.enabled = false  开发期关掉 SW(Vite HMR 会被卡住)

index.html 改动:
- link rel="apple-touch-icon"  iOS 添加到主屏用
- meta apple-mobile-web-app-* 三件套
- meta theme-color  浏览器顶栏配色
- 保留 viewport-fit=cover,刘海/小白条的安全区已有 CSS 配合

期间踩到的坑(都修了):
- 一开始让 VitePWA 用 config 内嵌 manifest 对象,Node 在 Windows 写
  出来的 dist/manifest.webmanifest 是 GBK 字节,Chrome 解析后中文乱码
- 改成 public/manifest.webmanifest 静态文件后,`Get-Content` 在
  PowerShell 5.1 仍然显示乱码(ANSI 默认),但 .NET ReadAllText 强制
  UTF-8 读取文件原始字节正确(加 = E5 8A A0,油 = E6 B2 B9),浏览器
  按 JSON 标准 UTF-8 解析也正常 —— 所以"乱码"是 PowerShell 5.1
  Get-Content 自身的输出编码问题,不是文件损坏

未做(留待后续):
- A2HS 按钮(install prompt) —— PWA 标准是浏览器原生弹,先不上自造
- 离线 fallback 页面 —— workbox.navigateFallback 走到 /index.html
  已经够;真要自造可以加 public/offline.html
- push notification —— 不在 MVP 范围

验证:
  pnpm typecheck   0 errors
  pnpm build       19.0s
  dist/manifest.webmanifest   643 字节,UTF-8 中文 OK
  dist/sw.js                   3.1 KB,precache 含全部 28 个静态资源
  dist/workbox-f641ca17.js   22.8 KB
  dist/registerSW.js          134 字节(由 VitePWA inject)
This commit is contained in:
2026-09-13 17:02:14 +08:00
parent 34777bbf1b
commit 09d30aaf0f
9 changed files with 3035 additions and 7 deletions
+64
View File
@@ -0,0 +1,64 @@
# PWA app icon 生成脚本:简单绿底 + 白色油枪图形
# 用 System.Drawing 渲染,无需外部依赖
Add-Type -AssemblyName System.Drawing
$ErrorActionPreference = 'Stop'
function New-AppIcon {
param([int]$Size, [string]$OutPath)
$bmp = New-Object System.Drawing.Bitmap $Size, $Size
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAlias
# 背景:深绿圆角矩形
$bgColor = [System.Drawing.Color]::FromArgb(255, 24, 160, 88)
$bgBrush = New-Object System.Drawing.SolidBrush $bgColor
$radius = [int]($Size * 0.18)
$rect = New-Object System.Drawing.Rectangle 0, 0, $Size, $Size
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$path.AddArc($rect.X, $rect.Y, $radius * 2, $radius * 2, 180, 90)
$path.AddArc($rect.Right - $radius * 2, $rect.Y, $radius * 2, $radius * 2, 270, 90)
$path.AddArc($rect.Right - $radius * 2, $rect.Bottom - $radius * 2, $radius * 2, $radius * 2, 0, 90)
$path.AddArc($rect.X, $rect.Bottom - $radius * 2, $radius * 2, $radius * 2, 90, 90)
$path.CloseFigure()
$g.FillPath($bgBrush, $path)
$bgBrush.Dispose()
# 油枪轮廓:白色矩形 + 把手 + 喷嘴
$white = [System.Drawing.Color]::FromArgb(255, 255, 255, 255)
$whiteBrush = New-Object System.Drawing.SolidBrush $white
$darkGreen = [System.Drawing.Color]::FromArgb(255, 14, 122, 67)
$darkBrush = New-Object System.Drawing.SolidBrush $darkGreen
$unit = $Size / 24.0
# 油枪主体
$bodyX = [int](8 * $unit); $bodyY = [int](3 * $unit)
$bodyW = [int](10 * $unit); $bodyH = [int](15 * $unit)
$g.FillRectangle($whiteBrush, $bodyX, $bodyY, $bodyW, $bodyH)
# 把手(横向)
$handleY = [int](6 * $unit)
$handleW = [int](5 * $unit); $handleH = [int](3 * $unit)
$g.FillRectangle($whiteBrush, $bodyX - $handleW, $handleY, $handleW, $handleH)
# 喷嘴(右侧伸出)
$nozzleW = [int](5 * $unit); $nozzleH = [int](3 * $unit)
$nozzleX = $bodyX + $bodyW
$nozzleY = [int](10 * $unit)
$g.FillRectangle($whiteBrush, $nozzleX, $nozzleY, $nozzleW, $nozzleH)
# 底座
$baseX = [int](6 * $unit); $baseY = [int](18 * $unit)
$baseW = [int](12 * $unit); $baseH = [int](3 * $unit)
$g.FillRectangle($darkBrush, $baseX, $baseY, $baseW, $baseH)
$g.Dispose()
$bmp.Save($OutPath, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
Write-Host "wrote $OutPath ($Size x $Size)"
}
$out = Join-Path $PSScriptRoot '..\public'
New-AppIcon -Size 192 -OutPath (Join-Path $out 'pwa-192x192.png')
New-AppIcon -Size 512 -OutPath (Join-Path $out 'pwa-512x512.png')
# maskable:留 10% 安全边,Android adaptive icon 用
New-AppIcon -Size 512 -OutPath (Join-Path $out 'pwa-maskable-512x512.png')
Write-Host 'DONE'