mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-02 04:51:30 +08:00
Fix notification read and clear state
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { SystemNotification } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { clearUnreadMessages } from '@/utils/badge'
|
||||
import { appUnreadMessageCount, clearUnreadMessages } from '@/utils/badge'
|
||||
import { emitAgentAssistantNotificationBubble } from '@/utils/agentAssistantBubble'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
@@ -13,7 +13,6 @@ type NotificationDisplayItem =
|
||||
| { kind: 'section'; key: string; title: string; count: number }
|
||||
| { kind: 'notification'; key: string; notification: SystemNotification }
|
||||
type NotificationClearScope = 'all' | 'system' | 'media'
|
||||
type NotificationClearBefore = Record<NotificationClearScope, number>
|
||||
|
||||
const { t } = useI18n()
|
||||
const { useDelayedSSE } = useBackground()
|
||||
@@ -23,9 +22,6 @@ const createConfirm = useConfirm()
|
||||
const PAGE_SIZE = 20
|
||||
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量。
|
||||
const NOTIFICATION_ITEM_HEIGHT = 136
|
||||
const MAX_FILTERED_PAGES_PER_LOAD = 5
|
||||
const CLEAR_NOTIFICATION_ENDPOINTS = ['message/notification', 'message/notification/clear']
|
||||
const NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY = 'moviepilot-notification-clear-before'
|
||||
|
||||
const appsMenu = ref(false)
|
||||
const hasNewMessage = ref(false)
|
||||
@@ -35,10 +31,12 @@ const loading = ref(false)
|
||||
const clearing = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const notificationKeys = new Set<string>()
|
||||
const notificationClearBefore = ref(readNotificationClearBefore())
|
||||
const expandedNotificationKeys = ref(new Set<string>())
|
||||
|
||||
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
|
||||
const hasBadgeUnreadMessages = computed(() => appUnreadMessageCount.value > 0)
|
||||
const canMarkAllAsRead = computed(() => hasUnreadNotifications.value || hasBadgeUnreadMessages.value)
|
||||
const hasUnreadMessageIndicator = computed(() => hasNewMessage.value || canMarkAllAsRead.value)
|
||||
const notificationDisplayList = computed(() => buildNotificationDisplayList(notificationList.value))
|
||||
const notificationClearCounts = computed(() => getNotificationClearCounts())
|
||||
const notificationClearOptions = computed(() => [
|
||||
@@ -65,60 +63,6 @@ const notificationClearOptions = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
/** 生成默认清理时间,分别记录全部、系统消息和媒体消息的清理范围。 */
|
||||
function createDefaultNotificationClearBefore(): NotificationClearBefore {
|
||||
return {
|
||||
all: 0,
|
||||
system: 0,
|
||||
media: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** 规范化清理时间,兼容旧版本只存一个数字的本地存储格式。 */
|
||||
function normalizeNotificationClearBefore(value: unknown): NotificationClearBefore {
|
||||
const clearBefore = createDefaultNotificationClearBefore()
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
clearBefore.all = value
|
||||
return clearBefore
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') return clearBefore
|
||||
|
||||
const scopes: NotificationClearScope[] = ['all', 'system', 'media']
|
||||
scopes.forEach(scope => {
|
||||
const scopeValue = Number((value as Partial<NotificationClearBefore>)[scope] || 0)
|
||||
clearBefore[scope] = Number.isFinite(scopeValue) ? scopeValue : 0
|
||||
})
|
||||
|
||||
return clearBefore
|
||||
}
|
||||
|
||||
/** 从本地存储读取通知清理时间戳,用于过滤已清理的历史通知。 */
|
||||
function readNotificationClearBefore() {
|
||||
if (typeof localStorage === 'undefined') return createDefaultNotificationClearBefore()
|
||||
|
||||
const storedValue = localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY)
|
||||
if (!storedValue) return createDefaultNotificationClearBefore()
|
||||
|
||||
try {
|
||||
return normalizeNotificationClearBefore(JSON.parse(storedValue))
|
||||
} catch {
|
||||
return normalizeNotificationClearBefore(Number(storedValue))
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入指定范围的通知清理时间戳,使清理结果在刷新后仍然生效。 */
|
||||
function writeNotificationClearBefore(scope: NotificationClearScope, value: number) {
|
||||
notificationClearBefore.value = {
|
||||
...notificationClearBefore.value,
|
||||
[scope]: value,
|
||||
}
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, JSON.stringify(notificationClearBefore.value))
|
||||
}
|
||||
|
||||
/** 将通知备注统一转换成稳定字符串,用于生成去重 key。 */
|
||||
function normalizeNote(note: SystemNotification['note']) {
|
||||
if (note == null) return ''
|
||||
@@ -185,16 +129,6 @@ function parseNotificationTime(value: string) {
|
||||
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
|
||||
}
|
||||
|
||||
/** 判断历史通知是否早于本地清理时间,需要从列表中过滤。 */
|
||||
function isClearedHistoryNotification(item: SystemNotification) {
|
||||
const scope = getNotificationClearScope(item)
|
||||
const clearBefore = Math.max(notificationClearBefore.value.all, notificationClearBefore.value[scope])
|
||||
if (!clearBefore) return false
|
||||
|
||||
const notificationTime = parseNotificationTime(getNotificationTime(item))
|
||||
return notificationTime > 0 && notificationTime <= clearBefore
|
||||
}
|
||||
|
||||
/** 按通知时间倒序重排当前列表。 */
|
||||
function sortNotifications() {
|
||||
notificationList.value = [...notificationList.value].sort(
|
||||
@@ -284,8 +218,8 @@ function rebuildExpandedNotificationKeys() {
|
||||
|
||||
/** 列表内容变化后同步未读红点和应用角标状态。 */
|
||||
function syncUnreadStateAfterListChange() {
|
||||
hasNewMessage.value = hasUnreadNotifications.value
|
||||
if (!hasUnreadNotifications.value) void clearUnreadMessages()
|
||||
// 只用当前列表更新通知中心红点,应用 badge 数量由 badge 工具维护。
|
||||
hasNewMessage.value = canMarkAllAsRead.value
|
||||
}
|
||||
|
||||
/** 统计当前已加载通知中各清理范围的数量,用于菜单展示和禁用空操作。 */
|
||||
@@ -333,33 +267,12 @@ function getClearSuccessText(scope: NotificationClearScope) {
|
||||
return t('notification.clearAllSuccess')
|
||||
}
|
||||
|
||||
/** 通过后端接口清理全部通知历史,兼容新旧后端可能暴露的清理路径。 */
|
||||
async function deleteNotificationHistory() {
|
||||
let lastError: unknown = null
|
||||
|
||||
for (const endpoint of CLEAR_NOTIFICATION_ENDPOINTS) {
|
||||
try {
|
||||
return await api.delete(endpoint)
|
||||
} catch (error: any) {
|
||||
lastError = error
|
||||
if (error?.response?.status !== 404 && error?.response?.status !== 405) break
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/** 尝试调用后端清理接口;分类清理使用本地时间戳过滤以兼容当前全量接口语义。 */
|
||||
/** 调用后端记录清理范围,后续分页查询会直接返回过滤后的通知。 */
|
||||
async function tryDeleteNotificationHistory(scope: NotificationClearScope) {
|
||||
if (scope !== 'all') return true
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await deleteNotificationHistory()
|
||||
return result?.success !== false
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404 || error?.response?.status === 405) return true
|
||||
throw error
|
||||
}
|
||||
const result: { [key: string]: any } = await api.delete('message/notification', {
|
||||
params: { scope },
|
||||
})
|
||||
return result?.success !== false
|
||||
}
|
||||
|
||||
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
|
||||
@@ -382,7 +295,6 @@ async function clearNotifications(scope: NotificationClearScope) {
|
||||
return
|
||||
}
|
||||
|
||||
writeNotificationClearBefore(scope, Date.now())
|
||||
removeNotificationsByScope(scope)
|
||||
if (scope === 'all') {
|
||||
await clearUnreadMessages()
|
||||
@@ -410,30 +322,24 @@ async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'er
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
let accepted = false
|
||||
let loadedPages = 0
|
||||
|
||||
// 清理后的分页里可能连续出现已被本地过滤的历史消息,循环跳过这些空页。
|
||||
while (hasMore.value && !accepted && loadedPages < MAX_FILTERED_PAGES_PER_LOAD) {
|
||||
const items = (await api.get('message/notification', {
|
||||
params: {
|
||||
page: page.value,
|
||||
count: PAGE_SIZE,
|
||||
},
|
||||
})) as SystemNotification[]
|
||||
const items = (await api.get('message/notification', {
|
||||
params: {
|
||||
page: page.value,
|
||||
count: PAGE_SIZE,
|
||||
},
|
||||
})) as SystemNotification[]
|
||||
|
||||
if (items.length === 0) {
|
||||
hasMore.value = false
|
||||
break
|
||||
}
|
||||
|
||||
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
|
||||
accepted = mergeNotifications(visibleItems, { read: true })
|
||||
page.value += 1
|
||||
loadedPages += 1
|
||||
hasMore.value = items.length >= PAGE_SIZE
|
||||
if (items.length === 0) {
|
||||
hasMore.value = false
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
mergeNotifications(items, { read: true })
|
||||
page.value += 1
|
||||
hasMore.value = items.length >= PAGE_SIZE
|
||||
|
||||
done(hasMore.value ? 'ok' : 'empty')
|
||||
} catch (error) {
|
||||
console.error('加载通知失败:', error)
|
||||
@@ -449,7 +355,6 @@ function handleMessage(event: MessageEvent) {
|
||||
|
||||
try {
|
||||
const notification = JSON.parse(event.data) as SystemNotification
|
||||
if (isClearedHistoryNotification(notification)) return
|
||||
|
||||
if (mergeNotifications([notification], { prepend: true, read: false })) {
|
||||
hasNewMessage.value = true
|
||||
@@ -462,6 +367,8 @@ function handleMessage(event: MessageEvent) {
|
||||
|
||||
/** 将通知列表标记为已读,并同步清理应用角标、未读红点和通知弹窗。 */
|
||||
function markAllAsRead() {
|
||||
if (!canMarkAllAsRead.value) return
|
||||
|
||||
hasNewMessage.value = false
|
||||
notificationList.value.forEach(item => {
|
||||
item.read = true
|
||||
@@ -536,11 +443,10 @@ function isNotificationExpanded(item: SystemNotification) {
|
||||
return expandedNotificationKeys.value.has(getNotificationExpansionKey(item))
|
||||
}
|
||||
|
||||
/** 标记单条通知为已读,并在全部已读时同步清理未读角标。 */
|
||||
/** 标记单条通知为已读,仅同步当前通知中心列表的未读状态。 */
|
||||
function markNotificationAsRead(item: SystemNotification) {
|
||||
item.read = true
|
||||
hasNewMessage.value = hasUnreadNotifications.value
|
||||
if (!hasUnreadNotifications.value) void clearUnreadMessages()
|
||||
}
|
||||
|
||||
/** 切换通知正文展开状态。 */
|
||||
@@ -579,7 +485,7 @@ useDelayedSSE(
|
||||
scrim
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
<VBadge v-if="hasUnreadMessageIndicator" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-bell-outline" size="22" />
|
||||
</IconBtn>
|
||||
@@ -628,7 +534,7 @@ useDelayedSSE(
|
||||
</VTooltip>
|
||||
<VTooltip :text="t('notification.markRead')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" :disabled="!hasUnreadNotifications" @click.stop="markAllAsRead">
|
||||
<IconBtn v-bind="props" :disabled="!canMarkAllAsRead" @click.stop="markAllAsRead">
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
|
||||
@@ -283,6 +283,17 @@ async function setStoredUnreadCount(count: number): Promise<void> {
|
||||
await set(UNREAD_COUNT_KEY, count)
|
||||
}
|
||||
|
||||
// 通知已打开的页面同步未读计数,保证前台通知中心能感知 PWA badge 的变化。
|
||||
async function broadcastUnreadCount(count: number) {
|
||||
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'UNREAD_COUNT_UPDATE',
|
||||
count,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function updateBadge(count: number) {
|
||||
if ('setAppBadge' in self.navigator) {
|
||||
try {
|
||||
@@ -309,6 +320,7 @@ async function clearBadge() {
|
||||
|
||||
try {
|
||||
await setStoredUnreadCount(0)
|
||||
await broadcastUnreadCount(0)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear unread count:', error)
|
||||
}
|
||||
@@ -422,7 +434,11 @@ self.addEventListener('push', function (event) {
|
||||
const currentCount = await getStoredUnreadCount()
|
||||
const newCount = currentCount + 1
|
||||
await setStoredUnreadCount(newCount)
|
||||
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
|
||||
await Promise.all([
|
||||
self.registration.showNotification(payload.title, content),
|
||||
updateBadge(newCount),
|
||||
broadcastUnreadCount(newCount),
|
||||
])
|
||||
})(),
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -454,6 +470,7 @@ self.addEventListener('message', function (event) {
|
||||
const count = event.data.count || 0
|
||||
setStoredUnreadCount(count)
|
||||
.then(() => updateBadge(count))
|
||||
.then(() => broadcastUnreadCount(count))
|
||||
.then(() => {
|
||||
event.ports[0]?.postMessage({ success: true })
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* PWA 徽章管理工具
|
||||
*/
|
||||
@@ -7,9 +9,27 @@ interface UnreadMessageEvent extends CustomEvent {
|
||||
detail: { count: number }
|
||||
}
|
||||
|
||||
const unreadMessageCount = ref(0)
|
||||
|
||||
// 暴露只读未读计数,供通知中心等组件直接判断应用角标状态。
|
||||
export const appUnreadMessageCount = readonly(unreadMessageCount)
|
||||
|
||||
function normalizeUnreadMessageCount(count: unknown) {
|
||||
const normalizedCount = Number(count)
|
||||
if (!Number.isFinite(normalizedCount) || normalizedCount <= 0) return 0
|
||||
|
||||
return Math.floor(normalizedCount)
|
||||
}
|
||||
|
||||
function setUnreadMessageCount(count: unknown) {
|
||||
unreadMessageCount.value = normalizeUnreadMessageCount(count)
|
||||
return unreadMessageCount.value
|
||||
}
|
||||
|
||||
// 发送全局未读消息事件
|
||||
export function emitUnreadMessageEvent(count: number) {
|
||||
const event = new CustomEvent('unreadMessage', { detail: { count } }) as UnreadMessageEvent
|
||||
const normalizedCount = setUnreadMessageCount(count)
|
||||
const event = new CustomEvent('unreadMessage', { detail: { count: normalizedCount } }) as UnreadMessageEvent
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
@@ -88,9 +108,8 @@ export async function checkUnreadOnStartup(): Promise<number> {
|
||||
export async function checkAndEmitUnreadMessages() {
|
||||
try {
|
||||
const count = await checkUnreadOnStartup()
|
||||
if (count > 0) {
|
||||
emitUnreadMessageEvent(count)
|
||||
}
|
||||
// 启动时同步 0 值,避免组件复用上一轮角标状态。
|
||||
emitUnreadMessageEvent(count)
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
@@ -139,11 +158,13 @@ export async function clearAppBadge(): Promise<boolean> {
|
||||
|
||||
// 更新桌面图标徽章数量
|
||||
export async function updateAppBadge(count: number): Promise<boolean> {
|
||||
const normalizedCount = normalizeUnreadMessageCount(count)
|
||||
|
||||
try {
|
||||
// 如果浏览器支持原生Badge API,直接调用
|
||||
if ('setAppBadge' in navigator) {
|
||||
if (count > 0) {
|
||||
await navigator.setAppBadge(count)
|
||||
if (normalizedCount > 0) {
|
||||
await navigator.setAppBadge(normalizedCount)
|
||||
} else {
|
||||
await navigator.clearAppBadge()
|
||||
}
|
||||
@@ -155,13 +176,18 @@ export async function updateAppBadge(count: number): Promise<boolean> {
|
||||
|
||||
return new Promise(resolve => {
|
||||
messageChannel.port1.onmessage = event => {
|
||||
resolve(event.data.success)
|
||||
const success = Boolean(event.data.success)
|
||||
if (success) emitUnreadMessageEvent(normalizedCount)
|
||||
resolve(success)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count }, [messageChannel.port2])
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count: normalizedCount }, [
|
||||
messageChannel.port2,
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
emitUnreadMessageEvent(normalizedCount)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to update app badge:', error)
|
||||
@@ -195,3 +221,11 @@ export async function getUnreadCount(): Promise<number> {
|
||||
export function supportsBadgeAPI(): boolean {
|
||||
return 'setAppBadge' in navigator && 'clearAppBadge' in navigator
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', event => {
|
||||
if (event.data?.type === 'UNREAD_COUNT_UPDATE') {
|
||||
emitUnreadMessageEvent(event.data.count || 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user