@mindbase/vue3-app-shell 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/composables/index.ts +3 -0
- package/composables/useBreakpoint.ts +64 -0
- package/composables/useDrawer.ts +66 -0
- package/composables/useTouchGesture.ts +165 -0
- package/createAppShell.ts +82 -0
- package/index.ts +20 -0
- package/layout/AppShellLayout.vue +41 -0
- package/layout/components/AppDrawer.vue +87 -0
- package/layout/components/AppHeader.vue +108 -0
- package/layout/components/AppMain.vue +67 -0
- package/layout/components/PWAInstallPrompt.vue +114 -0
- package/layout/index.ts +5 -0
- package/package.json +42 -0
- package/pwa/index.ts +3 -0
- package/pwa/manifest.ts +92 -0
- package/pwa/types.ts +4 -0
- package/pwa/utils.ts +108 -0
- package/store/index.ts +3 -0
- package/store/layout.ts +69 -0
- package/store/pwa.ts +141 -0
- package/store/slots.ts +68 -0
- package/styles/drawer.scss +62 -0
- package/styles/index.scss +5 -0
- package/styles/mobile.scss +58 -0
- package/styles/responsive.scss +33 -0
- package/styles/variables.scss +44 -0
- package/types/app.ts +41 -0
- package/types/index.ts +4 -0
- package/types/layout.ts +28 -0
- package/types/pwa.ts +53 -0
- package/types/slots.ts +31 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<header class="app-header">
|
|
3
|
+
<!-- 左侧区域:菜单按钮 + 插槽 -->
|
|
4
|
+
<div class="app-header__left">
|
|
5
|
+
<i
|
|
6
|
+
:class="iconClass"
|
|
7
|
+
class="menu-toggle"
|
|
8
|
+
@click="handleToggle" />
|
|
9
|
+
<template v-for="slot in headerLeftSlots" :key="slot">
|
|
10
|
+
<component :is="slot.component" v-bind="slot.props" />
|
|
11
|
+
</template>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<!-- 中间区域:插槽 -->
|
|
15
|
+
<div class="app-header__center">
|
|
16
|
+
<template v-for="slot in headerCenterSlots" :key="slot">
|
|
17
|
+
<component :is="slot.component" v-bind="slot.props" />
|
|
18
|
+
</template>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<!-- 右侧区域:插槽 -->
|
|
22
|
+
<div class="app-header__right">
|
|
23
|
+
<template v-for="slot in headerRightSlots" :key="slot">
|
|
24
|
+
<component :is="slot.component" v-bind="slot.props" />
|
|
25
|
+
</template>
|
|
26
|
+
</div>
|
|
27
|
+
</header>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup lang="ts">
|
|
31
|
+
import { computed } from 'vue'
|
|
32
|
+
import { storeToRefs } from 'pinia'
|
|
33
|
+
import { useAppShellLayoutStore, useAppShellSlotsStore } from '../../store'
|
|
34
|
+
import { useDrawer } from '../../composables'
|
|
35
|
+
|
|
36
|
+
const layoutStore = useAppShellLayoutStore()
|
|
37
|
+
const slotsStore = useAppShellSlotsStore()
|
|
38
|
+
const { isMobile } = useDrawer()
|
|
39
|
+
|
|
40
|
+
const { collapsed, drawerVisible } = storeToRefs(layoutStore)
|
|
41
|
+
const { headerLeft, headerCenter, headerRight } = storeToRefs(slotsStore)
|
|
42
|
+
|
|
43
|
+
// 图标类名
|
|
44
|
+
const iconClass = computed(() => {
|
|
45
|
+
if (isMobile.value) {
|
|
46
|
+
return drawerVisible.value ? 'mbiconfont mb-close' : 'mbiconfont mb-menu'
|
|
47
|
+
} else {
|
|
48
|
+
return collapsed.value ? 'mbiconfont mb-outdent' : 'mbiconfont mb-indent'
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// 切换菜单
|
|
53
|
+
const handleToggle = () => {
|
|
54
|
+
if (isMobile.value) {
|
|
55
|
+
layoutStore.toggleDrawer()
|
|
56
|
+
} else {
|
|
57
|
+
layoutStore.toggleCollapse()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<style lang="scss">
|
|
63
|
+
.app-header {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
height: var(--mb-app-shell-header-height, 60px);
|
|
67
|
+
background-color: var(--mb-app-shell-header-bg, #ffffff);
|
|
68
|
+
border-bottom: var(--mb-app-shell-header-border-bottom, 1px solid #e4e7ed);
|
|
69
|
+
padding: 0 16px;
|
|
70
|
+
flex-shrink: 0;
|
|
71
|
+
|
|
72
|
+
.app-header__left {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 12px;
|
|
76
|
+
flex-shrink: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.app-header__center {
|
|
80
|
+
flex: 1;
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
justify-content: center;
|
|
84
|
+
gap: 12px;
|
|
85
|
+
overflow: hidden;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.app-header__right {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: 12px;
|
|
92
|
+
flex-shrink: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.menu-toggle {
|
|
96
|
+
font-size: 18px;
|
|
97
|
+
color: var(--mb-app-shell-header-text-color, #606266);
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
transition: all 0.2s ease;
|
|
100
|
+
padding: 6px;
|
|
101
|
+
border-radius: 4px;
|
|
102
|
+
|
|
103
|
+
&:hover {
|
|
104
|
+
background-color: rgba(0, 0, 0, 0.05);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<main class="app-main">
|
|
3
|
+
<!-- 内容顶部插槽 -->
|
|
4
|
+
<div v-if="contentTopSlots.length > 0" class="app-main__top">
|
|
5
|
+
<template v-for="slot in contentTopSlots" :key="slot">
|
|
6
|
+
<component :is="slot.component" v-bind="slot.props" />
|
|
7
|
+
</template>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<!-- 路由视图 -->
|
|
11
|
+
<section class="app-main__content">
|
|
12
|
+
<router-view v-slot="{ Component }">
|
|
13
|
+
<KeepAlive :max="keepAliveMax">
|
|
14
|
+
<component :is="Component" :key="route.path" />
|
|
15
|
+
</KeepAlive>
|
|
16
|
+
</router-view>
|
|
17
|
+
</section>
|
|
18
|
+
|
|
19
|
+
<!-- 内容底部插槽 -->
|
|
20
|
+
<div v-if="contentBottomSlots.length > 0" class="app-main__bottom">
|
|
21
|
+
<template v-for="slot in contentBottomSlots" :key="slot">
|
|
22
|
+
<component :is="slot.component" v-bind="slot.props" />
|
|
23
|
+
</template>
|
|
24
|
+
</div>
|
|
25
|
+
</main>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
import { computed } from 'vue'
|
|
30
|
+
import { useRoute } from 'vue-router'
|
|
31
|
+
import { storeToRefs } from 'pinia'
|
|
32
|
+
import { useAppShellSlotsStore } from '../../store'
|
|
33
|
+
|
|
34
|
+
const route = useRoute()
|
|
35
|
+
const slotsStore = useAppShellSlotsStore()
|
|
36
|
+
|
|
37
|
+
const { contentTop, contentBottom } = storeToRefs(slotsStore)
|
|
38
|
+
|
|
39
|
+
// KeepAlive 最大缓存数量(可配置)
|
|
40
|
+
const keepAliveMax = computed(() => 10)
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<style lang="scss">
|
|
44
|
+
.app-main {
|
|
45
|
+
flex: 1;
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
background-color: var(--mb-app-shell-main-bg, #f5f5f5);
|
|
50
|
+
|
|
51
|
+
.app-main__top {
|
|
52
|
+
flex-shrink: 0;
|
|
53
|
+
padding: var(--mb-app-shell-main-padding, 16px);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.app-main__content {
|
|
57
|
+
flex: 1;
|
|
58
|
+
overflow: auto;
|
|
59
|
+
padding: var(--mb-app-shell-main-padding, 16px);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.app-main__bottom {
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
padding: var(--mb-app-shell-main-padding, 16px);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<teleport to="body">
|
|
3
|
+
<transition name="el-fade">
|
|
4
|
+
<div v-if="visible" class="pwa-install-prompt">
|
|
5
|
+
<div class="pwa-install-prompt__content">
|
|
6
|
+
<div class="pwa-install-prompt__text">
|
|
7
|
+
<h4 class="pwa-install-prompt__title">{{ config.title }}</h4>
|
|
8
|
+
<p class="pwa-install-prompt__description">{{ config.description }}</p>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="pwa-install-prompt__actions">
|
|
11
|
+
<el-button size="small" @click="handleCancel">
|
|
12
|
+
{{ config.cancelButtonText }}
|
|
13
|
+
</el-button>
|
|
14
|
+
<el-button type="primary" size="small" @click="handleInstall">
|
|
15
|
+
{{ config.installButtonText }}
|
|
16
|
+
</el-button>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="pwa-install-prompt__footer">
|
|
19
|
+
<el-checkbox v-model="dontShowAgain" @change="handleDontShowAgainChange">
|
|
20
|
+
{{ config.dontShowAgainText }}
|
|
21
|
+
</el-checkbox>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</transition>
|
|
26
|
+
</teleport>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<script setup lang="ts">
|
|
30
|
+
import { computed, watch } from 'vue'
|
|
31
|
+
import { storeToRefs } from 'pinia'
|
|
32
|
+
import { useAppShellPWAStore } from '../../store'
|
|
33
|
+
import { ElButton, ElCheckbox } from 'element-plus'
|
|
34
|
+
|
|
35
|
+
const pwaStore = useAppShellPWAStore()
|
|
36
|
+
const { config, promptVisible, dontShowAgain, shouldShowPrompt } = storeToRefs(pwaStore)
|
|
37
|
+
|
|
38
|
+
// 控制显示状态
|
|
39
|
+
const visible = computed(() => {
|
|
40
|
+
return shouldShowPrompt.value && promptVisible.value
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// 处理安装
|
|
44
|
+
const handleInstall = () => {
|
|
45
|
+
pwaStore.promptInstall()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 处理取消
|
|
49
|
+
const handleCancel = () => {
|
|
50
|
+
pwaStore.hidePrompt()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 处理"不再提示"变化
|
|
54
|
+
const handleDontShowAgainChange = (value: boolean) => {
|
|
55
|
+
pwaStore.setDontShowAgain(value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 监听 shouldShowPrompt,自动显示提示
|
|
59
|
+
watch(shouldShowPrompt, (shouldShow) => {
|
|
60
|
+
if (shouldShow) {
|
|
61
|
+
pwaStore.showPrompt()
|
|
62
|
+
}
|
|
63
|
+
}, { immediate: true })
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<style lang="scss">
|
|
67
|
+
.pwa-install-prompt {
|
|
68
|
+
position: fixed;
|
|
69
|
+
bottom: 20px;
|
|
70
|
+
left: 50%;
|
|
71
|
+
transform: translateX(-50%);
|
|
72
|
+
z-index: 10000;
|
|
73
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
74
|
+
|
|
75
|
+
.pwa-install-prompt__content {
|
|
76
|
+
background-color: #fff;
|
|
77
|
+
border-radius: 8px;
|
|
78
|
+
padding: 16px;
|
|
79
|
+
min-width: 320px;
|
|
80
|
+
max-width: 400px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.pwa-install-prompt__text {
|
|
84
|
+
margin-bottom: 12px;
|
|
85
|
+
|
|
86
|
+
.pwa-install-prompt__title {
|
|
87
|
+
margin: 0 0 8px;
|
|
88
|
+
font-size: 16px;
|
|
89
|
+
font-weight: 500;
|
|
90
|
+
color: #303133;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.pwa-install-prompt__description {
|
|
94
|
+
margin: 0;
|
|
95
|
+
font-size: 14px;
|
|
96
|
+
color: #606266;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.pwa-install-prompt__actions {
|
|
101
|
+
display: flex;
|
|
102
|
+
justify-content: flex-end;
|
|
103
|
+
gap: 8px;
|
|
104
|
+
margin-bottom: 12px;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.pwa-install-prompt__footer {
|
|
108
|
+
display: flex;
|
|
109
|
+
justify-content: center;
|
|
110
|
+
padding-top: 8px;
|
|
111
|
+
border-top: 1px solid #e4e7ed;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
</style>
|
package/layout/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as AppShellLayout } from './AppShellLayout.vue'
|
|
2
|
+
export { default as AppHeader } from './components/AppHeader.vue'
|
|
3
|
+
export { default as AppDrawer } from './components/AppDrawer.vue'
|
|
4
|
+
export { default as AppMain } from './components/AppMain.vue'
|
|
5
|
+
export { default as PWAInstallPrompt } from './components/PWAInstallPrompt.vue'
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mindbase/vue3-app-shell",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Vue 3 通用应用壳基础包,支持响应式布局和 PWA",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./index.ts",
|
|
9
|
+
"types": "./index.ts"
|
|
10
|
+
},
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@mindbase/vue3-kit": "workspace:*",
|
|
15
|
+
"vue": "^3.4.0",
|
|
16
|
+
"pinia": "^2.1.0",
|
|
17
|
+
"vue-router": "^4.2.0",
|
|
18
|
+
"element-plus": "^2.5.0",
|
|
19
|
+
"hammerjs": "^2.0.8"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/hammerjs": "^2.0.45",
|
|
23
|
+
"@vitejs/plugin-vue": "^5.0.0",
|
|
24
|
+
"sass": "^1.70.0",
|
|
25
|
+
"typescript": "^5.3.0",
|
|
26
|
+
"vite": "^5.0.0",
|
|
27
|
+
"vite-plugin-pwa": "^0.17.0",
|
|
28
|
+
"vue-tsc": "^1.8.0"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"vue3",
|
|
32
|
+
"app-shell",
|
|
33
|
+
"pwa",
|
|
34
|
+
"responsive",
|
|
35
|
+
"layout"
|
|
36
|
+
],
|
|
37
|
+
"author": "mindbase",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/pwa/index.ts
ADDED
package/pwa/manifest.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { VitePWAOptions } from 'vite-plugin-pwa'
|
|
2
|
+
import type { PWAConfig } from '../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 创建 Vite PWA 配置
|
|
6
|
+
*/
|
|
7
|
+
export function createPWAConfig(config: PWAConfig = {}): VitePWAOptions {
|
|
8
|
+
const {
|
|
9
|
+
name = 'App',
|
|
10
|
+
shortName = 'App',
|
|
11
|
+
description = 'My App',
|
|
12
|
+
themeColor = '#ffffff',
|
|
13
|
+
backgroundColor = '#ffffff',
|
|
14
|
+
icon = '/icon.png'
|
|
15
|
+
} = config
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
registerType: 'autoUpdate',
|
|
19
|
+
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
|
|
20
|
+
manifest: {
|
|
21
|
+
name,
|
|
22
|
+
short_name: shortName,
|
|
23
|
+
description,
|
|
24
|
+
theme_color: themeColor,
|
|
25
|
+
background_color: backgroundColor,
|
|
26
|
+
display: 'standalone',
|
|
27
|
+
icons: getIcons(icon),
|
|
28
|
+
start_url: '/',
|
|
29
|
+
orientation: 'portrait'
|
|
30
|
+
},
|
|
31
|
+
workbox: {
|
|
32
|
+
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
|
33
|
+
runtimeCaching: [
|
|
34
|
+
{
|
|
35
|
+
urlPattern: /^https:\/\/.*\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
|
|
36
|
+
handler: 'CacheFirst',
|
|
37
|
+
options: {
|
|
38
|
+
cacheName: 'images-cache',
|
|
39
|
+
expiration: {
|
|
40
|
+
maxEntries: 60,
|
|
41
|
+
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
urlPattern: /^https:\/\/api\//i,
|
|
47
|
+
handler: 'NetworkFirst',
|
|
48
|
+
options: {
|
|
49
|
+
cacheName: 'api-cache',
|
|
50
|
+
expiration: {
|
|
51
|
+
maxEntries: 100,
|
|
52
|
+
maxAgeSeconds: 5 * 60 // 5 minutes
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 获取图标配置
|
|
63
|
+
*/
|
|
64
|
+
function getIcons(icon: string | Record<string, string>) {
|
|
65
|
+
if (typeof icon === 'string') {
|
|
66
|
+
// 单个图标路径,生成多尺寸
|
|
67
|
+
const sizes = [72, 96, 128, 144, 152, 192, 384, 512]
|
|
68
|
+
return sizes.map(size => ({
|
|
69
|
+
src: icon,
|
|
70
|
+
sizes: `${size}x${size}`,
|
|
71
|
+
type: 'image/png'
|
|
72
|
+
}))
|
|
73
|
+
} else {
|
|
74
|
+
// 多个图标路径
|
|
75
|
+
return Object.entries(icon).map(([size, src]) => ({
|
|
76
|
+
src,
|
|
77
|
+
sizes: size,
|
|
78
|
+
type: 'image/png'
|
|
79
|
+
}))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 默认 PWA 配置
|
|
85
|
+
*/
|
|
86
|
+
export const defaultPWAConfig: VitePWAOptions = createPWAConfig({
|
|
87
|
+
name: 'Mindbase App',
|
|
88
|
+
shortName: 'App',
|
|
89
|
+
description: 'Mindbase Application',
|
|
90
|
+
themeColor: '#409eff',
|
|
91
|
+
backgroundColor: '#ffffff'
|
|
92
|
+
})
|
package/pwa/types.ts
ADDED
package/pwa/utils.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PWA 工具函数
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 检查是否为 PWA 环境
|
|
7
|
+
*/
|
|
8
|
+
export function isPWA(): boolean {
|
|
9
|
+
if (typeof window === 'undefined') return false
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
window.matchMedia('(display-mode: standalone)').matches ||
|
|
13
|
+
(window.navigator as any).standalone === true
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 检查是否支持 PWA 安装
|
|
19
|
+
*/
|
|
20
|
+
export function canInstallPWA(): boolean {
|
|
21
|
+
if (typeof window === 'undefined') return false
|
|
22
|
+
|
|
23
|
+
// 检查是否为 HTTPS
|
|
24
|
+
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 检查是否为支持的浏览器
|
|
29
|
+
return 'serviceWorker' in navigator && 'beforeinstallprompt' in window
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 获取设备信息
|
|
34
|
+
*/
|
|
35
|
+
export function getDeviceInfo(): {
|
|
36
|
+
isMobile: boolean
|
|
37
|
+
isTablet: boolean
|
|
38
|
+
isDesktop: boolean
|
|
39
|
+
userAgent: string
|
|
40
|
+
} {
|
|
41
|
+
if (typeof window === 'undefined') {
|
|
42
|
+
return {
|
|
43
|
+
isMobile: false,
|
|
44
|
+
isTablet: false,
|
|
45
|
+
isDesktop: true,
|
|
46
|
+
userAgent: ''
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const userAgent = navigator.userAgent
|
|
51
|
+
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent)
|
|
52
|
+
const isTablet = /iPad|Android|Tablet/i.test(userAgent) && window.innerWidth >= 768
|
|
53
|
+
const isDesktop = !isMobile && !isTablet
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
isMobile,
|
|
57
|
+
isTablet,
|
|
58
|
+
isDesktop,
|
|
59
|
+
userAgent
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 注册服务工作者
|
|
65
|
+
*/
|
|
66
|
+
export async function registerServiceWorker(
|
|
67
|
+
scriptURL: string,
|
|
68
|
+
options?: RegistrationOptions
|
|
69
|
+
): Promise<ServiceWorkerRegistration | null> {
|
|
70
|
+
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const registration = await navigator.serviceWorker.register(scriptURL, options)
|
|
76
|
+
return registration
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('Service Worker registration failed:', error)
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 等待服务工作者激活
|
|
85
|
+
*/
|
|
86
|
+
export async function waitForServiceWorkerActivation(
|
|
87
|
+
registration: ServiceWorkerRegistration,
|
|
88
|
+
timeout = 5000
|
|
89
|
+
): Promise<boolean> {
|
|
90
|
+
if (!registration.waiting && !registration.installing) {
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const timeoutId = setTimeout(() => {
|
|
96
|
+
resolve(false)
|
|
97
|
+
}, timeout)
|
|
98
|
+
|
|
99
|
+
if (registration.waiting) {
|
|
100
|
+
registration.waiting.postMessage('skipWaiting')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
104
|
+
clearTimeout(timeoutId)
|
|
105
|
+
resolve(true)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
}
|
package/store/index.ts
ADDED
package/store/layout.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { DrawerState } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* App Shell 布局状态管理
|
|
7
|
+
*/
|
|
8
|
+
export const useAppShellLayoutStore = defineStore('appShellLayout', () => {
|
|
9
|
+
// PC 端侧边栏折叠状态
|
|
10
|
+
const collapsed = ref(false)
|
|
11
|
+
|
|
12
|
+
// 移动端抽屉可见状态
|
|
13
|
+
const drawerVisible = ref(false)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 切换侧边栏折叠状态
|
|
17
|
+
*/
|
|
18
|
+
function toggleCollapse() {
|
|
19
|
+
collapsed.value = !collapsed.value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 设置侧边栏折叠状态
|
|
24
|
+
*/
|
|
25
|
+
function setCollapse(value: boolean) {
|
|
26
|
+
collapsed.value = value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 切换抽屉可见状态
|
|
31
|
+
*/
|
|
32
|
+
function toggleDrawer() {
|
|
33
|
+
drawerVisible.value = !drawerVisible.value
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 打开抽屉
|
|
38
|
+
*/
|
|
39
|
+
function openDrawer() {
|
|
40
|
+
drawerVisible.value = true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 关闭抽屉
|
|
45
|
+
*/
|
|
46
|
+
function closeDrawer() {
|
|
47
|
+
drawerVisible.value = false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 设置抽屉可见状态
|
|
52
|
+
*/
|
|
53
|
+
function setDrawerVisible(value: boolean) {
|
|
54
|
+
drawerVisible.value = value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
collapsed,
|
|
59
|
+
drawerVisible,
|
|
60
|
+
toggleCollapse,
|
|
61
|
+
setCollapse,
|
|
62
|
+
toggleDrawer,
|
|
63
|
+
openDrawer,
|
|
64
|
+
closeDrawer,
|
|
65
|
+
setDrawerVisible
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export type AppShellLayoutStore = ReturnType<typeof useAppShellLayoutStore>
|