Browse Source

✨ feat: 增加安装应用功能

Pchen. 2 weeks ago
parent
commit
2d0cbbfe3b
6 changed files with 179 additions and 1 deletions
  1. 6 0
      index.html
  2. 19 0
      public/manifest.webmanifest
  3. 28 0
      public/sw.js
  4. 4 1
      src/App.vue
  5. 114 0
      src/components/PwaInstallButton.vue
  6. 8 0
      src/main.js

+ 6 - 0
index.html

@@ -3,7 +3,13 @@
   <head>
   <head>
     <meta charset="UTF-8" />
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/logo.svg" />
     <link rel="icon" type="image/svg+xml" href="/logo.svg" />
+    <link rel="manifest" href="/manifest.webmanifest" />
     <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
     <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+    <meta name="theme-color" content="#165dff" />
+    <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="RunForge" />
+    <link rel="apple-touch-icon" href="/logo.svg" />
     <title>RunForge - 智能校园乐跑平台</title>
     <title>RunForge - 智能校园乐跑平台</title>
   </head>
   </head>
   <body>
   <body>

+ 19 - 0
public/manifest.webmanifest

@@ -0,0 +1,19 @@
+{
+  "name": "RunForge - 智能校园乐跑平台",
+  "short_name": "RunForge",
+  "description": "RunForge 智能校园乐跑平台",
+  "lang": "zh-CN",
+  "start_url": "/",
+  "scope": "/",
+  "display": "standalone",
+  "background_color": "#ffffff",
+  "theme_color": "#165dff",
+  "icons": [
+    {
+      "src": "/logo.svg",
+      "sizes": "any",
+      "type": "image/svg+xml",
+      "purpose": "any maskable"
+    }
+  ]
+}

+ 28 - 0
public/sw.js

@@ -0,0 +1,28 @@
+const CACHE_NAME = 'runforge-shell-v1'
+const APP_SHELL = ['/', '/manifest.webmanifest', '/logo.svg']
+
+self.addEventListener('install', (event) => {
+  event.waitUntil(
+    caches.open(CACHE_NAME)
+      .then((cache) => cache.addAll(APP_SHELL))
+      .then(() => self.skipWaiting())
+  )
+})
+
+self.addEventListener('activate', (event) => {
+  event.waitUntil(
+    caches.keys()
+      .then((keys) => Promise.all(
+        keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
+      ))
+      .then(() => self.clients.claim())
+  )
+})
+
+self.addEventListener('fetch', (event) => {
+  if (event.request.method !== 'GET' || event.request.mode !== 'navigate') return
+
+  event.respondWith(
+    fetch(event.request).catch(() => caches.match('/'))
+  )
+})

+ 4 - 1
src/App.vue

@@ -14,6 +14,8 @@
       </a-result>
       </a-result>
     </div>
     </div>
     <router-view></router-view>
     <router-view></router-view>
+    
+    <!-- <PwaInstallButton /> -->
   </div>
   </div>
 </template>
 </template>
 
 
@@ -22,6 +24,7 @@ import { ref, onMounted } from 'vue'
 import storage from 'store'
 import storage from 'store'
 import { Modal, Message } from '@arco-design/web-vue'
 import { Modal, Message } from '@arco-design/web-vue'
 import { getAppVersion } from '@/api/public'
 import { getAppVersion } from '@/api/public'
+import PwaInstallButton from '@/components/PwaInstallButton.vue'
 
 
 const mobile = ref(false)
 const mobile = ref(false)
 
 
@@ -87,4 +90,4 @@ onMounted(() => {
   top: 50%;
   top: 50%;
   transform: translateY(-50%);
   transform: translateY(-50%);
 }
 }
-</style>
+</style>

+ 114 - 0
src/components/PwaInstallButton.vue

@@ -0,0 +1,114 @@
+<template>
+  <button
+    v-if="canInstall"
+    type="button"
+    class="pwa-install-button"
+    aria-label="安装 RunForge 应用"
+    @click="installApp"
+  >
+    <icon-download class="install-icon" />
+    <span>安装应用</span>
+  </button>
+</template>
+
+<script setup>
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { Message, Modal } from '@arco-design/web-vue'
+import { isElectron } from '@/utils/electron'
+
+const installPrompt = ref(null)
+const isInstalled = ref(false)
+const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent)
+
+const canInstall = computed(() => {
+  return !isElectron() && !isInstalled.value && (Boolean(installPrompt.value) || isIos)
+})
+
+function handleBeforeInstallPrompt(event) {
+  event.preventDefault()
+  installPrompt.value = event
+}
+
+function handleAppInstalled() {
+  installPrompt.value = null
+  isInstalled.value = true
+  Message.success('RunForge 已安装')
+}
+
+async function installApp() {
+  if (isIos && !installPrompt.value) {
+    Modal.info({
+      title: '安装 RunForge',
+      content: '请点击浏览器底部的“分享”按钮,然后选择“添加到主屏幕”。'
+    })
+    return
+  }
+
+  const promptEvent = installPrompt.value
+  if (!promptEvent) return
+
+  await promptEvent.prompt()
+  const { outcome } = await promptEvent.userChoice
+  installPrompt.value = null
+
+  if (outcome === 'accepted') {
+    Message.success('正在安装 RunForge')
+  }
+}
+
+onMounted(() => {
+  isInstalled.value = window.matchMedia('(display-mode: standalone)').matches
+    || window.navigator.standalone === true
+  window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
+  window.addEventListener('appinstalled', handleAppInstalled)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
+  window.removeEventListener('appinstalled', handleAppInstalled)
+})
+</script>
+
+<style scoped>
+.pwa-install-button {
+  position: fixed;
+  right: max(20px, env(safe-area-inset-right));
+  bottom: max(20px, env(safe-area-inset-bottom));
+  z-index: 1000;
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  padding: 10px 16px;
+  border: 0;
+  border-radius: 999px;
+  color: #fff;
+  background: rgb(var(--primary-6, 22, 93, 255));
+  box-shadow: 0 8px 24px rgba(22, 93, 255, 0.28);
+  font: inherit;
+  font-weight: 500;
+  cursor: pointer;
+  transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.pwa-install-button:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 10px 28px rgba(22, 93, 255, 0.36);
+}
+
+.pwa-install-button:focus-visible {
+  outline: 3px solid rgba(22, 93, 255, 0.3);
+  outline-offset: 3px;
+}
+
+.install-icon {
+  font-size: 17px;
+}
+
+@media (max-width: 768px) {
+  .pwa-install-button {
+    right: max(14px, env(safe-area-inset-right));
+    bottom: max(14px, env(safe-area-inset-bottom));
+    padding: 9px 14px;
+  }
+}
+</style>

+ 8 - 0
src/main.js

@@ -31,3 +31,11 @@ app
   .use(createPinia())
   .use(createPinia())
   .use(globalComponents)
   .use(globalComponents)
   .mount('#app')
   .mount('#app')
+
+if ('serviceWorker' in navigator && import.meta.env.PROD) {
+  window.addEventListener('load', () => {
+    navigator.serviceWorker.register('/sw.js').catch((error) => {
+      console.warn('Service Worker 注册失败:', error)
+    })
+  })
+}