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:
@@ -3,8 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#18a058" />
|
<meta name="theme-color" content="#18a058" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="FuelLog" />
|
||||||
<title>FuelLog 加油记</title>
|
<title>FuelLog 加油记</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"unplugin-auto-import": "^0.18.6",
|
"unplugin-auto-import": "^0.18.6",
|
||||||
"unplugin-vue-components": "^0.27.5",
|
"unplugin-vue-components": "^0.27.5",
|
||||||
"vite": "^5.4.11",
|
"vite": "^5.4.11",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vue-tsc": "^2.1.10"
|
"vue-tsc": "^2.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2891
-5
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "FuelLog 加油记",
|
||||||
|
"short_name": "FuelLog",
|
||||||
|
"description": "轻量、精美、移动端优先的个人加油记录与车辆燃油成本分析工具",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#fafafa",
|
||||||
|
"theme_color": "#18a058",
|
||||||
|
"lang": "zh-CN",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "pwa-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "pwa-512x512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{
|
||||||
|
"src": "pwa-maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -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'
|
||||||
@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
import AutoImport from 'unplugin-auto-import/vite'
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
// 注:这份是真实生效的 Vite 配置(以 .js 输出,Vite 优先读 .js 而不是 .ts)。
|
// 注:这份是真实生效的 Vite 配置(以 .js 输出,Vite 优先读 .js 而不是 .ts)。
|
||||||
@@ -29,6 +30,58 @@ export default defineConfig({
|
|||||||
resolvers: [NaiveUiResolver()],
|
resolvers: [NaiveUiResolver()],
|
||||||
dts: 'src/components.d.ts',
|
dts: 'src/components.d.ts',
|
||||||
}),
|
}),
|
||||||
|
VitePWA({
|
||||||
|
// 自动注册,生成 SW,预缓存全部构建产物
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: 'auto',
|
||||||
|
// 静态 manifest:用 public/manifest.webmanifest 写好,这里关掉自动生成,
|
||||||
|
// 避免 Node 在 Windows 中文环境下把 manifest 写成 GBK 乱码
|
||||||
|
manifest: false,
|
||||||
|
// 走 pwaAssetDirectory / filename 显式输出
|
||||||
|
filename: 'sw.js',
|
||||||
|
strategies: 'generateSW',
|
||||||
|
injectManifest: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'] },
|
||||||
|
includeAssets: [
|
||||||
|
'favicon.svg',
|
||||||
|
'pwa-192x192.png',
|
||||||
|
'pwa-512x512.png',
|
||||||
|
'pwa-maskable-512x512.png',
|
||||||
|
'manifest.webmanifest',
|
||||||
|
],
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api/],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// API GET 走 network-first,失败回退 cache
|
||||||
|
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-cache',
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 静态资源 cache-first
|
||||||
|
urlPattern: ({ request }) =>
|
||||||
|
['style', 'script', 'worker', 'image', 'font'].includes(request.destination),
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'asset-cache',
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
// 开发期关掉 SW(经常把 Vite HMR 卡住);真要验证再开
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -48,7 +101,5 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
// 不上 manualChunks:naive-ui 内部依赖(vueuc / @css-render/vue3-ssr)
|
|
||||||
// 是 transitive,直接列在 manualChunks 会触发 "Could not resolve"。
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user