dramaling-app/apps/web/src/components/PWAInstallPrompt.vue

417 lines
9.5 KiB
Vue

<template>
<div v-if="showPrompt" class="pwa-install-prompt">
<q-banner class="install-banner" rounded>
<template v-slot:avatar>
<q-icon name="get_app" color="primary" size="md" />
</template>
<div class="banner-content">
<div class="banner-title">安裝 Drama Ling 應用程式</div>
<div class="banner-description">
在桌面安裝應用程式,享受更好的學習體驗:
</div>
<ul class="features-list">
<li>離線學習功能</li>
<li>快速啟動和存取</li>
<li>推播通知提醒</li>
<li>全螢幕沉浸體驗</li>
</ul>
</div>
<template v-slot:action>
<div class="banner-actions">
<q-btn
color="primary"
label="立即安裝"
@click="installApp"
:loading="isInstalling"
no-caps
/>
<q-btn
flat
label="稍後提醒"
@click="postponeInstall"
no-caps
class="q-ml-sm"
/>
<q-btn
flat
icon="close"
@click="dismissPermanently"
size="sm"
class="q-ml-sm"
/>
</div>
</template>
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
const $q = useQuasar()
// 響應式數據
const showPrompt = ref(false)
const isInstalling = ref(false)
const deferredPrompt = ref<any>(null)
// PWA 安裝狀態
const isStandalone = ref(false)
const isInstalled = ref(false)
// 檢查 PWA 安裝狀態
const checkPWAStatus = () => {
// 檢查是否已在獨立模式下運行 (已安裝)
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches ||
('standalone' in window.navigator && (window.navigator as any).standalone === true)
// 檢查用戶偏好設定
const userPreference = localStorage.getItem('pwa-install-preference')
const lastPromptTime = localStorage.getItem('pwa-last-prompt')
// 如果已安裝或用戶已永久拒絕,不顯示提示
if (isStandalone.value || userPreference === 'never') {
isInstalled.value = true
return false
}
// 如果用戶選擇稍後提醒,檢查是否已過足夠時間 (7天)
if (userPreference === 'later' && lastPromptTime) {
const daysSincePrompt = (Date.now() - parseInt(lastPromptTime)) / (1000 * 60 * 60 * 24)
if (daysSincePrompt < 7) {
return false
}
}
return true
}
// 處理 beforeinstallprompt 事件
const handleBeforeInstallPrompt = (e: Event) => {
// 阻止瀏覽器預設的安裝提示
e.preventDefault()
// 儲存事件以供後續使用
deferredPrompt.value = e
// 檢查是否應該顯示自訂提示
if (checkPWAStatus()) {
showPrompt.value = true
}
}
// 安裝應用程式
const installApp = async () => {
if (!deferredPrompt.value) {
// 如果沒有 beforeinstallprompt 事件,顯示手動安裝指引
showManualInstallInstructions()
return
}
isInstalling.value = true
try {
// 顯示安裝提示
const result = await deferredPrompt.value.prompt()
// 等待用戶回應
const choiceResult = await deferredPrompt.value.userChoice
if (choiceResult.outcome === 'accepted') {
// 用戶接受安裝
$q.notify({
type: 'positive',
message: '應用程式安裝成功!',
icon: 'check_circle',
timeout: 3000
})
// 記錄安裝成功
localStorage.setItem('pwa-install-preference', 'installed')
localStorage.setItem('pwa-install-time', Date.now().toString())
showPrompt.value = false
} else {
// 用戶拒絕安裝
$q.notify({
type: 'info',
message: '你可以稍後從瀏覽器選單安裝應用程式',
icon: 'info',
timeout: 3000
})
}
// 清除 deferredPrompt
deferredPrompt.value = null
} catch (error) {
console.error('安裝失敗:', error)
$q.notify({
type: 'negative',
message: '安裝過程發生錯誤',
icon: 'error',
timeout: 3000
})
} finally {
isInstalling.value = false
}
}
// 稍後提醒
const postponeInstall = () => {
localStorage.setItem('pwa-install-preference', 'later')
localStorage.setItem('pwa-last-prompt', Date.now().toString())
showPrompt.value = false
$q.notify({
type: 'info',
message: '我們會在 7 天後再次提醒你',
icon: 'schedule',
timeout: 3000
})
}
// 永久拒絕
const dismissPermanently = () => {
localStorage.setItem('pwa-install-preference', 'never')
showPrompt.value = false
$q.notify({
type: 'info',
message: '你可以隨時從設定中啟用安裝提示',
icon: 'settings',
timeout: 3000
})
}
// 顯示手動安裝指引
const showManualInstallInstructions = () => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
const isAndroid = /Android/.test(navigator.userAgent)
let instructions = ''
if (isIOS) {
instructions = '點擊 Safari 底部的分享按鈕,然後選擇「加入主畫面」'
} else if (isAndroid) {
instructions = '點擊瀏覽器選單中的「安裝應用程式」或「加到主畫面」'
} else {
instructions = '點擊網址列右側的安裝圖示,或從瀏覽器選單選擇「安裝」'
}
$q.dialog({
title: '手動安裝應用程式',
message: instructions,
html: true,
ok: {
label: '我知道了',
color: 'primary'
}
})
}
// 檢查應用程式是否已成功安裝
const handleAppInstalled = () => {
console.log('PWA 安裝成功')
$q.notify({
type: 'positive',
message: '歡迎使用 Drama Ling 應用程式!',
icon: 'celebration',
timeout: 5000,
actions: [{
label: '開始學習',
color: 'white',
handler: () => {
// 可以跳轉到學習頁面
}
}]
})
// 記錄安裝狀態
localStorage.setItem('pwa-install-preference', 'installed')
localStorage.setItem('pwa-install-time', Date.now().toString())
showPrompt.value = false
isInstalled.value = true
}
// 重置安裝提示 (用於測試或設定)
const resetInstallPrompt = () => {
localStorage.removeItem('pwa-install-preference')
localStorage.removeItem('pwa-last-prompt')
localStorage.removeItem('pwa-install-time')
if (checkPWAStatus() && deferredPrompt.value) {
showPrompt.value = true
}
}
// 生命週期
onMounted(() => {
// 監聽 PWA 安裝相關事件
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
// 初始化檢查
setTimeout(() => {
if (checkPWAStatus()) {
// 如果沒有 beforeinstallprompt 事件但符合顯示條件,顯示簡化版提示
if (!deferredPrompt.value) {
const visitCount = parseInt(localStorage.getItem('visit-count') || '0')
if (visitCount >= 3) { // 訪問 3 次後顯示提示
showPrompt.value = true
}
}
}
}, 3000) // 頁面載入 3 秒後顯示
// 增加訪問計數
const visitCount = parseInt(localStorage.getItem('visit-count') || '0')
localStorage.setItem('visit-count', (visitCount + 1).toString())
})
onUnmounted(() => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
})
// 暴露方法供外部使用
defineExpose({
installApp,
resetInstallPrompt,
isInstalled,
isStandalone
})
</script>
<style lang="scss" scoped>
.pwa-install-prompt {
position: fixed;
bottom: $space-4;
left: $space-4;
right: $space-4;
z-index: 1000;
max-width: 600px;
margin: 0 auto;
@media (max-width: 768px) {
bottom: $space-3;
left: $space-3;
right: $space-3;
}
}
.install-banner {
background: linear-gradient(135deg, $primary-teal 0%, $secondary-purple 100%);
color: white;
border-radius: $radius-xl;
box-shadow: $shadow-xl;
backdrop-filter: blur(10px);
:deep(.q-banner__content) {
padding: $space-4;
}
:deep(.q-banner__avatar) {
margin-right: $space-4;
align-self: flex-start;
margin-top: $space-1;
}
}
.banner-content {
flex: 1;
}
.banner-title {
font-size: $text-lg;
font-weight: 700;
margin-bottom: $space-2;
color: white;
}
.banner-description {
font-size: $text-base;
color: rgba(white, 0.9);
margin-bottom: $space-3;
}
.features-list {
margin: 0;
padding-left: $space-5;
li {
font-size: $text-sm;
color: rgba(white, 0.8);
margin-bottom: $space-1;
&:last-child {
margin-bottom: 0;
}
}
}
.banner-actions {
display: flex;
flex-direction: column;
gap: $space-2;
align-items: stretch;
margin-top: $space-4;
@media (min-width: 600px) {
flex-direction: row;
align-items: center;
margin-top: 0;
}
.q-btn {
min-width: 100px;
@media (max-width: 599px) {
width: 100%;
}
}
}
// 動畫效果
.pwa-install-prompt {
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// 響應式調整
@media (max-width: 480px) {
.banner-content {
.banner-title {
font-size: $text-base;
}
.banner-description {
font-size: $text-sm;
}
.features-list {
padding-left: $space-4;
li {
font-size: $text-xs;
}
}
}
}
</style>