Add PWA install support for mobile and desktop
Register service worker, install prompt UI, iOS meta tags, and document install steps in DEPLOY.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { defineConfig } from 'vitepress'
|
import { defineConfig } from 'vitepress'
|
||||||
|
import { createPwaPlugin } from './pwa.mts'
|
||||||
import { missingAssetsPlugin } from './missing-assets-plugin.mjs'
|
import { missingAssetsPlugin } from './missing-assets-plugin.mjs'
|
||||||
import { generateRewrites } from './rewrites.mts'
|
import { generateRewrites } from './rewrites.mts'
|
||||||
import { generateSidebar } from './sidebar.mts'
|
import { generateSidebar } from './sidebar.mts'
|
||||||
@@ -37,6 +38,9 @@ export default defineConfig({
|
|||||||
['meta', { name: 'msapplication-TileImage', content: '/apple-touch-icon.png' }],
|
['meta', { name: 'msapplication-TileImage', content: '/apple-touch-icon.png' }],
|
||||||
['meta', { name: 'apple-mobile-web-app-title', content: '道德经' }],
|
['meta', { name: 'apple-mobile-web-app-title', content: '道德经' }],
|
||||||
['meta', { name: 'application-name', content: '道德经' }],
|
['meta', { name: 'application-name', content: '道德经' }],
|
||||||
|
['meta', { name: 'mobile-web-app-capable', content: 'yes' }],
|
||||||
|
['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }],
|
||||||
|
['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }],
|
||||||
],
|
],
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
logo: { src: '/favicon.ico', width: 24, height: 24 },
|
logo: { src: '/favicon.ico', width: 24, height: 24 },
|
||||||
@@ -54,7 +58,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [missingAssetsPlugin()],
|
plugins: [missingAssetsPlugin(), createPwaPlugin()],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export function createPwaPlugin() {
|
||||||
|
return VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: 'auto',
|
||||||
|
manifestFilename: 'site.webmanifest',
|
||||||
|
includeAssets: [
|
||||||
|
'favicon.ico',
|
||||||
|
'favicon.svg',
|
||||||
|
'favicon.png',
|
||||||
|
'apple-touch-icon.png',
|
||||||
|
'icon-192.png',
|
||||||
|
'icon-512.png',
|
||||||
|
],
|
||||||
|
manifest: {
|
||||||
|
name: '道德经 · 传统文化典籍',
|
||||||
|
short_name: '道德经',
|
||||||
|
description: '传统文化典籍资料库',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
theme_color: '#0f3460',
|
||||||
|
background_color: '#1a1a2e',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait-primary',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
categories: ['books', 'education'],
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: [
|
||||||
|
'assets/**/*.{js,css,woff2}',
|
||||||
|
'favicon.ico',
|
||||||
|
'favicon.svg',
|
||||||
|
'favicon.png',
|
||||||
|
'icon-192.png',
|
||||||
|
'icon-512.png',
|
||||||
|
'apple-touch-icon.png',
|
||||||
|
'vp-icons.css',
|
||||||
|
'site.webmanifest',
|
||||||
|
],
|
||||||
|
globIgnores: ['**/*.html', 'images/**', 'assets/**/*.png', 'assets/**/*.jpg', 'assets/**/*.webp'],
|
||||||
|
navigateFallback: null,
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const showAndroidInstall = ref(false)
|
||||||
|
const showIOSHint = ref(false)
|
||||||
|
const dismissed = ref(false)
|
||||||
|
let deferredPrompt: BeforeInstallPromptEvent | null = null
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = computed(
|
||||||
|
() => !dismissed.value && (showAndroidInstall.value || showIOSHint.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const isStandalone =
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
(window.navigator as Navigator & { standalone?: boolean }).standalone
|
||||||
|
|
||||||
|
if (isStandalone) return
|
||||||
|
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||||
|
if (isIOS) {
|
||||||
|
showIOSHint.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
deferredPrompt = event as BeforeInstallPromptEvent
|
||||||
|
showAndroidInstall.value = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function installApp() {
|
||||||
|
if (!deferredPrompt) return
|
||||||
|
await deferredPrompt.prompt()
|
||||||
|
await deferredPrompt.userChoice
|
||||||
|
deferredPrompt = null
|
||||||
|
showAndroidInstall.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
dismissed.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="install-app">
|
||||||
|
<div class="install-app__content">
|
||||||
|
<p v-if="showAndroidInstall" class="install-app__text">
|
||||||
|
可将「道德经」安装到桌面,像 App 一样使用。
|
||||||
|
</p>
|
||||||
|
<p v-else class="install-app__text">
|
||||||
|
iPhone:点击 Safari 底部分享按钮,选择「添加到主屏幕」。
|
||||||
|
</p>
|
||||||
|
<div class="install-app__actions">
|
||||||
|
<button
|
||||||
|
v-if="showAndroidInstall"
|
||||||
|
type="button"
|
||||||
|
class="install-app__primary"
|
||||||
|
@click="installApp"
|
||||||
|
>
|
||||||
|
安装应用
|
||||||
|
</button>
|
||||||
|
<button type="button" class="install-app__ghost" @click="dismiss">知道了</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.install-app {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 100;
|
||||||
|
max-width: min(92vw, 360px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-app__content {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
background: var(--vp-c-bg-soft);
|
||||||
|
box-shadow: var(--vp-shadow-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-app__text {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--vp-c-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-app__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-app__primary,
|
||||||
|
.install-app__ghost {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-app__primary {
|
||||||
|
border: none;
|
||||||
|
background: var(--vp-c-brand-1);
|
||||||
|
color: var(--vp-c-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-app__ghost {
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vp-c-text-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import { h } from 'vue'
|
||||||
|
import InstallApp from './InstallApp.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: DefaultTheme,
|
||||||
|
Layout: () => {
|
||||||
|
return h(DefaultTheme.Layout, null, {
|
||||||
|
'layout-bottom': () => h(InstallApp),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -358,7 +358,46 @@ ss -tlnp | grep 12100
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. 安全建议
|
## 13. 安装为应用(PWA)
|
||||||
|
|
||||||
|
站点已支持 **Progressive Web App**,可在手机和电脑上安装为独立应用。
|
||||||
|
|
||||||
|
### 前提
|
||||||
|
|
||||||
|
- **对外访问必须使用 HTTPS**(云服务器反代层配置 SSL)
|
||||||
|
- 内网 `http://192.168.x.x:12100` 仅适合调试,Android 通常不会弹出「安装应用」
|
||||||
|
|
||||||
|
### 电脑(Chrome / Edge)
|
||||||
|
|
||||||
|
1. 用 HTTPS 域名打开站点并登录
|
||||||
|
2. 地址栏右侧点击 **「安装」** 或菜单 → **「安装 DAO DE JING / 道德经」**
|
||||||
|
3. 也可点击页面右下角 **「安装应用」** 按钮(Android / 部分桌面浏览器)
|
||||||
|
|
||||||
|
### Android 手机
|
||||||
|
|
||||||
|
1. Chrome 打开 **HTTPS 域名** 并登录
|
||||||
|
2. 点击右下角 **「安装应用」**,或菜单 → **「添加到主屏幕 / 安装应用」**
|
||||||
|
|
||||||
|
### iPhone / iPad
|
||||||
|
|
||||||
|
1. 必须用 **Safari** 打开 HTTPS 域名并登录
|
||||||
|
2. 点击底部分享按钮 **□↑**
|
||||||
|
3. 选择 **「添加到主屏幕」**
|
||||||
|
4. 页面右下角也会提示上述步骤
|
||||||
|
|
||||||
|
### 更新后
|
||||||
|
|
||||||
|
安装的应用不会自动更新构建内容,服务端更新后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull && npm install && npm run build && npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
用户重新打开应用即可加载新版本(Service Worker 会自动更新静态资源)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 安全建议
|
||||||
|
|
||||||
1. **立即修改** `server/auth.config.json` 中的默认密码和 `sessionSecret`
|
1. **立即修改** `server/auth.config.json` 中的默认密码和 `sessionSecret`
|
||||||
2. 不要将 `server/auth.config.json` 提交到 Git 或分享给他人
|
2. 不要将 `server/auth.config.json` 提交到 Git 或分享给他人
|
||||||
@@ -368,7 +407,7 @@ ss -tlnp | grep 12100
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. 快速部署清单
|
## 15. 快速部署清单
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 环境
|
# 1. 环境
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "DAO DE JING",
|
|
||||||
"short_name": "道德经",
|
|
||||||
"description": "传统文化典籍资料库",
|
|
||||||
"start_url": "/",
|
|
||||||
"scope": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#1a1a2e",
|
|
||||||
"theme_color": "#0f3460",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/favicon.ico",
|
|
||||||
"sizes": "48x48",
|
|
||||||
"type": "image/x-icon"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Generated
+4666
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"to-ico": "^1.1.5",
|
"to-ico": "^1.1.5",
|
||||||
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
"vitepress": "^1.6.3"
|
"vitepress": "^1.6.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ copyDir(path.join(root, 'images'), path.join(publicDir, 'images'))
|
|||||||
|
|
||||||
const siteDir = path.join(root, 'assets', 'site')
|
const siteDir = path.join(root, 'assets', 'site')
|
||||||
if (fs.existsSync(siteDir)) {
|
if (fs.existsSync(siteDir)) {
|
||||||
const skipInPublicRoot = new Set(['apple-touch-icon.png'])
|
const skipInPublicRoot = new Set(['apple-touch-icon.png', 'site.webmanifest'])
|
||||||
for (const file of fs.readdirSync(siteDir)) {
|
for (const file of fs.readdirSync(siteDir)) {
|
||||||
if (skipInPublicRoot.has(file)) continue
|
if (skipInPublicRoot.has(file)) continue
|
||||||
fs.copyFileSync(path.join(siteDir, file), path.join(publicDir, file))
|
fs.copyFileSync(path.join(siteDir, file), path.join(publicDir, file))
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ const PUBLIC_PATHS = new Set([
|
|||||||
function isPublicPath(reqPath) {
|
function isPublicPath(reqPath) {
|
||||||
if (PUBLIC_PATHS.has(reqPath)) return true
|
if (PUBLIC_PATHS.has(reqPath)) return true
|
||||||
if (reqPath.startsWith('/assets/')) return true
|
if (reqPath.startsWith('/assets/')) return true
|
||||||
|
if (reqPath === '/sw.js' || reqPath.endsWith('.webmanifest')) return true
|
||||||
|
if (/^\/workbox-[\w-]+\.js$/.test(reqPath)) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0f3460" />
|
<meta name="theme-color" content="#0f3460" />
|
||||||
<meta name="apple-mobile-web-app-title" content="道德经" />
|
<meta name="apple-mobile-web-app-title" content="道德经" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
|||||||
Reference in New Issue
Block a user