@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 ADDED
@@ -0,0 +1,116 @@
1
+ # @mindbase/vue3-app-shell
2
+
3
+ Vue 3 通用应用壳基础包,提供响应式布局和 PWA 支持。
4
+
5
+ ## 特性
6
+
7
+ - ✅ **插槽化布局**:灵活的插槽系统,支持自定义顶栏、侧边栏、内容区
8
+ - ✅ **响应式设计**:自适应 PC 和移动端,断点优化
9
+ - ✅ **抽屉式侧边栏**:PC 固定显示,移动端全屏抽屉(无遮罩)
10
+ - ✅ **PWA 支持**:完整的 PWA 配置和安装引导
11
+ - ✅ **移动端优化**:Element Plus 触摸优化、手势支持
12
+ - ✅ **主题集成**:与 @mindbase/vue3-kit 主题系统集成
13
+
14
+ ## 安装
15
+
16
+ ```bash
17
+ npm install @mindbase/vue3-app-shell
18
+ ```
19
+
20
+ ## 依赖
21
+
22
+ ```json
23
+ {
24
+ "peerDependencies": {
25
+ "@mindbase/vue3-kit": "workspace:*",
26
+ "vue": "^3.4.0",
27
+ "pinia": "^2.1.0",
28
+ "vue-router": "^4.2.0",
29
+ "element-plus": "^2.5.0",
30
+ "hammerjs": "^2.0.8"
31
+ }
32
+ }
33
+ ```
34
+
35
+ ## 使用
36
+
37
+ ### 基础用法
38
+
39
+ ```typescript
40
+ import { createAppShell } from '@mindbase/vue3-app-shell'
41
+ import '@mindbase/vue3-app-shell/dist/style.css'
42
+
43
+ const { app, router } = createAppShell({
44
+ el: '#app',
45
+ pwa: {
46
+ title: '我的应用',
47
+ description: '添加到主屏幕,获得更好的体验'
48
+ }
49
+ })
50
+
51
+ app.mount('#app')
52
+ ```
53
+
54
+ ### 注册插槽
55
+
56
+ ```vue
57
+ <script setup>
58
+ import { useAppShellSlotsStore, AppShellSlotLocation } from '@mindbase/vue3-app-shell'
59
+
60
+ const slotsStore = useAppShellSlotsStore()
61
+
62
+ // 注册组件到顶栏左侧
63
+ slotsStore.registerSlot({
64
+ location: AppShellSlotLocation.HEADER_LEFT,
65
+ component: MyCustomComponent
66
+ })
67
+ </script>
68
+ ```
69
+
70
+ ### 自定义侧边栏
71
+
72
+ ```vue
73
+ <script setup>
74
+ import { useAppShellSlotsStore, AppShellSlotLocation } from '@mindbase/vue3-app-shell'
75
+ import MyMenu from './MyMenu.vue'
76
+
77
+ const slotsStore = useAppShellSlotsStore()
78
+
79
+ slotsStore.registerSlot({
80
+ location: AppShellSlotLocation.SIDEBAR,
81
+ component: MyMenu
82
+ })
83
+ </script>
84
+ ```
85
+
86
+ ## 插槽位置
87
+
88
+ - `HEADER_LEFT`:顶栏左侧
89
+ - `HEADER_CENTER`:顶栏中间
90
+ - `HEADER_RIGHT`:顶栏右侧
91
+ - `SIDEBAR`:侧边栏
92
+ - `CONTENT_TOP`:内容区顶部
93
+ - `CONTENT_BOTTOM`:内容区底部
94
+
95
+ ## 响应式断点
96
+
97
+ - 小屏:< 768px(抽屉式侧边栏)
98
+ - 中屏:768px - 1199px(固定侧边栏)
99
+ - 大屏:≥ 1200px(固定侧边栏)
100
+
101
+ ## PWA 配置
102
+
103
+ ```typescript
104
+ const pwaConfig = {
105
+ title: '安装应用',
106
+ description: '添加到主屏幕,获得更好的体验',
107
+ installButtonText: '安装',
108
+ cancelButtonText: '暂不安装',
109
+ dontShowAgainText: '不再提示',
110
+ autoShow: true
111
+ }
112
+ ```
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,3 @@
1
+ export * from './useBreakpoint'
2
+ export * from './useDrawer'
3
+ export * from './useTouchGesture'
@@ -0,0 +1,64 @@
1
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
2
+ import type { Breakpoint } from '../types'
3
+
4
+ // 断点阈值
5
+ const BREAKPOINTS = {
6
+ small: 768,
7
+ medium: 1200
8
+ }
9
+
10
+ /**
11
+ * 响应式断点检测
12
+ */
13
+ export function useBreakpoint() {
14
+ const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024)
15
+
16
+ // 更新窗口宽度
17
+ const updateWidth = () => {
18
+ windowWidth.value = window.innerWidth
19
+ }
20
+
21
+ // 当前断点
22
+ const breakpoint = computed<Breakpoint>(() => {
23
+ if (windowWidth.value < BREAKPOINTS.small) {
24
+ return 'small'
25
+ } else if (windowWidth.value < BREAKPOINTS.medium) {
26
+ return 'medium'
27
+ } else {
28
+ return 'large'
29
+ }
30
+ })
31
+
32
+ // 是否为小屏
33
+ const isSmall = computed(() => breakpoint.value === 'small')
34
+
35
+ // 是否为中屏
36
+ const isMedium = computed(() => breakpoint.value === 'medium')
37
+
38
+ // 是否为大屏
39
+ const isLarge = computed(() => breakpoint.value === 'large')
40
+
41
+ // 是否为移动端(小屏)
42
+ const isMobile = computed(() => breakpoint.value === 'small')
43
+
44
+ // 是否为桌面端(中屏或大屏)
45
+ const isDesktop = computed(() => breakpoint.value === 'medium' || breakpoint.value === 'large')
46
+
47
+ onMounted(() => {
48
+ window.addEventListener('resize', updateWidth)
49
+ })
50
+
51
+ onUnmounted(() => {
52
+ window.removeEventListener('resize', updateWidth)
53
+ })
54
+
55
+ return {
56
+ breakpoint,
57
+ windowWidth: computed(() => windowWidth.value),
58
+ isSmall,
59
+ isMedium,
60
+ isLarge,
61
+ isMobile,
62
+ isDesktop
63
+ }
64
+ }
@@ -0,0 +1,66 @@
1
+ import { computed } from 'vue'
2
+ import { useAppShellLayoutStore } from '../store'
3
+ import { useBreakpoint } from './useBreakpoint'
4
+
5
+ /**
6
+ * 抽屉控制
7
+ * 统一管理 PC 端折叠和移动端抽屉
8
+ */
9
+ export function useDrawer() {
10
+ const layoutStore = useAppShellLayoutStore()
11
+ const { isMobile } = useBreakpoint()
12
+
13
+ // PC 端:折叠状态
14
+ const collapsed = computed(() => layoutStore.collapsed)
15
+
16
+ // 移动端:抽屉可见状态
17
+ const visible = computed(() => layoutStore.drawerVisible)
18
+
19
+ // 是否为抽屉模式(移动端)
20
+ const isDrawerMode = computed(() => isMobile.value)
21
+
22
+ // 切换状态
23
+ const toggle = () => {
24
+ if (isMobile.value) {
25
+ layoutStore.toggleDrawer()
26
+ } else {
27
+ layoutStore.toggleCollapse()
28
+ }
29
+ }
30
+
31
+ // 打开
32
+ const open = () => {
33
+ if (isMobile.value) {
34
+ layoutStore.openDrawer()
35
+ } else {
36
+ layoutStore.setCollapse(false)
37
+ }
38
+ }
39
+
40
+ // 关闭
41
+ const close = () => {
42
+ if (isMobile.value) {
43
+ layoutStore.closeDrawer()
44
+ }
45
+ // PC 端不需要"关闭"操作,只能折叠
46
+ }
47
+
48
+ // 设置状态
49
+ const setState = (value: boolean) => {
50
+ if (isMobile.value) {
51
+ layoutStore.setDrawerVisible(value)
52
+ } else {
53
+ layoutStore.setCollapse(!value) // PC 端 false = 展开
54
+ }
55
+ }
56
+
57
+ return {
58
+ collapsed,
59
+ visible,
60
+ isDrawerMode,
61
+ toggle,
62
+ open,
63
+ close,
64
+ setState
65
+ }
66
+ }
@@ -0,0 +1,165 @@
1
+ import { onMounted, onUnmounted, type Ref } from 'vue'
2
+ import Hammer from 'hammerjs'
3
+
4
+ export interface GestureHandlers {
5
+ onSwipeLeft?: (event: Hammer.Input) => void
6
+ onSwipeRight?: (event: Hammer.Input) => void
7
+ onSwipeUp?: (event: Hammer.Input) => void
8
+ onSwipeDown?: (event: Hammer.Input) => void
9
+ onTap?: (event: Hammer.Input) => void
10
+ onDoubleTap?: (event: Hammer.Input) => void
11
+ onPan?: (event: Hammer.Input) => void
12
+ onPanStart?: (event: Hammer.Input) => void
13
+ onPanEnd?: (event: Hammer.Input) => void
14
+ onPress?: (event: Hammer.Input) => void
15
+ }
16
+
17
+ /**
18
+ * 触摸手势 composable
19
+ * 使用 Hammer.js 库
20
+ */
21
+ export function useTouchGesture(
22
+ targetRef: Ref<HTMLElement | undefined | null>,
23
+ handlers: GestureHandlers,
24
+ options?: {
25
+ // 是否识别滑动手势
26
+ recognizeSwipe?: boolean
27
+ // 是否识别点击手势
28
+ recognizeTap?: boolean
29
+ // 是否识别双击手势
30
+ recognizeDoubleTap?: boolean
31
+ // 是否识别拖拽手势
32
+ recognizePan?: boolean
33
+ // 是否识别长按手势
34
+ recognizePress?: boolean
35
+ // 滑动阈值
36
+ swipeThreshold?: number
37
+ }
38
+ ) {
39
+ let hammerInstance: Hammer.Manager | null = null
40
+
41
+ const {
42
+ recognizeSwipe = true,
43
+ recognizeTap = true,
44
+ recognizeDoubleTap = false,
45
+ recognizePan = false,
46
+ recognizePress = false,
47
+ swipeThreshold = 10
48
+ } = options || {}
49
+
50
+ onMounted(() => {
51
+ const element = targetRef.value
52
+ if (!element) return
53
+
54
+ // 创建 Hammer 实例
55
+ hammerInstance = new Hammer.Manager(element, {
56
+ touchAction: 'auto'
57
+ })
58
+
59
+ // 添加滑动手势识别器
60
+ if (recognizeSwipe) {
61
+ const swipe = new Hammer.Swipe({ threshold: swipeThreshold })
62
+ hammerInstance.add(swipe)
63
+
64
+ if (handlers.onSwipeLeft) {
65
+ swipe.recognizeWith(hammerInstance.get('swipe'))
66
+ hammerInstance.on('swipeleft', handlers.onSwipeLeft)
67
+ }
68
+ if (handlers.onSwipeRight) {
69
+ hammerInstance.on('swiperight', handlers.onSwipeRight)
70
+ }
71
+ if (handlers.onSwipeUp) {
72
+ hammerInstance.on('swipeup', handlers.onSwipeUp)
73
+ }
74
+ if (handlers.onSwipeDown) {
75
+ hammerInstance.on('swipedown', handlers.onSwipeDown)
76
+ }
77
+ }
78
+
79
+ // 添加点击手势识别器
80
+ if (recognizeTap) {
81
+ const tap = new Hammer.Tap()
82
+ hammerInstance.add(tap)
83
+
84
+ if (handlers.onTap) {
85
+ hammerInstance.on('tap', handlers.onTap)
86
+ }
87
+ }
88
+
89
+ // 添加双击手势识别器
90
+ if (recognizeDoubleTap) {
91
+ const doubleTap = new Hammer.Tap({ event: 'doubletap', taps: 2 })
92
+ hammerInstance.add(doubleTap)
93
+
94
+ if (handlers.onDoubleTap) {
95
+ hammerInstance.on('doubletap', handlers.onDoubleTap)
96
+ }
97
+ }
98
+
99
+ // 添加拖拽手势识别器
100
+ if (recognizePan) {
101
+ const pan = new Hammer.Pan()
102
+ hammerInstance.add(pan)
103
+
104
+ if (handlers.onPan) {
105
+ hammerInstance.on('pan', handlers.onPan)
106
+ }
107
+ if (handlers.onPanStart) {
108
+ hammerInstance.on('panstart', handlers.onPanStart)
109
+ }
110
+ if (handlers.onPanEnd) {
111
+ hammerInstance.on('panend', handlers.onPanEnd)
112
+ }
113
+ }
114
+
115
+ // 添加长按手势识别器
116
+ if (recognizePress) {
117
+ const press = new Hammer.Press({ time: 500 })
118
+ hammerInstance.add(press)
119
+
120
+ if (handlers.onPress) {
121
+ hammerInstance.on('press', handlers.onPress)
122
+ }
123
+ }
124
+ })
125
+
126
+ onUnmounted(() => {
127
+ if (hammerInstance) {
128
+ hammerInstance.destroy()
129
+ hammerInstance = null
130
+ }
131
+ })
132
+
133
+ return {
134
+ hammerInstance
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 抽屉手势 composable
140
+ * 专门用于抽屉的滑动手势
141
+ */
142
+ export function useDrawerGesture(
143
+ targetRef: Ref<HTMLElement | undefined | null>,
144
+ onOpen: () => void,
145
+ onClose: () => void,
146
+ isOpen: boolean
147
+ ) {
148
+ useTouchGesture(targetRef, {
149
+ onSwipeRight: (event) => {
150
+ // 右滑打开抽屉
151
+ if (!isOpen) {
152
+ onOpen()
153
+ }
154
+ },
155
+ onSwipeLeft: (event) => {
156
+ // 左滑关闭抽屉
157
+ if (isOpen) {
158
+ onClose()
159
+ }
160
+ }
161
+ }, {
162
+ recognizeSwipe: true,
163
+ swipeThreshold: 30
164
+ })
165
+ }
@@ -0,0 +1,82 @@
1
+ import type { App } from 'vue'
2
+ import { createBaseApp, type CreateBaseAppOptions } from '@mindbase/vue3-kit'
3
+ import { AppShellLayout } from './layout'
4
+ import { useAppShellPWAStore } from './store'
5
+ import type { AppShellOptions, AppShellResult } from './types'
6
+
7
+ /**
8
+ * 创建 App Shell 应用
9
+ */
10
+ export function createAppShell(options: AppShellOptions = {}): AppShellResult {
11
+ const {
12
+ el = '#app',
13
+ router: routerOptions = {},
14
+ pwa: pwaConfig = {},
15
+ layout: layoutConfig = {},
16
+ setupPinia = true,
17
+ setupRouter = true
18
+ } = options
19
+
20
+ // 创建基础应用(复用 vue3-kit 的 createBaseApp)
21
+ const result = createBaseApp({
22
+ el,
23
+ router: routerOptions,
24
+ setupPinia,
25
+ setupRouter
26
+ })
27
+
28
+ const { app, router, pinia } = result
29
+
30
+ // 封装 mount 函数,在 mounted 后执行初始化
31
+ const originalMount = app.mount
32
+ app.mount = (mountEl: HTMLElement | string) => {
33
+ const mountResult = originalMount(mountEl)
34
+
35
+ // 在 mounted 状态执行初始化
36
+ if (setupPinia && pinia) {
37
+ // 设置 PWA 配置
38
+ if (pwaConfig && Object.keys(pwaConfig).length > 0) {
39
+ const pwaStore = useAppShellPWAStore()
40
+ pwaStore.setConfig(pwaConfig.installPrompt || {})
41
+ setupPWA(pwaStore)
42
+ }
43
+ }
44
+
45
+ return mountResult
46
+ }
47
+
48
+ return {
49
+ app,
50
+ router,
51
+ pinia
52
+ }
53
+ }
54
+
55
+ /**
56
+ * 设置 PWA
57
+ */
58
+ function setupPWA(pwaStore: ReturnType<typeof useAppShellPWAStore>) {
59
+ if (typeof window === 'undefined') return
60
+
61
+ // 监听 beforeinstallprompt 事件
62
+ window.addEventListener('beforeinstallprompt', (event) => {
63
+ // 阻止默认的安装提示
64
+ event.preventDefault()
65
+
66
+ // 保存事件,稍后触发
67
+ pwaStore.setDeferredPrompt(event)
68
+ pwaStore.setCanInstall(true)
69
+
70
+ // 如果配置了自动显示,则显示提示
71
+ if (pwaStore.config.autoShow) {
72
+ pwaStore.showPrompt()
73
+ }
74
+ })
75
+
76
+ // 监听 appinstalled 事件
77
+ window.addEventListener('appinstalled', () => {
78
+ pwaStore.setIsInstalled(true)
79
+ pwaStore.setCanInstall(false)
80
+ pwaStore.hidePrompt()
81
+ })
82
+ }
package/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ // 核心函数
2
+ export { createAppShell } from './createAppShell'
3
+
4
+ // 类型定义
5
+ export * from './types'
6
+
7
+ // 布局组件
8
+ export * from './layout'
9
+
10
+ // Store
11
+ export * from './store'
12
+
13
+ // Composables
14
+ export * from './composables'
15
+
16
+ // PWA
17
+ export * from './pwa'
18
+
19
+ // 样式
20
+ import './styles/index.scss'
@@ -0,0 +1,41 @@
1
+ <template>
2
+ <section class="app-shell">
3
+ <!-- 顶栏 -->
4
+ <app-header />
5
+
6
+ <!-- 主体区域 -->
7
+ <section class="app-shell__body">
8
+ <!-- 侧边栏(抽屉式) -->
9
+ <app-drawer />
10
+
11
+ <!-- 主内容区 -->
12
+ <app-main />
13
+ </section>
14
+
15
+ <!-- PWA 安装引导 -->
16
+ <pwa-install-prompt />
17
+ </section>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import AppHeader from './components/AppHeader.vue'
22
+ import AppDrawer from './components/AppDrawer.vue'
23
+ import AppMain from './components/AppMain.vue'
24
+ import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
25
+ </script>
26
+
27
+ <style lang="scss">
28
+ .app-shell {
29
+ display: flex;
30
+ flex-direction: column;
31
+ width: 100%;
32
+ height: 100vh;
33
+ overflow: hidden;
34
+
35
+ .app-shell__body {
36
+ flex: 1;
37
+ display: flex;
38
+ overflow: hidden;
39
+ }
40
+ }
41
+ </style>
@@ -0,0 +1,87 @@
1
+ <template>
2
+ <!-- PC 端:固定侧边栏 -->
3
+ <aside
4
+ class="app-drawer app-drawer--pc"
5
+ :class="{ 'is-collapsed': collapsed }">
6
+ <template v-for="slot in sidebarSlots" :key="slot">
7
+ <component :is="slot.component" v-bind="slot.props" />
8
+ </template>
9
+ </aside>
10
+
11
+ <!-- 移动端:全屏抽屉(条件渲染) -->
12
+ <teleport to="body">
13
+ <aside
14
+ class="app-drawer app-drawer--mobile"
15
+ :class="{ visible: drawerVisible }">
16
+ <template v-for="slot in sidebarSlots" :key="slot">
17
+ <component :is="slot.component" v-bind="slot.props" />
18
+ </template>
19
+ </aside>
20
+ </teleport>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import { computed } from 'vue'
25
+ import { storeToRefs } from 'pinia'
26
+ import { useAppShellLayoutStore, useAppShellSlotsStore } from '../../store'
27
+
28
+ const layoutStore = useAppShellLayoutStore()
29
+ const slotsStore = useAppShellSlotsStore()
30
+
31
+ const { collapsed, drawerVisible } = storeToRefs(layoutStore)
32
+ const { sidebar } = storeToRefs(slotsStore)
33
+ </script>
34
+
35
+ <style lang="scss">
36
+ /* PC 端:固定左侧 */
37
+ @media (min-width: 768px) {
38
+ .app-drawer--pc {
39
+ display: block;
40
+ position: fixed;
41
+ left: 0;
42
+ top: var(--mb-app-shell-header-height, 60px);
43
+ bottom: 0;
44
+ width: var(--mb-app-shell-drawer-width, 240px);
45
+ background-color: var(--mb-app-shell-drawer-bg, #ffffff);
46
+ border-right: var(--mb-app-shell-drawer-border-right, 1px solid #e4e7ed);
47
+ transition: width 0.3s ease;
48
+ overflow-y: auto;
49
+ overflow-x: hidden;
50
+ z-index: 1000;
51
+
52
+ &.is-collapsed {
53
+ width: var(--mb-app-shell-drawer-collapsed-width, 64px);
54
+ }
55
+ }
56
+
57
+ .app-drawer--mobile {
58
+ display: none;
59
+ }
60
+ }
61
+
62
+ /* 移动端:全屏抽屉(无遮罩) */
63
+ @media (max-width: 767px) {
64
+ .app-drawer--pc {
65
+ display: none;
66
+ }
67
+
68
+ .app-drawer--mobile {
69
+ display: block;
70
+ position: fixed;
71
+ left: 0;
72
+ top: 0;
73
+ bottom: 0;
74
+ width: 100%;
75
+ background-color: var(--mb-app-shell-drawer-bg, #ffffff);
76
+ transform: translateX(-100%);
77
+ transition: transform 0.3s ease;
78
+ overflow-y: auto;
79
+ overflow-x: hidden;
80
+ z-index: 9999;
81
+
82
+ &.visible {
83
+ transform: translateX(0);
84
+ }
85
+ }
86
+ }
87
+ </style>