ee74d31dac
Allow /images/ as public static assets and show install hints on all pages including mobile and HTTP deployments. Co-authored-by: Cursor <cursoragent@cursor.com>
183 lines
4.7 KiB
Vue
183 lines
4.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
|
||
type InstallMode = 'native' | 'ios' | 'android-menu' | 'desktop' | 'https-required'
|
||
|
||
const showInstall = ref(false)
|
||
const dismissed = ref(false)
|
||
const installMode = ref<InstallMode | null>(null)
|
||
const isMobile = ref(false)
|
||
let deferredPrompt: BeforeInstallPromptEvent | null = null
|
||
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
interface BeforeInstallPromptEvent extends Event {
|
||
prompt: () => Promise<void>
|
||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||
}
|
||
|
||
const visible = computed(() => !dismissed.value && showInstall.value && installMode.value !== null)
|
||
|
||
function needsHttpsForInstall() {
|
||
return (
|
||
!window.isSecureContext &&
|
||
!['localhost', '127.0.0.1'].includes(location.hostname)
|
||
)
|
||
}
|
||
|
||
function onBeforeInstallPrompt(event: Event) {
|
||
event.preventDefault()
|
||
deferredPrompt = event as BeforeInstallPromptEvent
|
||
installMode.value = 'native'
|
||
showInstall.value = true
|
||
if (fallbackTimer) {
|
||
clearTimeout(fallbackTimer)
|
||
fallbackTimer = null
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
isMobile.value = window.matchMedia('(max-width: 768px)').matches
|
||
|
||
if (sessionStorage.getItem('install-app-dismissed') === '1') {
|
||
dismissed.value = true
|
||
return
|
||
}
|
||
|
||
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) {
|
||
installMode.value = 'ios'
|
||
showInstall.value = true
|
||
return
|
||
}
|
||
|
||
if (needsHttpsForInstall()) {
|
||
installMode.value = 'https-required'
|
||
showInstall.value = true
|
||
return
|
||
}
|
||
|
||
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt)
|
||
|
||
fallbackTimer = setTimeout(() => {
|
||
if (deferredPrompt || installMode.value) return
|
||
installMode.value = isMobile.value ? 'android-menu' : 'desktop'
|
||
showInstall.value = true
|
||
}, 2500)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt)
|
||
if (fallbackTimer) clearTimeout(fallbackTimer)
|
||
})
|
||
|
||
async function installApp() {
|
||
if (!deferredPrompt) return
|
||
await deferredPrompt.prompt()
|
||
await deferredPrompt.userChoice
|
||
deferredPrompt = null
|
||
showInstall.value = false
|
||
}
|
||
|
||
function dismiss() {
|
||
dismissed.value = true
|
||
sessionStorage.setItem('install-app-dismissed', '1')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="visible" class="install-app">
|
||
<div class="install-app__content">
|
||
<p v-if="installMode === 'native'" class="install-app__text">
|
||
可将「道德经」安装到桌面,像 App 一样使用。
|
||
</p>
|
||
<p v-else-if="installMode === 'ios'" class="install-app__text">
|
||
iPhone:Safari 底部分享 →「添加到主屏幕」,阅读更方便。
|
||
</p>
|
||
<p v-else-if="installMode === 'android-menu'" class="install-app__text">
|
||
Android:Chrome 右上角菜单 →「安装应用」或「添加到主屏幕」。
|
||
</p>
|
||
<p v-else-if="installMode === 'desktop'" class="install-app__text">
|
||
电脑:地址栏右侧点击「安装」图标,或浏览器菜单 →「安装道德经」。
|
||
</p>
|
||
<p v-else class="install-app__text">
|
||
安装应用需通过 HTTPS 域名访问(内网 http://IP 地址只能添加快捷方式,无法真正安装)。
|
||
</p>
|
||
<div class="install-app__actions">
|
||
<button
|
||
v-if="installMode === 'native'"
|
||
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: calc(16px + env(safe-area-inset-bottom, 0));
|
||
z-index: 50;
|
||
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: 10px 14px;
|
||
font-size: 14px;
|
||
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);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.install-app {
|
||
left: 12px;
|
||
right: 12px;
|
||
max-width: none;
|
||
}
|
||
}
|
||
</style>
|