Skip to content

Commit b90663d

Browse files
committed
feat(notifications): implement notification system
1 parent 1675b72 commit b90663d

43 files changed

Lines changed: 3693 additions & 383 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/components/common/AppBar/AppBar.vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,13 @@ const menuOpen = ref(false)
215215
const notificationMenuOpen = ref(false)
216216
const unreadNotificationsCount = ref(0)
217217
218+
watch(
219+
() => router.currentRoute.value.fullPath,
220+
() => {
221+
notificationMenuOpen.value = false
222+
}
223+
)
224+
218225
watch(menuOpen, (newValue) => {
219226
if (newValue) {
220227
fetchAIQuota()
@@ -255,10 +262,10 @@ const fetchAIQuota = async () => {
255262
256263
// 获取未读通知数量
257264
const fetchUnreadNotificationsCount = async () => {
258-
if (!loggedIn.value || !currentUser.value?.id) return
265+
if (!loggedIn.value) return
259266
260267
try {
261-
const response = await NotificationsApi.getUnreadCount(currentUser.value.id)
268+
const response = await NotificationsApi.getUnreadCount()
262269
unreadNotificationsCount.value = response.data.count
263270
} catch (error) {
264271
console.error('获取未读通知数量失败:', error)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<template>
2+
<v-avatar :size="size" :color="avatarColor" :variant="variant">
3+
<!-- 如果有主要实体并且有头像,显示实体头像 -->
4+
<v-img v-if="primaryEntityWithAvatar" :src="primaryEntityWithAvatar.avatarUrl" />
5+
<!-- 否则显示图标 -->
6+
<v-icon v-else :icon="icon" :size="iconSize" :color="iconColor"></v-icon>
7+
</v-avatar>
8+
</template>
9+
10+
<script setup lang="ts">
11+
import type { Notification } from '@/network/api/notifications/types'
12+
import type { EntityInfo } from '@/network/api/notifications/types'
13+
14+
import { computed } from 'vue'
15+
16+
import { getNotificationColor, getNotificationIcon } from '@/services/notification/registry'
17+
18+
const props = defineProps<{
19+
notification: Notification
20+
size?: number | string
21+
color?: string
22+
}>()
23+
24+
// 默认头像大小
25+
const size = computed(() => props.size || 36)
26+
27+
// 图标大小
28+
const iconSize = computed(() => {
29+
const avatarSize = typeof props.size === 'number' ? props.size : parseInt(props.size as string, 10)
30+
return isNaN(avatarSize) ? 18 : Math.max(Math.floor(avatarSize / 2), 16)
31+
})
32+
33+
// 获取通知图标
34+
const icon = computed(() => getNotificationIcon(props.notification.type))
35+
36+
// 获取通知颜色
37+
const color = computed(() => props.color || getNotificationColor(props.notification.type))
38+
39+
// 获取主要实体(优先级:发送者 > 其他人类实体)
40+
const primaryEntityWithAvatar = computed<EntityInfo | null>(() => {
41+
const { entities } = props.notification
42+
const possibleRoles = [
43+
'sender',
44+
'mentioner',
45+
'replier',
46+
'reactor',
47+
'inviter',
48+
'requester',
49+
'approver',
50+
'rejector',
51+
'accepter',
52+
'decliner',
53+
'canceler',
54+
]
55+
56+
// 遍历可能的角色,找到第一个有头像的实体
57+
for (const role of possibleRoles) {
58+
const entity = entities[role]
59+
if (entity && entity.avatarUrl) {
60+
return entity
61+
}
62+
}
63+
64+
// 如果没有发送者或发送者没有头像,查找其他实体
65+
for (const [role, entity] of Object.entries(entities)) {
66+
if (entity && entity.avatarUrl && entity.type === 'user') {
67+
return entity
68+
}
69+
}
70+
71+
return null
72+
})
73+
74+
// 头像颜色
75+
const avatarColor = computed(() => {
76+
return primaryEntityWithAvatar.value ? undefined : color.value
77+
})
78+
79+
// 头像变体
80+
const variant = computed(() => {
81+
return primaryEntityWithAvatar.value ? undefined : 'tonal'
82+
})
83+
84+
// 图标颜色
85+
const iconColor = computed(() => {
86+
return color.value
87+
})
88+
</script>
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
<template>
2+
<v-list-item
3+
v-if="hasRouterLink"
4+
:active="!notification.read"
5+
:class="{ 'unread-notification': !notification.read }"
6+
class="notification-item py-2 px-4 transition-fast-in-fast-out"
7+
@click="navigateToTarget"
8+
>
9+
<div class="d-flex align-start w-100">
10+
<notification-avatar :notification="notification" class="me-3 mt-1" />
11+
12+
<div class="flex-grow-1 d-flex flex-column">
13+
<div class="d-flex flex-row justify-space-between align-center">
14+
<component
15+
:is="contentComponent"
16+
ref="contentRef"
17+
:notification="notification"
18+
@update-notification="onUpdateNotification"
19+
/>
20+
<span class="text-caption text-medium-emphasis ms-2">{{ formattedTime }}</span>
21+
</div>
22+
23+
<div class="d-flex justify-end align-center mt-2">
24+
<template v-if="renderedActions && renderedActions.length > 0">
25+
<v-btn
26+
v-for="(action, index) in renderedActions"
27+
:key="index"
28+
variant="text"
29+
density="comfortable"
30+
size="small"
31+
:color="action.color || 'primary'"
32+
class="px-2 ms-2"
33+
@click.stop="action.handler"
34+
>
35+
{{ action.text }}
36+
</v-btn>
37+
</template>
38+
<template v-else>
39+
<v-btn
40+
v-if="!notification.read"
41+
variant="text"
42+
density="comfortable"
43+
size="small"
44+
:color="notificationColor"
45+
class="px-2"
46+
@click.stop="markAsRead"
47+
>
48+
{{ t('notifications.common.markAsRead') }}
49+
</v-btn>
50+
<v-btn
51+
variant="text"
52+
density="comfortable"
53+
size="small"
54+
color="error"
55+
class="px-2 ms-2"
56+
@click.stop="deleteNotification"
57+
>
58+
{{ t('notifications.common.delete') }}
59+
</v-btn>
60+
</template>
61+
</div>
62+
</div>
63+
</div>
64+
</v-list-item>
65+
66+
<v-list-item
67+
v-else
68+
:active="!notification.read"
69+
:class="{ 'unread-notification': !notification.read }"
70+
class="notification-item py-2 px-4 transition-fast-in-fast-out"
71+
>
72+
<div class="d-flex align-start w-100">
73+
<notification-avatar :notification="notification" class="me-3 mt-1" />
74+
75+
<div class="flex-grow-1 d-flex flex-column">
76+
<div class="d-flex flex-row justify-space-between align-center">
77+
<component
78+
:is="contentComponent"
79+
ref="contentRef"
80+
:notification="notification"
81+
@update-notification="onUpdateNotification"
82+
/>
83+
<span class="text-caption text-medium-emphasis ms-2">{{ formattedTime }}</span>
84+
</div>
85+
86+
<div class="d-flex justify-end align-center mt-2">
87+
<template v-if="renderedActions && renderedActions.length > 0">
88+
<v-btn
89+
v-for="(action, index) in renderedActions"
90+
:key="index"
91+
variant="text"
92+
density="comfortable"
93+
size="small"
94+
:color="action.color || 'primary'"
95+
class="px-2 ms-2"
96+
@click.stop="action.handler"
97+
>
98+
{{ action.text }}
99+
</v-btn>
100+
</template>
101+
<template v-else>
102+
<v-btn
103+
v-if="!notification.read"
104+
variant="text"
105+
density="comfortable"
106+
size="small"
107+
:color="notificationColor"
108+
class="px-2"
109+
@click.stop="markAsRead"
110+
>
111+
{{ t('notifications.common.markAsRead') }}
112+
</v-btn>
113+
<v-btn
114+
variant="text"
115+
density="comfortable"
116+
size="small"
117+
color="error"
118+
class="px-2 ms-2"
119+
@click.stop="deleteNotification"
120+
>
121+
{{ t('notifications.common.delete') }}
122+
</v-btn>
123+
</template>
124+
</div>
125+
</div>
126+
</div>
127+
</v-list-item>
128+
</template>
129+
130+
<script setup lang="ts">
131+
import type { Component } from 'vue'
132+
import type { Notification } from '@/network/api/notifications/types'
133+
import type { RenderedNotificationContent } from './renders/NotificationRenderUtils'
134+
135+
import { computed, markRaw, onMounted, onUpdated, ref, shallowRef } from 'vue'
136+
import { useI18n } from 'vue-i18n'
137+
import { useRouter } from 'vue-router'
138+
import { toast } from 'vuetify-sonner'
139+
140+
import { useFormattedTime } from '@/utils/dateTime'
141+
142+
import NotificationAvatar from './NotificationAvatar.vue'
143+
144+
import { getNotificationColor, getNotificationRenderer } from '@/services/notification/registry'
145+
146+
const props = defineProps<{
147+
notification: Notification
148+
onMarkAsRead: (notificationId: number) => void
149+
onDelete: (notificationId: number) => void
150+
}>()
151+
152+
const { t } = useI18n()
153+
const { formatTime } = useFormattedTime()
154+
155+
const formattedTime = computed(() => formatTime(props.notification.createdAt))
156+
157+
const notificationColor = computed(() => getNotificationColor(props.notification.type))
158+
159+
const contentComponent = computed<Component>(() => {
160+
return getNotificationRenderer(props.notification.type)
161+
})
162+
163+
const contentRef = ref<{ content: RenderedNotificationContent } | null>(null)
164+
165+
const contentCache = shallowRef<RenderedNotificationContent | undefined>(undefined)
166+
const updateContentCache = () => {
167+
if (contentRef.value?.content) {
168+
contentCache.value = markRaw({ ...contentRef.value.content })
169+
}
170+
}
171+
172+
const renderedActions = computed(() => contentCache.value?.actions || [])
173+
174+
const hasRouterLink = computed(() => {
175+
return !!contentCache.value?.routerLink
176+
})
177+
178+
const routerLinkTarget = computed(() => {
179+
const link = contentCache.value?.routerLink
180+
if (!link) return { name: 'home' }
181+
182+
return {
183+
name: String(link.name),
184+
params: link.params ? { ...link.params } : {},
185+
query: link.query ? { ...link.query } : {},
186+
}
187+
})
188+
189+
let updateQueued = false
190+
const queueContentUpdate = () => {
191+
if (!updateQueued) {
192+
updateQueued = true
193+
setTimeout(() => {
194+
updateContentCache()
195+
updateQueued = false
196+
}, 0)
197+
}
198+
}
199+
200+
const checkContentUpdate = () => {
201+
if (contentRef.value?.content) {
202+
queueContentUpdate()
203+
}
204+
}
205+
206+
onMounted(checkContentUpdate)
207+
onUpdated(checkContentUpdate)
208+
209+
const markAsRead = (event: Event) => {
210+
event.stopPropagation()
211+
props.onMarkAsRead(props.notification.id)
212+
}
213+
214+
const deleteNotification = (event: Event) => {
215+
event.stopPropagation()
216+
props.onDelete(props.notification.id)
217+
}
218+
219+
const router = useRouter()
220+
221+
const navigateToTarget = () => {
222+
if (hasRouterLink.value) {
223+
props.onMarkAsRead(props.notification.id)
224+
router.push(routerLinkTarget.value).catch((error) => {
225+
if (error.name !== 'NavigationDuplicated') {
226+
console.error('导航错误:', error)
227+
toast.error('无法访问该页面,您可能没有权限或页面不存在')
228+
}
229+
})
230+
}
231+
}
232+
233+
const onUpdateNotification = (notificationId: number) => {
234+
props.onMarkAsRead(notificationId)
235+
}
236+
</script>
237+
238+
<style scoped>
239+
.notification-item {
240+
border-left: 3px solid transparent;
241+
transition: background-color 0.2s ease;
242+
}
243+
244+
.notification-item:hover {
245+
background-color: rgba(var(--v-theme-primary), 0.04);
246+
}
247+
248+
.unread-notification {
249+
background-color: rgba(var(--v-theme-primary), 0.04);
250+
border-left: 3px solid var(--v-theme-primary);
251+
}
252+
</style>

0 commit comments

Comments
 (0)