You've already forked agentic-coding-workflow
feat(web): notification bell with polling and dropdown
This commit is contained in:
@@ -1,7 +1,73 @@
|
|||||||
<template>
|
<template>
|
||||||
<span class="text-sm text-gray-400">🔔</span>
|
<NPopover
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom-end"
|
||||||
|
@update:show="onOpen"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<NBadge
|
||||||
|
:value="store.unreadCount"
|
||||||
|
:max="99"
|
||||||
|
:show="store.unreadCount > 0"
|
||||||
|
>
|
||||||
|
<NButton text>
|
||||||
|
<span class="text-lg">🔔</span>
|
||||||
|
</NButton>
|
||||||
|
</NBadge>
|
||||||
|
</template>
|
||||||
|
<div class="w-80 max-h-96 overflow-auto">
|
||||||
|
<div
|
||||||
|
v-if="store.items.length === 0"
|
||||||
|
class="p-4 text-gray-500 text-sm"
|
||||||
|
>
|
||||||
|
暂无通知
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="n in store.items"
|
||||||
|
:key="n.id"
|
||||||
|
class="p-3 border-b border-gray-100 dark:border-gray-700 last:border-0"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ n.title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
|
{{ n.body }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="store.unreadCount > 0"
|
||||||
|
class="p-2 border-t border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
@click="onMarkAll"
|
||||||
|
>
|
||||||
|
全部标记为已读
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NPopover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// I6 占位:真实未读轮询 + 弹层在 I6 实现。
|
import { NPopover, NBadge, NButton } from 'naive-ui'
|
||||||
|
import { notificationsApi } from '@/api/notifications'
|
||||||
|
import { useNotificationsStore } from '@/stores/notifications'
|
||||||
|
import { useUnreadPoller } from '@/composables/useNotifications'
|
||||||
|
|
||||||
|
const store = useNotificationsStore()
|
||||||
|
useUnreadPoller()
|
||||||
|
|
||||||
|
async function onOpen(open: boolean) {
|
||||||
|
if (!open) return
|
||||||
|
const { items } = await notificationsApi.list(false, 50)
|
||||||
|
store.setItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMarkAll() {
|
||||||
|
await notificationsApi.markAllRead()
|
||||||
|
store.setUnreadCount(0)
|
||||||
|
store.setItems(store.items.map((i) => ({ ...i, read_at: new Date().toISOString() })))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { notificationsApi } from '@/api/notifications'
|
||||||
|
import { useNotificationsStore } from '@/stores/notifications'
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
export function useUnreadPoller() {
|
||||||
|
const store = useNotificationsStore()
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const { count } = await notificationsApi.unreadCount()
|
||||||
|
store.setUnreadCount(count)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refresh()
|
||||||
|
timer = setInterval(refresh, POLL_INTERVAL_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { refresh, error }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user