@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 +1 -1
- 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 +2 -2
- package/templates/react/src/layouts/DefaultLayout.tsx +2 -0
- package/templates/react/src/stores/notification.ts +30 -12
- package/templates/react/src/stores/permission.ts +0 -10
- package/templates/vue/src/App.vue +2 -0
- package/templates/vue/src/api/notification.ts +43 -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/stores/notification.ts +30 -13
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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)) {
|
|
@@ -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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
{
|