@lvetechs/create-app 1.0.4 → 1.0.6
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/package.json +1 -1
- package/templates/react/.env +3 -3
- package/templates/react/.env.development +3 -3
- package/templates/react/.env.production +3 -3
- package/templates/react/package.json +1 -1
- package/templates/react/pnpm-lock.yaml +5 -5
- package/templates/react/src/App.tsx +7 -1
- package/templates/react/src/api/notification.ts +43 -0
- package/templates/react/src/components/NotificationButton/index.tsx +219 -0
- package/templates/react/src/components/Toast/index.tsx +150 -0
- package/templates/react/src/hooks/useForm.ts +77 -0
- package/templates/react/src/layouts/DefaultLayout.tsx +2 -0
- package/templates/react/src/layouts/menuConfig.ts +3 -33
- package/templates/react/src/router/index.tsx +0 -39
- package/templates/react/src/stores/app.ts +141 -3
- package/templates/react/src/stores/notification.ts +146 -0
- package/templates/react/src/stores/permission.ts +173 -0
- package/templates/react/src/stores/user.ts +151 -4
- package/templates/react/src/views/home/index.tsx +167 -6
- package/templates/react/src/views/system/user/index.tsx +171 -5
- package/templates/vue/.env +2 -2
- package/templates/vue/.env.development +2 -2
- package/templates/vue/.env.production +2 -2
- package/templates/vue/pnpm-lock.yaml +3307 -3307
- package/templates/vue/src/App.vue +2 -0
- package/templates/vue/src/api/notification.ts +43 -0
- package/templates/vue/src/auto-imports.d.ts +5 -0
- package/templates/vue/src/components/NotificationButton/index.vue +242 -0
- package/templates/vue/src/components/Toast/index.vue +126 -0
- package/templates/vue/src/components.d.ts +2 -0
- package/templates/vue/src/layouts/DefaultLayout.vue +2 -0
- package/templates/vue/src/layouts/menuConfig.ts +3 -33
- package/templates/vue/src/router/index.ts +3 -88
- package/templates/vue/src/stores/app.ts +133 -2
- package/templates/vue/src/stores/notification.ts +189 -0
- package/templates/vue/src/stores/permission.ts +184 -0
- package/templates/vue/src/stores/user.ts +109 -2
- package/templates/vue/src/views/home/index.vue +7 -7
- package/templates/react/src/views/about/index.tsx +0 -40
- package/templates/react/src/views/login/index.tsx +0 -138
- package/templates/react/src/views/register/index.tsx +0 -143
- package/templates/react/src/views/result/fail.tsx +0 -39
- package/templates/react/src/views/result/success.tsx +0 -35
- package/templates/react/src/views/screen/index.tsx +0 -120
- package/templates/react/src/views/system/log/login.tsx +0 -51
- package/templates/react/src/views/system/log/operation.tsx +0 -47
- package/templates/react/src/views/system/menu/index.tsx +0 -62
- package/templates/react/src/views/system/role/index.tsx +0 -63
- package/templates/vue/src/views/about/index.vue +0 -67
- package/templates/vue/src/views/login/index.vue +0 -153
- package/templates/vue/src/views/register/index.vue +0 -169
- package/templates/vue/src/views/result/fail.vue +0 -92
- package/templates/vue/src/views/result/success.vue +0 -92
- package/templates/vue/src/views/screen/index.vue +0 -150
- package/templates/vue/src/views/system/log/login.vue +0 -51
- package/templates/vue/src/views/system/log/operation.vue +0 -47
- package/templates/vue/src/views/system/menu/index.vue +0 -58
- package/templates/vue/src/views/system/role/index.vue +0 -59
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { http } from '@/utils/request'
|
|
2
|
+
import type { Notification } from '@/stores/notification'
|
|
3
|
+
|
|
4
|
+
/** 获取通知列表参数 */
|
|
5
|
+
export interface GetNotificationsParams {
|
|
6
|
+
page?: number
|
|
7
|
+
pageSize?: number
|
|
8
|
+
type?: Notification['type']
|
|
9
|
+
category?: string
|
|
10
|
+
unreadOnly?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 获取通知列表响应 */
|
|
14
|
+
export interface GetNotificationsResult {
|
|
15
|
+
list: Notification[]
|
|
16
|
+
total: number
|
|
17
|
+
unreadCount: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 获取通知列表 */
|
|
21
|
+
export function getNotificationsApi(params?: GetNotificationsParams) {
|
|
22
|
+
return http.get<GetNotificationsResult>('/notification/list', { params })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 标记通知为已读 */
|
|
26
|
+
export function markNotificationReadApi(id: string) {
|
|
27
|
+
return http.post(`/notification/${id}/read`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 批量标记为已读 */
|
|
31
|
+
export function markAllReadApi() {
|
|
32
|
+
return http.post('/notification/read-all')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 删除通知 */
|
|
36
|
+
export function deleteNotificationApi(id: string) {
|
|
37
|
+
return http.delete(`/notification/${id}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 获取未读数量 */
|
|
41
|
+
export function getUnreadCountApi() {
|
|
42
|
+
return http.get<{ count: number }>('/notification/unread-count')
|
|
43
|
+
}
|
|
@@ -144,6 +144,7 @@ declare global {
|
|
|
144
144
|
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
|
145
145
|
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
|
146
146
|
const useCached: typeof import('@vueuse/core')['useCached']
|
|
147
|
+
const useCartStore: typeof import('./stores/cart')['useCartStore']
|
|
147
148
|
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
|
148
149
|
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
|
149
150
|
const useCloned: typeof import('@vueuse/core')['useCloned']
|
|
@@ -213,6 +214,7 @@ declare global {
|
|
|
213
214
|
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
|
214
215
|
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
|
215
216
|
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
|
217
|
+
const useNotificationStore: typeof import('./stores/notification')['useNotificationStore']
|
|
216
218
|
const useNow: typeof import('@vueuse/core')['useNow']
|
|
217
219
|
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
|
218
220
|
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
|
@@ -223,6 +225,7 @@ declare global {
|
|
|
223
225
|
const useParentElement: typeof import('@vueuse/core')['useParentElement']
|
|
224
226
|
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
|
225
227
|
const usePermission: typeof import('@vueuse/core')['usePermission']
|
|
228
|
+
const usePermissionStore: typeof import('./stores/permission')['usePermissionStore']
|
|
226
229
|
const usePointer: typeof import('@vueuse/core')['usePointer']
|
|
227
230
|
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
|
228
231
|
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
|
@@ -523,6 +526,7 @@ declare module 'vue' {
|
|
|
523
526
|
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
|
|
524
527
|
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
|
|
525
528
|
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
|
|
529
|
+
readonly useNotificationStore: UnwrapRef<typeof import('./stores/notification')['useNotificationStore']>
|
|
526
530
|
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
|
|
527
531
|
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
|
|
528
532
|
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
|
|
@@ -533,6 +537,7 @@ declare module 'vue' {
|
|
|
533
537
|
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
|
|
534
538
|
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
|
|
535
539
|
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
|
|
540
|
+
readonly usePermissionStore: UnwrapRef<typeof import('./stores/permission')['usePermissionStore']>
|
|
536
541
|
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
|
|
537
542
|
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
|
|
538
543
|
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="relative" ref="dropdownRef">
|
|
3
|
+
<button
|
|
4
|
+
class="relative flex items-center justify-center w-10 h-10 rounded-lg border border-slate-200 hover:border-indigo-500 hover:text-indigo-500 transition-colors"
|
|
5
|
+
@click="toggleDropdown"
|
|
6
|
+
title="消息通知"
|
|
7
|
+
>
|
|
8
|
+
<span class="text-lg">🔔</span>
|
|
9
|
+
<span
|
|
10
|
+
v-if="notificationStore.unreadCount > 0"
|
|
11
|
+
class="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-xs font-bold text-white bg-red-500 rounded-full"
|
|
12
|
+
>
|
|
13
|
+
{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}
|
|
14
|
+
</span>
|
|
15
|
+
</button>
|
|
16
|
+
|
|
17
|
+
<Transition name="fade">
|
|
18
|
+
<div
|
|
19
|
+
v-if="showDropdown"
|
|
20
|
+
class="absolute right-0 top-12 w-80 bg-white rounded-lg shadow-lg border border-slate-200 z-50 max-h-[500px] flex flex-col"
|
|
21
|
+
>
|
|
22
|
+
<!-- 头部 -->
|
|
23
|
+
<div class="flex items-center justify-between p-4 border-b border-slate-200">
|
|
24
|
+
<h3 class="font-semibold text-slate-700">消息通知</h3>
|
|
25
|
+
<div class="flex gap-2">
|
|
26
|
+
<button
|
|
27
|
+
class="text-xs text-indigo-600 hover:text-indigo-700"
|
|
28
|
+
@click="fetchNotifications"
|
|
29
|
+
>
|
|
30
|
+
刷新
|
|
31
|
+
</button>
|
|
32
|
+
<!-- <button
|
|
33
|
+
v-if="notificationStore.unreadCount > 0"
|
|
34
|
+
class="text-xs text-indigo-600 hover:text-indigo-700"
|
|
35
|
+
@click="handleMarkAllRead"
|
|
36
|
+
>
|
|
37
|
+
全部已读
|
|
38
|
+
</button> -->
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- 消息列表 -->
|
|
43
|
+
<div class="flex-1 overflow-y-auto">
|
|
44
|
+
<div v-if="notificationStore.notifications.length === 0" class="flex flex-col items-center justify-center py-8 text-slate-400">
|
|
45
|
+
<span class="text-4xl mb-2"></span>
|
|
46
|
+
<p class="text-sm">暂无消息</p>
|
|
47
|
+
</div>
|
|
48
|
+
<div v-else class="divide-y divide-slate-100">
|
|
49
|
+
<div
|
|
50
|
+
v-for="item in notificationStore.notifications"
|
|
51
|
+
:key="item.id"
|
|
52
|
+
:class="[
|
|
53
|
+
'p-3 hover:bg-slate-50 cursor-pointer transition-colors',
|
|
54
|
+
!item.read ? 'bg-blue-50' : ''
|
|
55
|
+
]"
|
|
56
|
+
@click="!item.read && handleMarkAsRead(item.id)"
|
|
57
|
+
>
|
|
58
|
+
<div class="flex items-start gap-2">
|
|
59
|
+
<span :class="['text-lg', typeColors[item.type]]">
|
|
60
|
+
{{ typeIcons[item.type] }}
|
|
61
|
+
</span>
|
|
62
|
+
<div class="flex-1 min-w-0">
|
|
63
|
+
<div class="flex items-center justify-between mb-1">
|
|
64
|
+
<h4 class="font-medium text-sm text-slate-700 truncate">
|
|
65
|
+
{{ item.title }}
|
|
66
|
+
</h4>
|
|
67
|
+
<span
|
|
68
|
+
v-if="!item.read"
|
|
69
|
+
class="flex-shrink-0 w-2 h-2 bg-blue-500 rounded-full ml-2"
|
|
70
|
+
></span>
|
|
71
|
+
</div>
|
|
72
|
+
<p class="text-xs text-slate-500 line-clamp-2 mb-1">
|
|
73
|
+
{{ item.content }}
|
|
74
|
+
</p>
|
|
75
|
+
<div class="flex items-center justify-between">
|
|
76
|
+
<span class="text-xs text-slate-400">{{ formatTime(item.timestamp) }}</span>
|
|
77
|
+
<span
|
|
78
|
+
v-if="item.category"
|
|
79
|
+
class="text-xs text-slate-400 bg-slate-100 px-2 py-0.5 rounded"
|
|
80
|
+
>
|
|
81
|
+
{{ item.category }}
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- 底部 -->
|
|
91
|
+
<!-- <div v-if="notificationStore.notifications.length > 0" class="p-2 border-t border-slate-200 text-center">
|
|
92
|
+
<button
|
|
93
|
+
class="text-xs text-slate-500 hover:text-indigo-600"
|
|
94
|
+
@click="() => console.log('查看全部消息')"
|
|
95
|
+
>
|
|
96
|
+
查看全部消息
|
|
97
|
+
</button>
|
|
98
|
+
</div> -->
|
|
99
|
+
</div>
|
|
100
|
+
</Transition>
|
|
101
|
+
</div>
|
|
102
|
+
</template>
|
|
103
|
+
|
|
104
|
+
<script setup lang="ts">
|
|
105
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
106
|
+
import { useNotificationStore } from '@/stores/notification'
|
|
107
|
+
import { getNotificationsApi, markNotificationReadApi, markAllReadApi } from '@/api/notification'
|
|
108
|
+
import type { Notification } from '@/stores/notification'
|
|
109
|
+
|
|
110
|
+
const notificationStore = useNotificationStore()
|
|
111
|
+
const showDropdown = ref(false)
|
|
112
|
+
const dropdownRef = ref<HTMLElement>()
|
|
113
|
+
|
|
114
|
+
const typeIcons = {
|
|
115
|
+
info: 'ℹ️',
|
|
116
|
+
success: '✓',
|
|
117
|
+
warning: '⚠️',
|
|
118
|
+
error: '✕'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const typeColors = {
|
|
122
|
+
info: 'text-blue-500',
|
|
123
|
+
success: 'text-green-500',
|
|
124
|
+
warning: 'text-yellow-500',
|
|
125
|
+
error: 'text-red-500'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toggleDropdown() {
|
|
129
|
+
showDropdown.value = !showDropdown.value
|
|
130
|
+
// if (showDropdown.value) {
|
|
131
|
+
// fetchNotifications()
|
|
132
|
+
// }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 获取最新消息
|
|
136
|
+
async function fetchNotifications() {
|
|
137
|
+
try {
|
|
138
|
+
const response = await getNotificationsApi({ unreadOnly: false, pageSize: 20 })
|
|
139
|
+
if (response.data) {
|
|
140
|
+
// 将新消息添加到 store(实际项目中可能需要去重逻辑)
|
|
141
|
+
const existingIds = new Set(notificationStore.notifications.map((n) => n.id))
|
|
142
|
+
response.data.list.forEach((item) => {
|
|
143
|
+
if (!existingIds.has(item.id)) {
|
|
144
|
+
notificationStore.addNotification({
|
|
145
|
+
title: item.title,
|
|
146
|
+
content: item.content,
|
|
147
|
+
type: item.type,
|
|
148
|
+
category: item.category,
|
|
149
|
+
actionUrl: item.actionUrl
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
if ((window as any).$toast) {
|
|
154
|
+
;(window as any).$toast.success(`获取到 ${response.data.list.length} 条消息`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('获取消息失败:', error)
|
|
159
|
+
if ((window as any).$toast) {
|
|
160
|
+
;(window as any).$toast.error('获取消息失败')
|
|
161
|
+
}
|
|
162
|
+
// 如果 API 失败,使用示例数据
|
|
163
|
+
notificationStore.initSampleData()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 标记为已读
|
|
168
|
+
async function handleMarkAsRead(id: string) {
|
|
169
|
+
try {
|
|
170
|
+
await markNotificationReadApi(id)
|
|
171
|
+
notificationStore.markAsRead(id)
|
|
172
|
+
if ((window as any).$toast) {
|
|
173
|
+
;(window as any).$toast.success('已标记为已读')
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('标记已读失败:', error)
|
|
177
|
+
if ((window as any).$toast) {
|
|
178
|
+
;(window as any).$toast.error('标记已读失败')
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 全部标记为已读
|
|
184
|
+
async function handleMarkAllRead() {
|
|
185
|
+
try {
|
|
186
|
+
await markAllReadApi()
|
|
187
|
+
notificationStore.markAllAsRead()
|
|
188
|
+
if ((window as any).$toast) {
|
|
189
|
+
;(window as any).$toast.success('已全部标记为已读')
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error('全部标记已读失败:', error)
|
|
193
|
+
if ((window as any).$toast) {
|
|
194
|
+
;(window as any).$toast.error('全部标记已读失败')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 格式化时间
|
|
200
|
+
function formatTime(timestamp: number) {
|
|
201
|
+
const now = Date.now()
|
|
202
|
+
const diff = now - timestamp
|
|
203
|
+
const minutes = Math.floor(diff / 60000)
|
|
204
|
+
const hours = Math.floor(diff / 3600000)
|
|
205
|
+
const days = Math.floor(diff / 86400000)
|
|
206
|
+
|
|
207
|
+
if (minutes < 1) return '刚刚'
|
|
208
|
+
if (minutes < 60) return `${minutes}分钟前`
|
|
209
|
+
if (hours < 24) return `${hours}小时前`
|
|
210
|
+
if (days < 7) return `${days}天前`
|
|
211
|
+
return new Date(timestamp).toLocaleDateString()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 点击外部关闭下拉菜单
|
|
215
|
+
function handleClickOutside(event: MouseEvent) {
|
|
216
|
+
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
|
217
|
+
showDropdown.value = false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
onMounted(() => {
|
|
222
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
223
|
+
// 初始化时获取一次消息
|
|
224
|
+
// fetchNotifications()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
onUnmounted(() => {
|
|
228
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
229
|
+
})
|
|
230
|
+
</script>
|
|
231
|
+
|
|
232
|
+
<style scoped lang="scss">
|
|
233
|
+
.fade-enter-active,
|
|
234
|
+
.fade-leave-active {
|
|
235
|
+
transition: opacity 0.2s ease;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.fade-enter-from,
|
|
239
|
+
.fade-leave-to {
|
|
240
|
+
opacity: 0;
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<TransitionGroup
|
|
4
|
+
name="toast"
|
|
5
|
+
tag="div"
|
|
6
|
+
class="fixed top-4 right-4 z-[9999] flex flex-col gap-2"
|
|
7
|
+
>
|
|
8
|
+
<div
|
|
9
|
+
v-for="toast in toasts"
|
|
10
|
+
:key="toast.id"
|
|
11
|
+
:class="[
|
|
12
|
+
'flex items-center gap-3 rounded-lg px-4 py-3 text-white shadow-lg transition-all cursor-pointer',
|
|
13
|
+
toastColors[toast.type]
|
|
14
|
+
]"
|
|
15
|
+
@click="closeToast(toast.id)"
|
|
16
|
+
>
|
|
17
|
+
<span class="text-lg">{{ toastIcons[toast.type] }}</span>
|
|
18
|
+
<span class="flex-1 text-sm">{{ toast.message }}</span>
|
|
19
|
+
<button
|
|
20
|
+
class="ml-2 text-white opacity-70 hover:opacity-100"
|
|
21
|
+
@click.stop="closeToast(toast.id)"
|
|
22
|
+
>
|
|
23
|
+
✕
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
26
|
+
</TransitionGroup>
|
|
27
|
+
</Teleport>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup lang="ts">
|
|
31
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
32
|
+
|
|
33
|
+
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
|
34
|
+
|
|
35
|
+
export interface ToastItem {
|
|
36
|
+
id: string
|
|
37
|
+
message: string
|
|
38
|
+
type: ToastType
|
|
39
|
+
duration?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const toasts = ref<ToastItem[]>([])
|
|
43
|
+
|
|
44
|
+
const toastIcons = {
|
|
45
|
+
success: '✓',
|
|
46
|
+
error: '✕',
|
|
47
|
+
warning: '⚠',
|
|
48
|
+
info: 'ℹ'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toastColors = {
|
|
52
|
+
success: 'bg-green-500',
|
|
53
|
+
error: 'bg-red-500',
|
|
54
|
+
warning: 'bg-yellow-500',
|
|
55
|
+
info: 'bg-blue-500'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const timers = new Map<string, NodeJS.Timeout>()
|
|
59
|
+
|
|
60
|
+
function closeToast(id: string) {
|
|
61
|
+
const index = toasts.value.findIndex((t) => t.id === id)
|
|
62
|
+
if (index > -1) {
|
|
63
|
+
toasts.value.splice(index, 1)
|
|
64
|
+
const timer = timers.get(id)
|
|
65
|
+
if (timer) {
|
|
66
|
+
clearTimeout(timer)
|
|
67
|
+
timers.delete(id)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function showToast(message: string, type: ToastType = 'info', duration = 3000) {
|
|
73
|
+
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
74
|
+
const toast: ToastItem = { id, message, type, duration }
|
|
75
|
+
toasts.value.push(toast)
|
|
76
|
+
|
|
77
|
+
if (duration && duration > 0) {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
closeToast(id)
|
|
80
|
+
}, duration)
|
|
81
|
+
timers.set(id, timer)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return id
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 暴露方法给全局使用
|
|
88
|
+
const toast = {
|
|
89
|
+
success: (message: string, duration?: number) => showToast(message, 'success', duration),
|
|
90
|
+
error: (message: string, duration?: number) => showToast(message, 'error', duration),
|
|
91
|
+
warning: (message: string, duration?: number) => showToast(message, 'warning', duration),
|
|
92
|
+
info: (message: string, duration?: number) => showToast(message, 'info', duration)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 挂载到全局
|
|
96
|
+
onMounted(() => {
|
|
97
|
+
;(window as any).$toast = toast
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
onUnmounted(() => {
|
|
101
|
+
timers.forEach((timer) => clearTimeout(timer))
|
|
102
|
+
timers.clear()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
defineExpose({ toast })
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<style scoped lang="scss">
|
|
109
|
+
.toast-enter-active {
|
|
110
|
+
transition: all 0.3s ease-out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.toast-leave-active {
|
|
114
|
+
transition: all 0.2s ease-in;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.toast-enter-from {
|
|
118
|
+
opacity: 0;
|
|
119
|
+
transform: translateX(100%);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.toast-leave-to {
|
|
123
|
+
opacity: 0;
|
|
124
|
+
transform: translateX(100%);
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -7,8 +7,10 @@ export {}
|
|
|
7
7
|
|
|
8
8
|
declare module 'vue' {
|
|
9
9
|
export interface GlobalComponents {
|
|
10
|
+
NotificationButton: typeof import('./components/NotificationButton/index.vue')['default']
|
|
10
11
|
RouterLink: typeof import('vue-router')['RouterLink']
|
|
11
12
|
RouterView: typeof import('vue-router')['RouterView']
|
|
12
13
|
SvgIcon: typeof import('./components/SvgIcon.vue')['default']
|
|
14
|
+
Toast: typeof import('./components/Toast/index.vue')['default']
|
|
13
15
|
}
|
|
14
16
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useAppStore } from '@/stores/app'
|
|
3
3
|
import SidebarMenu from './components/SidebarMenu.vue'
|
|
4
4
|
import { menuRoutes } from './menuConfig'
|
|
5
|
+
import NotificationButton from '@/components/NotificationButton/index.vue'
|
|
5
6
|
const title = computed(() => import.meta.env.VITE_APP_TITLE)
|
|
6
7
|
const appStore = useAppStore()
|
|
7
8
|
</script>
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
</button>
|
|
20
21
|
</div>
|
|
21
22
|
<div class="header-right">
|
|
23
|
+
<NotificationButton />
|
|
22
24
|
<button
|
|
23
25
|
class="theme-btn"
|
|
24
26
|
@click="appStore.setTheme(appStore.theme === 'light' ? 'dark' : 'light')"
|
|
@@ -25,49 +25,19 @@ export const menuRoutes: MenuItem[] = [
|
|
|
25
25
|
{
|
|
26
26
|
path: '/home',
|
|
27
27
|
title: '首页',
|
|
28
|
-
icon: '
|
|
28
|
+
icon: ''
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
path: '/system',
|
|
32
32
|
title: '系统管理',
|
|
33
|
-
icon: '
|
|
33
|
+
icon: '',
|
|
34
34
|
children: [
|
|
35
35
|
{
|
|
36
36
|
path: '/system/user',
|
|
37
37
|
title: '用户管理',
|
|
38
|
-
icon: '
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
path: '/system/role',
|
|
42
|
-
title: '角色管理',
|
|
43
|
-
icon: '🔑'
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
path: '/system/menu',
|
|
47
|
-
title: '菜单管理',
|
|
48
|
-
icon: '📋'
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
path: '/system/log',
|
|
52
|
-
title: '日志管理',
|
|
53
|
-
icon: '📝',
|
|
54
|
-
children: [
|
|
55
|
-
{
|
|
56
|
-
path: '/system/log/operation',
|
|
57
|
-
title: '操作日志'
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
path: '/system/log/login',
|
|
61
|
-
title: '登录日志'
|
|
62
|
-
}
|
|
63
|
-
]
|
|
38
|
+
icon: ''
|
|
64
39
|
}
|
|
65
40
|
]
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
path: '/about',
|
|
69
|
-
title: '关于',
|
|
70
|
-
icon: 'ℹ️'
|
|
71
41
|
}
|
|
72
42
|
]
|
|
73
43
|
|
|
@@ -32,7 +32,7 @@ const layoutRoutes: RouteRecordRaw = {
|
|
|
32
32
|
path: 'home',
|
|
33
33
|
name: 'Home',
|
|
34
34
|
component: () => import('@/views/home/index.vue'),
|
|
35
|
-
meta: { title: '首页', icon: '
|
|
35
|
+
meta: { title: '首页', icon: '', requireAuth: true }
|
|
36
36
|
},
|
|
37
37
|
|
|
38
38
|
// ---------- 系统管理 (多级路由示例) ----------
|
|
@@ -40,58 +40,17 @@ const layoutRoutes: RouteRecordRaw = {
|
|
|
40
40
|
path: 'system',
|
|
41
41
|
name: 'System',
|
|
42
42
|
redirect: '/system/user',
|
|
43
|
-
meta: { title: '系统管理', icon: '
|
|
43
|
+
meta: { title: '系统管理', icon: '' },
|
|
44
44
|
children: [
|
|
45
45
|
{
|
|
46
46
|
path: 'user',
|
|
47
47
|
name: 'SystemUser',
|
|
48
48
|
component: () => import('@/views/system/user/index.vue'),
|
|
49
|
-
meta: { title: '用户管理', icon: '
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
path: 'role',
|
|
53
|
-
name: 'SystemRole',
|
|
54
|
-
component: () => import('@/views/system/role/index.vue'),
|
|
55
|
-
meta: { title: '角色管理', icon: '🔑', requireAuth: true }
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
path: 'menu',
|
|
59
|
-
name: 'SystemMenu',
|
|
60
|
-
component: () => import('@/views/system/menu/index.vue'),
|
|
61
|
-
meta: { title: '菜单管理', icon: '📋', requireAuth: true }
|
|
62
|
-
},
|
|
63
|
-
// 三级路由示例: 系统管理 > 日志管理 > 操作日志 / 登录日志
|
|
64
|
-
{
|
|
65
|
-
path: 'log',
|
|
66
|
-
name: 'SystemLog',
|
|
67
|
-
redirect: '/system/log/operation',
|
|
68
|
-
meta: { title: '日志管理', icon: '📝' },
|
|
69
|
-
children: [
|
|
70
|
-
{
|
|
71
|
-
path: 'operation',
|
|
72
|
-
name: 'OperationLog',
|
|
73
|
-
component: () => import('@/views/system/log/operation.vue'),
|
|
74
|
-
meta: { title: '操作日志', requireAuth: true }
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
path: 'login',
|
|
78
|
-
name: 'LoginLog',
|
|
79
|
-
component: () => import('@/views/system/log/login.vue'),
|
|
80
|
-
meta: { title: '登录日志', requireAuth: true }
|
|
81
|
-
}
|
|
82
|
-
]
|
|
49
|
+
meta: { title: '用户管理', icon: '', requireAuth: true }
|
|
83
50
|
}
|
|
84
51
|
]
|
|
85
52
|
},
|
|
86
53
|
|
|
87
|
-
// ---------- 关于页 ----------
|
|
88
|
-
{
|
|
89
|
-
path: 'about',
|
|
90
|
-
name: 'About',
|
|
91
|
-
component: () => import('@/views/about/index.vue'),
|
|
92
|
-
meta: { title: '关于', icon: 'ℹ️' }
|
|
93
|
-
},
|
|
94
|
-
|
|
95
54
|
// ---------- 详情页 (Layout 内但菜单隐藏) ----------
|
|
96
55
|
{
|
|
97
56
|
path: 'user/detail/:id',
|
|
@@ -102,49 +61,6 @@ const layoutRoutes: RouteRecordRaw = {
|
|
|
102
61
|
]
|
|
103
62
|
}
|
|
104
63
|
|
|
105
|
-
// ==================== 独立页面 (无 Layout) ====================
|
|
106
|
-
const standaloneRoutes: RouteRecordRaw[] = [
|
|
107
|
-
{
|
|
108
|
-
path: '/login',
|
|
109
|
-
name: 'Login',
|
|
110
|
-
component: () => import('@/views/login/index.vue'),
|
|
111
|
-
meta: { title: '登录' }
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
path: '/register',
|
|
115
|
-
name: 'Register',
|
|
116
|
-
component: () => import('@/views/register/index.vue'),
|
|
117
|
-
meta: { title: '注册' }
|
|
118
|
-
},
|
|
119
|
-
// 独立结果页 (不带侧边栏,全屏展示)
|
|
120
|
-
{
|
|
121
|
-
path: '/result',
|
|
122
|
-
name: 'Result',
|
|
123
|
-
redirect: '/result/success',
|
|
124
|
-
children: [
|
|
125
|
-
{
|
|
126
|
-
path: 'success',
|
|
127
|
-
name: 'ResultSuccess',
|
|
128
|
-
component: () => import('@/views/result/success.vue'),
|
|
129
|
-
meta: { title: '操作成功' }
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
path: 'fail',
|
|
133
|
-
name: 'ResultFail',
|
|
134
|
-
component: () => import('@/views/result/fail.vue'),
|
|
135
|
-
meta: { title: '操作失败' }
|
|
136
|
-
}
|
|
137
|
-
]
|
|
138
|
-
},
|
|
139
|
-
// 独立大屏页面示例
|
|
140
|
-
{
|
|
141
|
-
path: '/screen',
|
|
142
|
-
name: 'DataScreen',
|
|
143
|
-
component: () => import('@/views/screen/index.vue'),
|
|
144
|
-
meta: { title: '数据大屏' }
|
|
145
|
-
}
|
|
146
|
-
]
|
|
147
|
-
|
|
148
64
|
// ==================== 错误页 ====================
|
|
149
65
|
const errorRoutes: RouteRecordRaw[] = [
|
|
150
66
|
{
|
|
@@ -164,7 +80,6 @@ const errorRoutes: RouteRecordRaw[] = [
|
|
|
164
80
|
// ==================== 合并所有路由 ====================
|
|
165
81
|
const routes: RouteRecordRaw[] = [
|
|
166
82
|
layoutRoutes,
|
|
167
|
-
...standaloneRoutes,
|
|
168
83
|
...errorRoutes
|
|
169
84
|
]
|
|
170
85
|
|