@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.
@@ -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>
@@ -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
@@ -0,0 +1,3 @@
1
+ export * from './manifest'
2
+ export * from './utils'
3
+ export * from './types'
@@ -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
@@ -0,0 +1,4 @@
1
+ /**
2
+ * PWA 相关类型定义(已在 types/pwa.ts 中定义,此文件为兼容性导出)
3
+ */
4
+ export type { PWAInstallConfig, PWAConfig, PWAInstallState } from '../types/pwa'
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
@@ -0,0 +1,3 @@
1
+ export * from './slots'
2
+ export * from './layout'
3
+ export * from './pwa'
@@ -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>