@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
package/package.json
CHANGED
package/templates/react/.env
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
# 基础环境变量
|
|
2
|
-
VITE_APP_TITLE=My React App
|
|
3
|
-
VITE_APP_BASE_API=/api
|
|
1
|
+
# 基础环境变量
|
|
2
|
+
VITE_APP_TITLE=My React App
|
|
3
|
+
VITE_APP_BASE_API=/api
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
# 开发环境
|
|
2
|
-
VITE_APP_TITLE=My React App (Dev)
|
|
3
|
-
VITE_APP_BASE_API=/api
|
|
1
|
+
# 开发环境
|
|
2
|
+
VITE_APP_TITLE=My React App (Dev)
|
|
3
|
+
VITE_APP_BASE_API=/api
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
# 生产环境
|
|
2
|
-
VITE_APP_TITLE=My React App
|
|
3
|
-
VITE_APP_BASE_API=https://api.example.com
|
|
1
|
+
# 生产环境
|
|
2
|
+
VITE_APP_TITLE=My React App
|
|
3
|
+
VITE_APP_BASE_API=https://api.example.com
|
|
@@ -34,8 +34,8 @@ importers:
|
|
|
34
34
|
version: 4.5.7(@types/react@18.3.28)(react@18.3.1)
|
|
35
35
|
devDependencies:
|
|
36
36
|
'@lvetechs/ui-lib':
|
|
37
|
-
specifier: ^1.1.
|
|
38
|
-
version: 1.1.
|
|
37
|
+
specifier: ^1.1.7
|
|
38
|
+
version: 1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vue@3.5.28(typescript@5.9.3))
|
|
39
39
|
'@types/node':
|
|
40
40
|
specifier: ^25.2.3
|
|
41
41
|
version: 25.2.3
|
|
@@ -371,8 +371,8 @@ packages:
|
|
|
371
371
|
'@jridgewell/trace-mapping@0.3.31':
|
|
372
372
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
|
373
373
|
|
|
374
|
-
'@lvetechs/ui-lib@1.1.
|
|
375
|
-
resolution: {integrity: sha512-
|
|
374
|
+
'@lvetechs/ui-lib@1.1.7':
|
|
375
|
+
resolution: {integrity: sha512-BH3QmLcLxK9A7C5mAs/pLovO9ZJ2MBlxjJhdMCElxMn935OQ0TXhrVmHI39asST5xAmTNeSyDMXLVuS3eNuu6w==}
|
|
376
376
|
peerDependencies:
|
|
377
377
|
react: ^18.0.0
|
|
378
378
|
react-dom: ^18.0.0
|
|
@@ -2005,7 +2005,7 @@ snapshots:
|
|
|
2005
2005
|
'@jridgewell/resolve-uri': 3.1.2
|
|
2006
2006
|
'@jridgewell/sourcemap-codec': 1.5.5
|
|
2007
2007
|
|
|
2008
|
-
'@lvetechs/ui-lib@1.1.
|
|
2008
|
+
'@lvetechs/ui-lib@1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vue@3.5.28(typescript@5.9.3))':
|
|
2009
2009
|
dependencies:
|
|
2010
2010
|
lucide-react: 0.563.0(react@18.3.1)
|
|
2011
2011
|
react: 18.3.1
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState, useCallback, ChangeEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
export type FormErrors<T> = Partial<Record<keyof T, string>>
|
|
4
|
+
|
|
5
|
+
export interface UseFormOptions<T> {
|
|
6
|
+
/** 初始表单值 */
|
|
7
|
+
initialValues: T
|
|
8
|
+
/** 校验函数(可选) */
|
|
9
|
+
validate?: (values: T) => FormErrors<T>
|
|
10
|
+
/** 提交成功回调(可选) */
|
|
11
|
+
onSubmit?: (values: T) => void | Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 通用表单 Hook 示例
|
|
16
|
+
*
|
|
17
|
+
* - 管理表单值 values
|
|
18
|
+
* - 简单错误提示 errors
|
|
19
|
+
* - 提供 handleChange / handleSubmit / reset
|
|
20
|
+
*/
|
|
21
|
+
export function useForm<T extends Record<string, any>>({
|
|
22
|
+
initialValues,
|
|
23
|
+
validate,
|
|
24
|
+
onSubmit
|
|
25
|
+
}: UseFormOptions<T>) {
|
|
26
|
+
const [values, setValues] = useState<T>(initialValues)
|
|
27
|
+
const [errors, setErrors] = useState<FormErrors<T>>({})
|
|
28
|
+
const [submitting, setSubmitting] = useState(false)
|
|
29
|
+
|
|
30
|
+
const handleChange = useCallback(
|
|
31
|
+
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
32
|
+
const { name, value } = e.target
|
|
33
|
+
setValues((prev) => ({
|
|
34
|
+
...prev,
|
|
35
|
+
[name]: value
|
|
36
|
+
}))
|
|
37
|
+
},
|
|
38
|
+
[]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const reset = useCallback(() => {
|
|
42
|
+
setValues(initialValues)
|
|
43
|
+
setErrors({})
|
|
44
|
+
}, [initialValues])
|
|
45
|
+
|
|
46
|
+
const handleSubmit = useCallback(
|
|
47
|
+
async () => {
|
|
48
|
+
if (validate) {
|
|
49
|
+
const nextErrors = validate(values)
|
|
50
|
+
setErrors(nextErrors)
|
|
51
|
+
const hasError = Object.values(nextErrors).some(Boolean)
|
|
52
|
+
if (hasError) return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!onSubmit) return
|
|
56
|
+
try {
|
|
57
|
+
setSubmitting(true)
|
|
58
|
+
await onSubmit(values)
|
|
59
|
+
} finally {
|
|
60
|
+
setSubmitting(false)
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[onSubmit, validate, values]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
values,
|
|
68
|
+
errors,
|
|
69
|
+
submitting,
|
|
70
|
+
handleChange,
|
|
71
|
+
handleSubmit,
|
|
72
|
+
reset,
|
|
73
|
+
setValues,
|
|
74
|
+
setErrors
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
@@ -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')}
|
|
@@ -24,49 +24,19 @@ export const menuRoutes: MenuItem[] = [
|
|
|
24
24
|
{
|
|
25
25
|
path: '/home',
|
|
26
26
|
title: '首页',
|
|
27
|
-
icon: '
|
|
27
|
+
icon: ''
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
path: '/system',
|
|
31
31
|
title: '系统管理',
|
|
32
|
-
icon: '
|
|
32
|
+
icon: '',
|
|
33
33
|
children: [
|
|
34
34
|
{
|
|
35
35
|
path: '/system/user',
|
|
36
36
|
title: '用户管理',
|
|
37
|
-
icon: '
|
|
37
|
+
icon: ''
|
|
38
38
|
},
|
|
39
|
-
{
|
|
40
|
-
path: '/system/role',
|
|
41
|
-
title: '角色管理',
|
|
42
|
-
icon: '🔑'
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
path: '/system/menu',
|
|
46
|
-
title: '菜单管理',
|
|
47
|
-
icon: '📋'
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
path: '/system/log',
|
|
51
|
-
title: '日志管理',
|
|
52
|
-
icon: '📝',
|
|
53
|
-
children: [
|
|
54
|
-
{
|
|
55
|
-
path: '/system/log/operation',
|
|
56
|
-
title: '操作日志'
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
path: '/system/log/login',
|
|
60
|
-
title: '登录日志'
|
|
61
|
-
}
|
|
62
|
-
]
|
|
63
|
-
}
|
|
64
39
|
]
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
path: '/about',
|
|
68
|
-
title: '关于',
|
|
69
|
-
icon: 'ℹ️'
|
|
70
40
|
}
|
|
71
41
|
]
|
|
72
42
|
|