@lvetechs/create-app 1.0.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvetechs/create-app",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "快速创建 Vue 3 / React 18 项目的脚手架工具",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,15 @@
1
1
  import { useRoutes } from 'react-router-dom'
2
2
  import { routes } from './router'
3
+ import { ToastProvider } from '@/components/Toast'
3
4
 
4
5
  function App() {
5
6
  const element = useRoutes(routes)
6
- return <>{element}</>
7
+ return (
8
+ <>
9
+ {element}
10
+ <ToastProvider />
11
+ </>
12
+ )
7
13
  }
8
14
 
9
15
  export default App
@@ -0,0 +1,43 @@
1
+ import { http } from '@/utils/request'
2
+ import type { NotificationItem } from '@/stores/notification'
3
+
4
+ /** 获取通知列表参数 */
5
+ export interface GetNotificationsParams {
6
+ page?: number
7
+ pageSize?: number
8
+ type?: NotificationItem['type']
9
+ category?: string
10
+ unreadOnly?: boolean
11
+ }
12
+
13
+ /** 获取通知列表响应 */
14
+ export interface GetNotificationsResult {
15
+ list: NotificationItem[]
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
+ }
@@ -0,0 +1,219 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { useNotificationStore } from '@/stores/notification'
3
+ import { getNotificationsApi, markNotificationReadApi, markAllReadApi } from '@/api/notification'
4
+ import { toast } from '@/components/Toast'
5
+ import type { NotificationItem } from '@/stores/notification'
6
+
7
+ export default function NotificationButton() {
8
+ const [showDropdown, setShowDropdown] = useState(false)
9
+ const dropdownRef = useRef<HTMLDivElement>(null)
10
+ const { notifications, addNotification, markAsRead, markAllAsRead, getUnreadCount } =
11
+ useNotificationStore()
12
+
13
+ const unreadCount = getUnreadCount()
14
+
15
+ // 点击外部关闭下拉菜单
16
+ useEffect(() => {
17
+ function handleClickOutside(event: MouseEvent) {
18
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
19
+ setShowDropdown(false)
20
+ }
21
+ }
22
+
23
+ if (showDropdown) {
24
+ document.addEventListener('mousedown', handleClickOutside)
25
+ return () => document.removeEventListener('mousedown', handleClickOutside)
26
+ }
27
+ }, [showDropdown])
28
+
29
+ // 获取最新消息
30
+ const fetchNotifications = async () => {
31
+ try {
32
+ const response = await getNotificationsApi({ unreadOnly: false, pageSize: 20 })
33
+ if (response.data) {
34
+ // 将新消息添加到 store(实际项目中可能需要去重逻辑)
35
+ response.data.list.forEach((item) => {
36
+ // 检查是否已存在
37
+ const exists = notifications.find((n) => n.id === item.id)
38
+ if (!exists) {
39
+ addNotification({
40
+ title: item.title,
41
+ content: item.content,
42
+ type: item.type,
43
+ category: item.category,
44
+ actionUrl: item.actionUrl
45
+ })
46
+ }
47
+ })
48
+ toast.success(`获取到 ${response.data.list.length} 条消息`)
49
+ }
50
+ } catch (error) {
51
+ console.error('获取消息失败:', error)
52
+ toast.error('获取消息失败')
53
+ }
54
+ }
55
+
56
+ // 标记为已读
57
+ const handleMarkAsRead = async (id: string) => {
58
+ try {
59
+ await markNotificationReadApi(id)
60
+ markAsRead(id)
61
+ toast.success('已标记为已读')
62
+ } catch (error) {
63
+ console.error('标记已读失败:', error)
64
+ toast.error('标记已读失败')
65
+ }
66
+ }
67
+
68
+ // 全部标记为已读
69
+ const handleMarkAllRead = async () => {
70
+ try {
71
+ await markAllReadApi()
72
+ markAllAsRead()
73
+ toast.success('已全部标记为已读')
74
+ } catch (error) {
75
+ console.error('全部标记已读失败:', error)
76
+ toast.error('全部标记已读失败')
77
+ }
78
+ }
79
+
80
+ // 格式化时间
81
+ const formatTime = (timestamp: number) => {
82
+ const now = Date.now()
83
+ const diff = now - timestamp
84
+ const minutes = Math.floor(diff / 60000)
85
+ const hours = Math.floor(diff / 3600000)
86
+ const days = Math.floor(diff / 86400000)
87
+
88
+ if (minutes < 1) return '刚刚'
89
+ if (minutes < 60) return `${minutes}分钟前`
90
+ if (hours < 24) return `${hours}小时前`
91
+ if (days < 7) return `${days}天前`
92
+ return new Date(timestamp).toLocaleDateString()
93
+ }
94
+
95
+ const typeIcons = {
96
+ info: 'ℹ️',
97
+ success: '✓',
98
+ warning: '⚠️',
99
+ error: '✕'
100
+ }
101
+
102
+ const typeColors = {
103
+ info: 'text-blue-500',
104
+ success: 'text-green-500',
105
+ warning: 'text-yellow-500',
106
+ error: 'text-red-500'
107
+ }
108
+
109
+ return (
110
+ <div className="relative" ref={dropdownRef}>
111
+ <button
112
+ className="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"
113
+ onClick={() => {
114
+ setShowDropdown(!showDropdown)
115
+ // if (!showDropdown) {
116
+ // fetchNotifications()
117
+ // }
118
+ }}
119
+ title="消息通知"
120
+ >
121
+ <span className="text-lg">🔔</span>
122
+ {unreadCount > 0 && (
123
+ <span className="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">
124
+ {unreadCount > 99 ? '99+' : unreadCount}
125
+ </span>
126
+ )}
127
+ </button>
128
+
129
+ {showDropdown && (
130
+ <div className="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">
131
+ {/* 头部 */}
132
+ <div className="flex items-center justify-between p-4 border-b border-slate-200">
133
+ <h3 className="font-semibold text-slate-700">消息通知</h3>
134
+ <div className="flex gap-2">
135
+ {/* <button
136
+ className="text-xs text-indigo-600 hover:text-indigo-700"
137
+ onClick={fetchNotifications}
138
+ >
139
+ 刷新
140
+ </button> */}
141
+ {unreadCount > 0 && (
142
+ <button
143
+ className="text-xs text-indigo-600 hover:text-indigo-700"
144
+ onClick={handleMarkAllRead}
145
+ >
146
+ 全部已读
147
+ </button>
148
+ )}
149
+ </div>
150
+ </div>
151
+
152
+ {/* 消息列表 */}
153
+ <div className="flex-1 overflow-y-auto">
154
+ {notifications.length === 0 ? (
155
+ <div className="flex flex-col items-center justify-center py-8 text-slate-400">
156
+ <span className="text-4xl mb-2"></span>
157
+ <p className="text-sm">暂无消息</p>
158
+ </div>
159
+ ) : (
160
+ <div className="divide-y divide-slate-100">
161
+ {notifications.map((item: NotificationItem) => (
162
+ <div
163
+ key={item.id}
164
+ className={`p-3 hover:bg-slate-50 cursor-pointer transition-colors ${
165
+ !item.read ? 'bg-blue-50' : ''
166
+ }`}
167
+ onClick={() => !item.read && handleMarkAsRead(item.id)}
168
+ >
169
+ <div className="flex items-start gap-2">
170
+ <span className={`text-lg ${typeColors[item.type]}`}>
171
+ {typeIcons[item.type]}
172
+ </span>
173
+ <div className="flex-1 min-w-0">
174
+ <div className="flex items-center justify-between mb-1">
175
+ <h4 className="font-medium text-sm text-slate-700 truncate">
176
+ {item.title}
177
+ </h4>
178
+ {!item.read && (
179
+ <span className="flex-shrink-0 w-2 h-2 bg-blue-500 rounded-full ml-2"></span>
180
+ )}
181
+ </div>
182
+ <p className="text-xs text-slate-500 line-clamp-2 mb-1">
183
+ {item.content}
184
+ </p>
185
+ <div className="flex items-center justify-between">
186
+ <span className="text-xs text-slate-400">{formatTime(item.timestamp)}</span>
187
+ {item.category && (
188
+ <span className="text-xs text-slate-400 bg-slate-100 px-2 py-0.5 rounded">
189
+ {item.category}
190
+ </span>
191
+ )}
192
+ </div>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ ))}
197
+ </div>
198
+ )}
199
+ </div>
200
+
201
+ {/* 底部 */}
202
+ {notifications.length > 0 && (
203
+ <div className="p-2 border-t border-slate-200 text-center">
204
+ <button
205
+ className="text-xs text-slate-500 hover:text-indigo-600"
206
+ onClick={() => {
207
+ // 可以跳转到消息中心页面
208
+ console.log('查看全部消息')
209
+ }}
210
+ >
211
+ 查看全部消息
212
+ </button>
213
+ </div>
214
+ )}
215
+ </div>
216
+ )}
217
+ </div>
218
+ )
219
+ }
@@ -0,0 +1,150 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ export type ToastType = 'success' | 'error' | 'warning' | 'info'
5
+
6
+ export interface ToastItem {
7
+ id: string
8
+ message: string
9
+ type: ToastType
10
+ duration?: number
11
+ }
12
+
13
+ interface ToastProps {
14
+ toast: ToastItem
15
+ onClose: (id: string) => void
16
+ }
17
+
18
+ function ToastItem({ toast, onClose }: ToastProps) {
19
+ useEffect(() => {
20
+ if (toast.duration && toast.duration > 0) {
21
+ const timer = setTimeout(() => {
22
+ onClose(toast.id)
23
+ }, toast.duration)
24
+ return () => clearTimeout(timer)
25
+ }
26
+ }, [toast.id, toast.duration, onClose])
27
+
28
+ const icons = {
29
+ success: '✓',
30
+ error: '✕',
31
+ warning: '⚠',
32
+ info: 'ℹ'
33
+ }
34
+
35
+ const colors = {
36
+ success: 'bg-green-500',
37
+ error: 'bg-red-500',
38
+ warning: 'bg-yellow-500',
39
+ info: 'bg-blue-500'
40
+ }
41
+
42
+ return (
43
+ <div
44
+ className={`flex items-center gap-3 rounded-lg ${colors[toast.type]} px-4 py-3 text-white shadow-lg transition-all animate-in slide-in-from-top-5`}
45
+ onClick={() => onClose(toast.id)}
46
+ >
47
+ <span className="text-lg">{icons[toast.type]}</span>
48
+ <span className="flex-1 text-sm">{toast.message}</span>
49
+ <button
50
+ className="ml-2 text-white opacity-70 hover:opacity-100"
51
+ onClick={(e) => {
52
+ e.stopPropagation()
53
+ onClose(toast.id)
54
+ }}
55
+ >
56
+
57
+ </button>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ interface ToastContainerProps {
63
+ toasts: ToastItem[]
64
+ onClose: (id: string) => void
65
+ }
66
+
67
+ function ToastContainer({ toasts, onClose }: ToastContainerProps) {
68
+ if (toasts.length === 0) return null
69
+
70
+ return createPortal(
71
+ <div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2">
72
+ {toasts.map((toast) => (
73
+ <ToastItem key={toast.id} toast={toast} onClose={onClose} />
74
+ ))}
75
+ </div>,
76
+ document.body
77
+ )
78
+ }
79
+
80
+ // Toast 管理器
81
+ class ToastManager {
82
+ private toasts: ToastItem[] = []
83
+ private listeners: Array<(toasts: ToastItem[]) => void> = []
84
+
85
+ private notify() {
86
+ this.listeners.forEach((listener) => listener([...this.toasts]))
87
+ }
88
+
89
+ subscribe(listener: (toasts: ToastItem[]) => void) {
90
+ this.listeners.push(listener)
91
+ return () => {
92
+ this.listeners = this.listeners.filter((l) => l !== listener)
93
+ }
94
+ }
95
+
96
+ show(message: string, type: ToastType = 'info', duration = 3000) {
97
+ const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
98
+ const toast: ToastItem = { id, message, type, duration }
99
+ this.toasts.push(toast)
100
+ this.notify()
101
+ return id
102
+ }
103
+
104
+ close(id: string) {
105
+ this.toasts = this.toasts.filter((t) => t.id !== id)
106
+ this.notify()
107
+ }
108
+
109
+ success(message: string, duration?: number) {
110
+ return this.show(message, 'success', duration)
111
+ }
112
+
113
+ error(message: string, duration?: number) {
114
+ return this.show(message, 'error', duration)
115
+ }
116
+
117
+ warning(message: string, duration?: number) {
118
+ return this.show(message, 'warning', duration)
119
+ }
120
+
121
+ info(message: string, duration?: number) {
122
+ return this.show(message, 'info', duration)
123
+ }
124
+
125
+ getToasts() {
126
+ return [...this.toasts]
127
+ }
128
+ }
129
+
130
+ export const toastManager = new ToastManager()
131
+
132
+ // Toast 容器组件(需要在 App 中引入)
133
+ export function ToastProvider() {
134
+ const [toasts, setToasts] = useState<ToastItem[]>([])
135
+
136
+ useEffect(() => {
137
+ const unsubscribe = toastManager.subscribe(setToasts)
138
+ return unsubscribe
139
+ }, [])
140
+
141
+ return <ToastContainer toasts={toasts} onClose={(id) => toastManager.close(id)} />
142
+ }
143
+
144
+ // 便捷方法
145
+ export const toast = {
146
+ success: (message: string, duration?: number) => toastManager.success(message, duration),
147
+ error: (message: string, duration?: number) => toastManager.error(message, duration),
148
+ warning: (message: string, duration?: number) => toastManager.warning(message, duration),
149
+ info: (message: string, duration?: number) => toastManager.info(message, duration)
150
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, ChangeEvent, FormEvent } from 'react'
1
+ import { useState, useCallback, ChangeEvent } from 'react'
2
2
 
3
3
  export type FormErrors<T> = Partial<Record<keyof T, string>>
4
4
 
@@ -49,7 +49,7 @@ export function useForm<T extends Record<string, any>>({
49
49
  const nextErrors = validate(values)
50
50
  setErrors(nextErrors)
51
51
  const hasError = Object.values(nextErrors).some(Boolean)
52
- if (hasError) return false;
52
+ if (hasError) return
53
53
  }
54
54
 
55
55
  if (!onSubmit) return
@@ -7,6 +7,7 @@ import { Outlet } from 'react-router-dom'
7
7
  import { useAppStore } from '@/stores/app'
8
8
  import SidebarMenu from './components/SidebarMenu'
9
9
  import { menuRoutes } from './menuConfig'
10
+ import NotificationButton from '@/components/NotificationButton'
10
11
 
11
12
  export default function DefaultLayout() {
12
13
  const { sidebarCollapsed, toggleSidebar, theme, setTheme } = useAppStore()
@@ -24,6 +25,7 @@ export default function DefaultLayout() {
24
25
  </button>
25
26
  </div>
26
27
  <div className="header-right">
28
+ <NotificationButton />
27
29
  <button
28
30
  className="theme-btn"
29
31
  onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
@@ -35,6 +35,9 @@ interface NotificationState {
35
35
 
36
36
  // 示例数据
37
37
  initSampleData: () => void
38
+
39
+ // API 相关
40
+ fetchNotifications: () => Promise<void>
38
41
  }
39
42
 
40
43
  export const useNotificationStore = create<NotificationState>()(
@@ -103,18 +106,33 @@ export const useNotificationStore = create<NotificationState>()(
103
106
  type: 'info',
104
107
  category: '系统'
105
108
  })
106
- add({
107
- title: '订单提醒',
108
- content: '您有新的订单待处理',
109
- type: 'warning',
110
- category: '订单'
111
- })
112
- add({
113
- title: '操作成功',
114
- content: '数据保存成功',
115
- type: 'success',
116
- category: '操作'
117
- })
109
+ },
110
+
111
+ fetchNotifications: async () => {
112
+ try {
113
+ // 动态导入避免循环依赖
114
+ const { getNotificationsApi } = await import('@/api/notification')
115
+ const response = await getNotificationsApi({ unreadOnly: false, pageSize: 20 })
116
+ if (response.data) {
117
+ // 合并新消息(实际项目中需要去重逻辑)
118
+ const existingIds = new Set(get().notifications.map((n) => n.id))
119
+ response.data.list.forEach((item) => {
120
+ if (!existingIds.has(item.id)) {
121
+ get().addNotification({
122
+ title: item.title,
123
+ content: item.content,
124
+ type: item.type,
125
+ category: item.category,
126
+ actionUrl: item.actionUrl
127
+ })
128
+ }
129
+ })
130
+ }
131
+ } catch (error) {
132
+ console.error('获取消息失败:', error)
133
+ // 如果 API 失败,使用示例数据
134
+ get().initSampleData()
135
+ }
118
136
  }
119
137
  }),
120
138
  {
@@ -138,16 +138,6 @@ export const usePermissionStore = create<PermissionState>()(
138
138
  getPermissionConfig: (permission) =>
139
139
  get().allPermissions.find((p) => p.permission === permission),
140
140
 
141
- getPermissionsByModule: () => {
142
- const grouped: Record<string, PermissionConfig[]> = {}
143
- get().allPermissions.forEach((permission) => {
144
- const module = permission.module || '其他'
145
- if (!grouped[module]) grouped[module] = []
146
- grouped[module].push(permission)
147
- })
148
- return grouped
149
- },
150
-
151
141
  addPermission: (permission) =>
152
142
  set((state) => {
153
143
  if (state.allPermissions.some((p) => p.permission === permission.permission)) {
@@ -1,8 +1,10 @@
1
1
  <script setup lang="ts">
2
+ import Toast from '@/components/Toast/index.vue'
2
3
  </script>
3
4
 
4
5
  <template>
5
6
  <router-view />
7
+ <Toast />
6
8
  </template>
7
9
 
8
10
  <style scoped>
@@ -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
+ }
@@ -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')"
@@ -130,18 +130,34 @@ export const useNotificationStore = defineStore(
130
130
  type: 'info',
131
131
  category: '系统'
132
132
  })
133
- addNotification({
134
- title: '订单提醒',
135
- content: '您有新的订单待处理',
136
- type: 'warning',
137
- category: '订单'
138
- })
139
- addNotification({
140
- title: '操作成功',
141
- content: '数据保存成功',
142
- type: 'success',
143
- category: '操作'
144
- })
133
+ }
134
+ }
135
+
136
+ // 从 API 获取消息
137
+ async function fetchNotifications() {
138
+ try {
139
+ // 动态导入避免循环依赖
140
+ const { getNotificationsApi } = await import('@/api/notification')
141
+ const response = await getNotificationsApi({ unreadOnly: false, pageSize: 20 })
142
+ if (response.data) {
143
+ // 合并新消息(实际项目中需要去重逻辑)
144
+ const existingIds = new Set(notifications.value.map((n) => n.id))
145
+ response.data.list.forEach((item) => {
146
+ if (!existingIds.has(item.id)) {
147
+ addNotification({
148
+ title: item.title,
149
+ content: item.content,
150
+ type: item.type,
151
+ category: item.category,
152
+ actionUrl: item.actionUrl
153
+ })
154
+ }
155
+ })
156
+ }
157
+ } catch (error) {
158
+ console.error('获取消息失败:', error)
159
+ // 如果 API 失败,使用示例数据
160
+ initSampleData()
145
161
  }
146
162
  }
147
163
 
@@ -160,7 +176,8 @@ export const useNotificationStore = defineStore(
160
176
  getUnreadNotifications,
161
177
  getNotificationsByType,
162
178
  getNotificationsByCategory,
163
- initSampleData
179
+ initSampleData,
180
+ fetchNotifications
164
181
  }
165
182
  },
166
183
  {