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:
dekun
2026-06-05 17:41:51 +08:00
parent a8be586652
commit f663867a25
11 changed files with 4923 additions and 39 deletions
+123
View File
@@ -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>
+12
View File
@@ -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),
})
},
}