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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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