mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-27 11:10:12 +08:00
refactor: remove ShortcutMessageDialog and MessageView components; update ShortcutBar and UserNotification for improved notification handling
This commit is contained in:
@@ -1131,6 +1131,12 @@ export interface MediaServerLibrary {
|
|||||||
|
|
||||||
// 消息通知
|
// 消息通知
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
// 消息ID
|
||||||
|
id?: number
|
||||||
|
// 消息渠道
|
||||||
|
channel?: string
|
||||||
|
// 消息来源
|
||||||
|
source?: string
|
||||||
// 消息类型
|
// 消息类型
|
||||||
mtype?: string
|
mtype?: string
|
||||||
// 消息标题
|
// 消息标题
|
||||||
@@ -1150,19 +1156,15 @@ export interface Message {
|
|||||||
// 消息方向:0-接收,1-发送
|
// 消息方向:0-接收,1-发送
|
||||||
action?: number
|
action?: number
|
||||||
// JSON
|
// JSON
|
||||||
note?: string
|
note?: string | any[] | Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统通知
|
// 系统通知
|
||||||
export interface SystemNotification {
|
export interface SystemNotification extends Message {
|
||||||
// 通知类型 user/system/plugin
|
// 通知类型 user/system/plugin/notification
|
||||||
type: string
|
type?: string
|
||||||
// 通知标题
|
|
||||||
title: string
|
|
||||||
// 通知内容
|
|
||||||
text: string
|
|
||||||
// 通知时间
|
// 通知时间
|
||||||
date: string
|
date?: string
|
||||||
// 是否已读
|
// 是否已读
|
||||||
read?: boolean
|
read?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import MarkdownIt from 'markdown-it'
|
|
||||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
|
||||||
import type { Message } from '@/api/types'
|
|
||||||
import { formatDateDifference } from '@core/utils/formatters'
|
|
||||||
|
|
||||||
// 输入参数
|
|
||||||
const props = defineProps({
|
|
||||||
message: Object as PropType<Message>,
|
|
||||||
width: String,
|
|
||||||
height: String,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 定义事件
|
|
||||||
const emit = defineEmits(['imageload'])
|
|
||||||
|
|
||||||
// 图片是否加载完成
|
|
||||||
const isImageLoaded = ref(false)
|
|
||||||
|
|
||||||
// 图片是否加载失败
|
|
||||||
const imageLoadError = ref(false)
|
|
||||||
|
|
||||||
// 初始化 markdown-it
|
|
||||||
const md = new MarkdownIt({
|
|
||||||
html: true,
|
|
||||||
breaks: true,
|
|
||||||
linkify: true,
|
|
||||||
typographer: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 插件:链接在新窗口打开
|
|
||||||
md.use(mdLinkAttributes, {
|
|
||||||
attrs: {
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener noreferrer',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图片加载完成
|
|
||||||
async function imageLoaded() {
|
|
||||||
isImageLoaded.value = true
|
|
||||||
emit('imageload')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 链接打开新窗口
|
|
||||||
function openLink() {
|
|
||||||
if (props.message?.link) window.open(props.message.link, '_blank')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将note转换为json
|
|
||||||
function noteToJson() {
|
|
||||||
if (props.message?.note) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(props.message.note)
|
|
||||||
} catch (error) {
|
|
||||||
return props.message.note
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染 Markdown
|
|
||||||
function renderMarkdown(value: string) {
|
|
||||||
if (!value) return ''
|
|
||||||
return md.render(value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
|
|
||||||
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
|
|
||||||
<VImg
|
|
||||||
:src="props.message?.image"
|
|
||||||
aspect-ratio="3/2"
|
|
||||||
cover
|
|
||||||
position="top"
|
|
||||||
@load="imageLoaded"
|
|
||||||
@error="imageLoadError = true"
|
|
||||||
min-height="10rem"
|
|
||||||
>
|
|
||||||
<template #placeholder>
|
|
||||||
<div class="w-full h-full">
|
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VImg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
props.message?.title &&
|
|
||||||
!props.message?.text &&
|
|
||||||
!props.message?.image &&
|
|
||||||
isNullOrEmptyObject(props.message?.note) &&
|
|
||||||
props.message?.action === 0
|
|
||||||
"
|
|
||||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
|
||||||
>
|
|
||||||
<p class="mb-0">{{ props.message?.title }}</p>
|
|
||||||
</div>
|
|
||||||
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
|
|
||||||
{{ props.message?.title }}
|
|
||||||
</VCardTitle>
|
|
||||||
<div
|
|
||||||
v-if="props.message?.text && props.message?.action === 0"
|
|
||||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
|
||||||
>
|
|
||||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
|
||||||
</div>
|
|
||||||
<VCardText
|
|
||||||
v-if="props.message?.text && props.message?.action === 1"
|
|
||||||
class="markdown-body"
|
|
||||||
v-html="renderMarkdown(props.message?.text)"
|
|
||||||
/>
|
|
||||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
|
||||||
<VList>
|
|
||||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
|
||||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
|
||||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
|
||||||
</VListItemTitle>
|
|
||||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
|
||||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
|
||||||
</VListItemTitle>
|
|
||||||
<VListItemSubtitle v-if="value.type">
|
|
||||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
|
||||||
</VListItemSubtitle>
|
|
||||||
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
|
|
||||||
{{ value.description }}
|
|
||||||
</VListItemSubtitle>
|
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
<div class="text-end">
|
|
||||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
|
||||||
<span class="text-sm italic me-2">{{
|
|
||||||
formatDateDifference(props.message?.reg_time || props.message?.date || '')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.markdown-body {
|
|
||||||
word-break: break-all;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p:last-child {
|
|
||||||
margin-block-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style-type: disc;
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
padding-inline-start: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol {
|
|
||||||
list-style-type: decimal;
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
padding-inline-start: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: list-item;
|
|
||||||
margin-block-end: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: rgba(var(--v-border-color), 0.1);
|
|
||||||
font-family: monospace;
|
|
||||||
padding-block: 0.2rem;
|
|
||||||
padding-inline: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: rgba(var(--v-border-color), 0.1);
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
|
||||||
font-style: italic;
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
padding-inline-start: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
inline-size: 100%;
|
|
||||||
margin-block-end: 1rem;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: rgba(var(--v-border-color), 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
block-size: auto;
|
|
||||||
max-inline-size: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import api from '@/api'
|
|
||||||
import { clearUnreadMessages } from '@/utils/badge'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
|
|
||||||
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
|
|
||||||
|
|
||||||
type MessageViewExpose = {
|
|
||||||
pauseSSE?: () => void
|
|
||||||
resumeSSE?: () => void
|
|
||||||
refreshLatestMessages?: () => Promise<void> | void
|
|
||||||
forceScrollToEnd?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// 国际化
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// 显示器宽度
|
|
||||||
const display = useDisplay()
|
|
||||||
|
|
||||||
// 输入参数
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
|
||||||
const emit = defineEmits(['update:modelValue', 'close'])
|
|
||||||
|
|
||||||
// 弹窗显示状态
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: value => {
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
if (!value) emit('close')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 输入消息
|
|
||||||
const user_message = ref('')
|
|
||||||
|
|
||||||
// 发送按钮是否可用
|
|
||||||
const sendButtonDisabled = ref(false)
|
|
||||||
|
|
||||||
// 消息视图引用
|
|
||||||
const messageViewRef = ref<MessageViewExpose | null>(null)
|
|
||||||
|
|
||||||
/** 发送 Web 消息。 */
|
|
||||||
async function sendMessage() {
|
|
||||||
const messageText = user_message.value.trim()
|
|
||||||
if (!messageText) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sendButtonDisabled.value = true
|
|
||||||
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
|
|
||||||
user_message.value = ''
|
|
||||||
messageViewRef.value?.forceScrollToEnd?.()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
sendButtonDisabled.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除未读消息计数和桌面角标。 */
|
|
||||||
function clearUnreadMessageState() {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
void clearUnreadMessages()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(visible, async newValue => {
|
|
||||||
if (newValue) {
|
|
||||||
await nextTick()
|
|
||||||
messageViewRef.value?.resumeSSE?.()
|
|
||||||
|
|
||||||
clearUnreadMessageState()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
messageViewRef.value?.pauseSSE?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
clearUnreadMessageState()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
messageViewRef.value?.pauseSSE?.()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
|
||||||
<VCard>
|
|
||||||
<VCardItem>
|
|
||||||
<VCardTitle>
|
|
||||||
<VIcon icon="mdi-message" class="me-2" />
|
|
||||||
{{ t('shortcut.message.subtitle') }}
|
|
||||||
</VCardTitle>
|
|
||||||
<VDialogCloseBtn v-model="visible" />
|
|
||||||
</VCardItem>
|
|
||||||
<VDivider />
|
|
||||||
<VCardText>
|
|
||||||
<MessageView ref="messageViewRef" />
|
|
||||||
</VCardText>
|
|
||||||
<VDivider />
|
|
||||||
<VCardActions class="pa-4">
|
|
||||||
<div class="d-flex w-100 gap-2">
|
|
||||||
<VTextField
|
|
||||||
v-model="user_message"
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
density="compact"
|
|
||||||
:placeholder="t('common.inputMessage')"
|
|
||||||
@keyup.enter="sendMessage"
|
|
||||||
/>
|
|
||||||
<VBtn
|
|
||||||
variant="elevated"
|
|
||||||
:disabled="sendButtonDisabled"
|
|
||||||
@click="sendMessage"
|
|
||||||
:loading="sendButtonDisabled"
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-send"
|
|
||||||
>{{ t('common.send') }}
|
|
||||||
</VBtn>
|
|
||||||
</div>
|
|
||||||
</VCardActions>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
</template>
|
|
||||||
@@ -5,7 +5,6 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
||||||
import { clearUnreadMessages, getUnreadCount, onUnreadMessage } from '@/utils/badge'
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -21,7 +20,6 @@ const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vu
|
|||||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||||
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
||||||
const ShortcutMessageDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutMessageDialog.vue'))
|
|
||||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||||
|
|
||||||
type ShortcutItem = PermissionProtectedItem & {
|
type ShortcutItem = PermissionProtectedItem & {
|
||||||
@@ -44,12 +42,6 @@ const appsMenu = ref(false)
|
|||||||
// 菜单最大宽度
|
// 菜单最大宽度
|
||||||
const menuMaxWidth = ref(420)
|
const menuMaxWidth = ref(420)
|
||||||
|
|
||||||
// 未读消息数量,用于控制消息捷径卡片上的红点。
|
|
||||||
const unreadMessageCount = ref(0)
|
|
||||||
const hasUnreadMessages = computed(() => unreadMessageCount.value > 0)
|
|
||||||
let unreadStateRevision = 0
|
|
||||||
let stopUnreadMessageListener: (() => void) | null = null
|
|
||||||
|
|
||||||
// 定义捷径列表
|
// 定义捷径列表
|
||||||
const shortcuts: ShortcutItem[] = [
|
const shortcuts: ShortcutItem[] = [
|
||||||
{
|
{
|
||||||
@@ -123,55 +115,16 @@ const shortcuts: ShortcutItem[] = [
|
|||||||
component: ModuleTestView,
|
component: ModuleTestView,
|
||||||
titleText: t('shortcut.system.subtitle'),
|
titleText: t('shortcut.system.subtitle'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('shortcut.message.title'),
|
|
||||||
subtitle: t('shortcut.message.subtitle'),
|
|
||||||
icon: 'mdi-message',
|
|
||||||
dialog: 'message',
|
|
||||||
customDialog: ShortcutMessageDialog,
|
|
||||||
},
|
|
||||||
].map(item => ({ ...item, permission: 'admin' }))
|
].map(item => ({ ...item, permission: 'admin' }))
|
||||||
|
|
||||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||||
|
|
||||||
/** 设置消息捷径卡片的未读数量。 */
|
|
||||||
function setUnreadMessageCount(count: number) {
|
|
||||||
unreadMessageCount.value = Math.max(0, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同步全局未读消息数量到消息捷径卡片。 */
|
|
||||||
function handleUnreadMessage(count: number) {
|
|
||||||
unreadStateRevision += 1
|
|
||||||
setUnreadMessageCount(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从 Service Worker 读取当前未读数量,避免错过启动早期事件。 */
|
|
||||||
async function syncUnreadMessageStateFromBadge() {
|
|
||||||
const revision = unreadStateRevision
|
|
||||||
const count = await getUnreadCount()
|
|
||||||
|
|
||||||
if (revision === unreadStateRevision) {
|
|
||||||
setUnreadMessageCount(count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清空未读消息数量和 PWA 桌面角标。 */
|
|
||||||
function clearUnreadMessageState() {
|
|
||||||
unreadStateRevision += 1
|
|
||||||
setUnreadMessageCount(0)
|
|
||||||
void clearUnreadMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开快捷工具对应的共享弹窗。 */
|
/** 打开快捷工具对应的共享弹窗。 */
|
||||||
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||||
if (!hasItemPermission(item, userPermissions.value)) return
|
if (!hasItemPermission(item, userPermissions.value)) return
|
||||||
|
|
||||||
appsMenu.value = false
|
appsMenu.value = false
|
||||||
|
|
||||||
if (item.dialog === 'message') {
|
|
||||||
clearUnreadMessageState()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.customDialog) {
|
if (item.customDialog) {
|
||||||
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||||
return
|
return
|
||||||
@@ -195,21 +148,7 @@ function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 供外部调用的打开消息弹窗方法。 */
|
|
||||||
function openMessageDialogFromExternal() {
|
|
||||||
const messageShortcut = visibleShortcuts.value.find(item => item.dialog === 'message')
|
|
||||||
if (messageShortcut) openShortcutDialog(messageShortcut)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
openMessageDialog: openMessageDialogFromExternal,
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
stopUnreadMessageListener = onUnreadMessage(handleUnreadMessage)
|
|
||||||
void syncUnreadMessageStateFromBadge()
|
|
||||||
|
|
||||||
const shortcut = getQueryValue('shortcut')
|
const shortcut = getQueryValue('shortcut')
|
||||||
if (shortcut) {
|
if (shortcut) {
|
||||||
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
|
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
|
||||||
@@ -218,10 +157,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stopUnreadMessageListener?.()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -257,30 +192,20 @@ onBeforeUnmount(() => {
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<!-- 循环渲染快捷方式 -->
|
<!-- 循环渲染快捷方式 -->
|
||||||
<div v-for="(item, index) in visibleShortcuts" :key="index">
|
<div v-for="(item, index) in visibleShortcuts" :key="index">
|
||||||
<VBadge
|
<VCard
|
||||||
:model-value="item.dialog === 'message' && hasUnreadMessages"
|
flat
|
||||||
dot
|
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
|
||||||
color="error"
|
hover
|
||||||
location="top end"
|
@click="openShortcutDialog(item)"
|
||||||
offset-x="8"
|
|
||||||
offset-y="8"
|
|
||||||
class="d-block h-full w-100"
|
|
||||||
>
|
>
|
||||||
<VCard
|
<VAvatar variant="text" size="48" rounded="lg">
|
||||||
flat
|
<VIcon color="primary" :icon="item.icon" size="24" />
|
||||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
|
</VAvatar>
|
||||||
hover
|
<div>
|
||||||
@click="openShortcutDialog(item)"
|
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
|
||||||
>
|
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
|
||||||
<VAvatar variant="text" size="48" rounded="lg">
|
</div>
|
||||||
<VIcon color="primary" :icon="item.icon" size="24" />
|
</VCard>
|
||||||
</VAvatar>
|
|
||||||
<div>
|
|
||||||
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
|
|
||||||
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</VBadge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,68 +1,250 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { SystemNotification } from '@/api/types'
|
||||||
|
import api from '@/api'
|
||||||
|
import { clearUnreadMessages } from '@/utils/badge'
|
||||||
import { formatDateDifference } from '@core/utils/formatters'
|
import { formatDateDifference } from '@core/utils/formatters'
|
||||||
import { SystemNotification } from '@/api/types'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useBackground } from '@/composables/useBackground'
|
import { useBackground } from '@/composables/useBackground'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { useDelayedSSE } = useBackground()
|
const { useDelayedSSE } = useBackground()
|
||||||
|
|
||||||
// 是否有新消息
|
const PAGE_SIZE = 20
|
||||||
const hasNewMessage = ref(false)
|
// 固定通知项高度,配合 VVirtualScroll 避免历史通知过多时一次性渲染全部 DOM。
|
||||||
|
const NOTIFICATION_ITEM_HEIGHT = 104
|
||||||
|
const MEDIA_NOTIFICATION_TYPES = ['资源下载', '整理入库', '订阅', '媒体服务器', '手动处理']
|
||||||
|
|
||||||
// 通知列表
|
|
||||||
const notificationList = ref<SystemNotification[]>([])
|
|
||||||
const MAX_NOTIFICATIONS = 100
|
|
||||||
|
|
||||||
// 弹窗
|
|
||||||
const appsMenu = ref(false)
|
const appsMenu = ref(false)
|
||||||
|
const hasNewMessage = ref(false)
|
||||||
|
const notificationList = ref<SystemNotification[]>([])
|
||||||
|
const page = ref(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadedOnce = ref(false)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const notificationKeys = new Set<string>()
|
||||||
|
|
||||||
// 标记所有消息为已读
|
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
|
||||||
function markAllAsRead() {
|
|
||||||
hasNewMessage.value = false
|
function normalizeNote(note: SystemNotification['note']) {
|
||||||
// 标记所有消息为已读
|
if (note == null) return ''
|
||||||
notificationList.value.forEach(item => {
|
if (typeof note === 'string') return note
|
||||||
item.read = true
|
if (typeof note === 'object' && !Array.isArray(note) && Object.keys(note).length === 0) return ''
|
||||||
})
|
return JSON.stringify(note)
|
||||||
appsMenu.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息处理函数
|
function getNotificationTime(item: SystemNotification) {
|
||||||
function handleMessage(event: MessageEvent) {
|
return item.reg_time || item.date || ''
|
||||||
if (event.data) {
|
}
|
||||||
const noti: SystemNotification = JSON.parse(event.data)
|
|
||||||
notificationList.value.unshift(noti)
|
function normalizeText(value: unknown) {
|
||||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
return String(value ?? '').replace(/\s+/g, ' ').trim()
|
||||||
notificationList.value.length = MAX_NOTIFICATIONS
|
}
|
||||||
}
|
|
||||||
hasNewMessage.value = true
|
function getNotificationKind(item: SystemNotification) {
|
||||||
|
if (item.type === 'plugin' || item.mtype === '插件') return 'plugin'
|
||||||
|
if (item.type === 'system' || item.mtype === '其它') return 'system'
|
||||||
|
return item.mtype || item.type || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationTimeBucket(item: SystemNotification) {
|
||||||
|
return getNotificationTime(item).slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationContentKey(item: SystemNotification) {
|
||||||
|
return [
|
||||||
|
getNotificationKind(item),
|
||||||
|
getNotificationTimeBucket(item),
|
||||||
|
normalizeText(item.title),
|
||||||
|
normalizeText(item.text),
|
||||||
|
item.image ?? '',
|
||||||
|
item.link ?? '',
|
||||||
|
normalizeNote(item.note),
|
||||||
|
].join('::')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationKeys(item: SystemNotification) {
|
||||||
|
return [item.id ? `id:${item.id}` : '', `content:${getNotificationContentKey(item)}`].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationKey(item: SystemNotification) {
|
||||||
|
return item.id ? `id:${item.id}` : `content:${getNotificationContentKey(item)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNotificationTime(value: string) {
|
||||||
|
if (!value) return 0
|
||||||
|
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortNotifications() {
|
||||||
|
notificationList.value = [...notificationList.value].sort(
|
||||||
|
(a, b) => parseNotificationTime(getNotificationTime(b)) - parseNotificationTime(getNotificationTime(a)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactNotifications(items: SystemNotification[]) {
|
||||||
|
const contentKeys = new Set<string>()
|
||||||
|
const idKeys = new Set<string>()
|
||||||
|
const compactedItems: SystemNotification[] = []
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const contentKey = getNotificationContentKey(item)
|
||||||
|
const idKey = item.id ? `id:${item.id}` : ''
|
||||||
|
|
||||||
|
if (contentKeys.has(contentKey) || (idKey && idKeys.has(idKey))) return
|
||||||
|
|
||||||
|
contentKeys.add(contentKey)
|
||||||
|
if (idKey) idKeys.add(idKey)
|
||||||
|
compactedItems.push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return compactedItems
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotification(item: SystemNotification, read = true): SystemNotification {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
read,
|
||||||
|
title: item.title || item.source || item.mtype || t('notification.center'),
|
||||||
|
type: item.type || (item.action === 1 ? 'notification' : item.type),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟3秒启动SSE连接,避免认证信息尚未准备好。
|
function mergeNotifications(items: SystemNotification[], options: { prepend?: boolean; read?: boolean } = {}) {
|
||||||
|
const normalizedItems = items.map(item => normalizeNotification(item, options.read ?? true))
|
||||||
|
const acceptedItems: SystemNotification[] = []
|
||||||
|
|
||||||
|
normalizedItems.forEach(item => {
|
||||||
|
const keys = getNotificationKeys(item)
|
||||||
|
if (keys.some(key => notificationKeys.has(key))) return
|
||||||
|
|
||||||
|
keys.forEach(key => notificationKeys.add(key))
|
||||||
|
acceptedItems.push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (acceptedItems.length === 0) return false
|
||||||
|
|
||||||
|
notificationList.value = options.prepend
|
||||||
|
? [...acceptedItems, ...notificationList.value]
|
||||||
|
: [...notificationList.value, ...acceptedItems]
|
||||||
|
notificationList.value = compactNotifications(notificationList.value)
|
||||||
|
sortNotifications()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
|
||||||
|
if (loading.value) {
|
||||||
|
done('ok')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore.value) {
|
||||||
|
done('empty')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const items = (await api.get('message/notification', {
|
||||||
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
count: PAGE_SIZE,
|
||||||
|
},
|
||||||
|
})) as SystemNotification[]
|
||||||
|
|
||||||
|
loadedOnce.value = true
|
||||||
|
|
||||||
|
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)
|
||||||
|
done('error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(event: MessageEvent) {
|
||||||
|
if (!event.data) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notification = JSON.parse(event.data) as SystemNotification
|
||||||
|
if (mergeNotifications([notification], { prepend: true, read: false })) {
|
||||||
|
hasNewMessage.value = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析通知失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllAsRead() {
|
||||||
|
hasNewMessage.value = false
|
||||||
|
notificationList.value.forEach(item => {
|
||||||
|
item.read = true
|
||||||
|
})
|
||||||
|
void clearUnreadMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationIcon(item: SystemNotification) {
|
||||||
|
if (getNotificationKind(item) === 'plugin') return 'mdi-puzzle-outline'
|
||||||
|
if (item.mtype === '资源下载') return 'mdi-download'
|
||||||
|
if (item.mtype === '整理入库') return 'mdi-folder-check-outline'
|
||||||
|
if (item.mtype === '订阅') return 'mdi-rss'
|
||||||
|
if (item.mtype === '智能体') return 'lucide:bot'
|
||||||
|
return getNotificationKind(item) === 'system' ? 'mdi-alert-circle-outline' : 'mdi-bell-outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNotificationColor(item: SystemNotification) {
|
||||||
|
if (getNotificationKind(item) === 'system') return 'error'
|
||||||
|
if (getNotificationKind(item) === 'plugin') return 'warning'
|
||||||
|
if (item.mtype === '资源下载') return 'info'
|
||||||
|
if (item.mtype === '整理入库') return 'success'
|
||||||
|
if (item.mtype === '订阅') return 'primary'
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMediaNotification(item: SystemNotification) {
|
||||||
|
return Boolean(item.image) || MEDIA_NOTIFICATION_TYPES.includes(item.mtype || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNotification(item: SystemNotification) {
|
||||||
|
item.read = true
|
||||||
|
hasNewMessage.value = hasUnreadNotifications.value
|
||||||
|
if (!hasUnreadNotifications.value) void clearUnreadMessages()
|
||||||
|
if (item.link) window.open(item.link, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
useDelayedSSE(
|
useDelayedSSE(
|
||||||
`${import.meta.env.VITE_API_BASE_URL}system/message`,
|
`${import.meta.env.VITE_API_BASE_URL}system/message?role=notification`,
|
||||||
handleMessage,
|
handleMessage,
|
||||||
'user-notification',
|
'user-notification',
|
||||||
3000,
|
3000,
|
||||||
{
|
{
|
||||||
backgroundCloseDelay: 5000,
|
backgroundCloseDelay: 5000,
|
||||||
reconnectDelay: 3000,
|
reconnectDelay: 3000,
|
||||||
maxReconnectAttempts: 3
|
maxReconnectAttempts: 3,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VMenu
|
<VMenu
|
||||||
v-model="appsMenu"
|
v-model="appsMenu"
|
||||||
width="400"
|
width="420"
|
||||||
|
max-width="calc(100vw - 24px)"
|
||||||
transition="scale-transition"
|
transition="scale-transition"
|
||||||
close-on-content-click
|
close-on-content-click
|
||||||
class="notification-menu"
|
class="notification-menu"
|
||||||
scrim
|
scrim
|
||||||
>
|
>
|
||||||
<!-- Menu Activator -->
|
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||||
<IconBtn>
|
<IconBtn>
|
||||||
@@ -73,14 +255,14 @@ useDelayedSSE(
|
|||||||
<VIcon icon="mdi-bell-outline" />
|
<VIcon icon="mdi-bell-outline" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
<!-- Menu Content -->
|
|
||||||
<VCard>
|
<VCard class="notification-panel">
|
||||||
<VCardItem class="py-3">
|
<VCardItem class="py-3">
|
||||||
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
|
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
<VTooltip :text="t('notification.markRead')">
|
<VTooltip :text="t('notification.markRead')">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props" @click="markAllAsRead">
|
<IconBtn v-bind="props" :disabled="!hasUnreadNotifications && !hasNewMessage" @click.stop="markAllAsRead">
|
||||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
@@ -88,42 +270,228 @@ useDelayedSSE(
|
|||||||
</template>
|
</template>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
|
||||||
<div class="notification-list-container">
|
<div class="notification-list-container">
|
||||||
<div v-if="notificationList.length > 0">
|
<VInfiniteScroll
|
||||||
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
|
mode="intersect"
|
||||||
<template #prepend>
|
side="end"
|
||||||
<VAvatar rounded>
|
:items="notificationList"
|
||||||
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
|
class="notification-list-scroll"
|
||||||
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
|
@load="loadNotifications"
|
||||||
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
|
>
|
||||||
</VAvatar>
|
<template #loading>
|
||||||
</template>
|
<div class="py-3 text-center text-caption text-medium-emphasis">
|
||||||
<div>
|
{{ t('message.loadMore') }}
|
||||||
<div class="text-body-1 text-high-emphasis break-words whitespace-break-spaces">
|
|
||||||
{{ item.title }}
|
|
||||||
</div>
|
|
||||||
<div class="text-caption mt-1.5">
|
|
||||||
{{ item.text }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-primary mt-1.5">
|
|
||||||
{{ formatDateDifference(item.date) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</VListItem>
|
</template>
|
||||||
</div>
|
<template #empty>
|
||||||
<div v-else class="py-8 text-center">
|
<div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
|
||||||
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
|
{{ t('message.noMoreData') }}
|
||||||
<div>{{ t('notification.empty') }}</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
|
<VVirtualScroll
|
||||||
|
v-if="notificationList.length > 0"
|
||||||
|
renderless
|
||||||
|
:items="notificationList"
|
||||||
|
:item-height="NOTIFICATION_ITEM_HEIGHT"
|
||||||
|
>
|
||||||
|
<template #default="{ item, itemRef }">
|
||||||
|
<div :ref="itemRef" :key="getNotificationKey(item)" class="notification-virtual-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="notification-row"
|
||||||
|
:class="{
|
||||||
|
'notification-row--unread': item.read === false,
|
||||||
|
'notification-row--media': isMediaNotification(item),
|
||||||
|
}"
|
||||||
|
@click="openNotification(item)"
|
||||||
|
>
|
||||||
|
<div v-if="isMediaNotification(item)" class="notification-media">
|
||||||
|
<VImg v-if="item.image" :src="item.image" cover class="notification-media__image">
|
||||||
|
<template #placeholder>
|
||||||
|
<VSkeletonLoader class="h-100 w-100" />
|
||||||
|
</template>
|
||||||
|
</VImg>
|
||||||
|
<div v-else class="notification-media__fallback">
|
||||||
|
<VIcon :icon="getNotificationIcon(item)" size="24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="notification-icon" :class="`text-${getNotificationColor(item)}`">
|
||||||
|
<VIcon :icon="getNotificationIcon(item)" size="22" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notification-content">
|
||||||
|
<div class="notification-title-row">
|
||||||
|
<span class="notification-title">{{ item.title }}</span>
|
||||||
|
<span v-if="item.read === false" class="notification-unread-dot" />
|
||||||
|
</div>
|
||||||
|
<div v-if="item.text" class="notification-text">
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
|
<div class="notification-meta">
|
||||||
|
<span v-if="item.mtype" class="notification-type">{{ item.mtype }}</span>
|
||||||
|
<span>{{ formatDateDifference(getNotificationTime(item)) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VVirtualScroll>
|
||||||
|
|
||||||
|
<div v-if="notificationList.length === 0 && loadedOnce && !loading" class="notification-empty">
|
||||||
|
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
|
||||||
|
<div>{{ t('notification.empty') }}</div>
|
||||||
|
</div>
|
||||||
|
</VInfiniteScroll>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.notification-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.notification-list-container {
|
.notification-list-container {
|
||||||
max-block-size: 50vh;
|
max-block-size: min(560px, 62vh);
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-list-scroll {
|
||||||
|
max-block-size: min(560px, 62vh);
|
||||||
|
min-block-size: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-virtual-item {
|
||||||
|
block-size: 104px;
|
||||||
|
padding-block: 4px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: start;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row:hover {
|
||||||
|
background: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row--unread {
|
||||||
|
background: rgba(var(--v-theme-error), 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row--media {
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-media {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 56px;
|
||||||
|
block-size: 76px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-media__image,
|
||||||
|
.notification-media__fallback {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-media__fallback,
|
||||||
|
.notification-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
flex: 0 0 40px;
|
||||||
|
block-size: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
min-inline-size: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-block-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-unread-dot {
|
||||||
|
flex: 0 0 7px;
|
||||||
|
inline-size: 7px;
|
||||||
|
block-size: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(var(--v-theme-error));
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-block-start: 4px;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-block-start: 6px;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-type {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(var(--v-theme-primary), 0.1);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
padding-block: 2px;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-empty {
|
||||||
|
padding: 32px 16px;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -680,10 +680,6 @@ export default {
|
|||||||
title: 'System',
|
title: 'System',
|
||||||
subtitle: 'Health Check',
|
subtitle: 'Health Check',
|
||||||
},
|
},
|
||||||
message: {
|
|
||||||
title: 'Messages',
|
|
||||||
subtitle: 'Message Center',
|
|
||||||
},
|
|
||||||
words: {
|
words: {
|
||||||
title: 'Words',
|
title: 'Words',
|
||||||
subtitle: 'Word Settings',
|
subtitle: 'Word Settings',
|
||||||
|
|||||||
@@ -676,10 +676,6 @@ export default {
|
|||||||
title: '系统',
|
title: '系统',
|
||||||
subtitle: '健康检查',
|
subtitle: '健康检查',
|
||||||
},
|
},
|
||||||
message: {
|
|
||||||
title: '消息',
|
|
||||||
subtitle: '消息中心',
|
|
||||||
},
|
|
||||||
words: {
|
words: {
|
||||||
title: '词表',
|
title: '词表',
|
||||||
subtitle: '词表设置',
|
subtitle: '词表设置',
|
||||||
|
|||||||
@@ -676,10 +676,6 @@ export default {
|
|||||||
title: '系統',
|
title: '系統',
|
||||||
subtitle: '健康檢查',
|
subtitle: '健康檢查',
|
||||||
},
|
},
|
||||||
message: {
|
|
||||||
title: '消息',
|
|
||||||
subtitle: '消息中心',
|
|
||||||
},
|
|
||||||
words: {
|
words: {
|
||||||
title: '詞表',
|
title: '詞表',
|
||||||
subtitle: '詞表設置',
|
subtitle: '詞表設置',
|
||||||
|
|||||||
@@ -1,426 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import type { Message } from '@/api/types'
|
|
||||||
import MessageCard from '@/components/cards/MessageCard.vue'
|
|
||||||
import api from '@/api'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useBackground } from '@/composables/useBackground'
|
|
||||||
|
|
||||||
// 国际化
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { useSSE } = useBackground()
|
|
||||||
|
|
||||||
// 消息列表
|
|
||||||
const messages = ref<Message[]>([])
|
|
||||||
// 当前页数据
|
|
||||||
const currData = ref<Message[]>([])
|
|
||||||
|
|
||||||
// 已加载消息的签名集合
|
|
||||||
// SSE 消息与数据库消息的字段来源不同(date vs reg_time, null vs {}),签名已归一化处理。
|
|
||||||
const messageKeys = new Set<string>()
|
|
||||||
|
|
||||||
// 是否完成加载
|
|
||||||
const isLoaded = ref(false)
|
|
||||||
|
|
||||||
// 是否加载中
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 当前页码
|
|
||||||
const page = ref(1)
|
|
||||||
|
|
||||||
// 存量消息最新时间
|
|
||||||
const lastTime = ref('')
|
|
||||||
|
|
||||||
// 消息列表滚动容器
|
|
||||||
const messageListRef = ref<any>(null)
|
|
||||||
|
|
||||||
// 自动滚动状态
|
|
||||||
const shouldAutoScroll = ref(true)
|
|
||||||
const isSyncingScroll = ref(false)
|
|
||||||
|
|
||||||
const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
|
|
||||||
|
|
||||||
let scrollTimer: number | undefined
|
|
||||||
let scrollReleaseTimer: number | undefined
|
|
||||||
let boundScrollContainer: HTMLElement | null = null
|
|
||||||
|
|
||||||
// 生成消息去重签名
|
|
||||||
// SSE 消息只有 date 没有 reg_time,数据库消息只有 reg_time 没有 date;
|
|
||||||
// note 在 SSE 侧为 null,数据库侧为 {},需要归一化。
|
|
||||||
function normalizeNote(note: Message['note']): string {
|
|
||||||
if (note == null) return ''
|
|
||||||
if (typeof note === 'string') return note
|
|
||||||
if (typeof note === 'object' && !Array.isArray(note) && Object.keys(note).length === 0) return ''
|
|
||||||
return JSON.stringify(note)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageKey(message: Message) {
|
|
||||||
return [
|
|
||||||
message.action ?? '',
|
|
||||||
message.userid ?? '',
|
|
||||||
message.reg_time || message.date || '',
|
|
||||||
message.title ?? '',
|
|
||||||
message.text ?? '',
|
|
||||||
message.image ?? '',
|
|
||||||
message.link ?? '',
|
|
||||||
normalizeNote(message.note),
|
|
||||||
].join('::')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取消息时间
|
|
||||||
function getMessageTime(message: Message) {
|
|
||||||
return message.reg_time || message.date || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 排序消息列表,确保最新消息始终位于底部
|
|
||||||
function sortMessages(items: Message[]) {
|
|
||||||
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录最新消息时间
|
|
||||||
function updateLastTime(message: Message) {
|
|
||||||
const messageTime = getMessageTime(message)
|
|
||||||
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
|
|
||||||
lastTime.value = messageTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 判断元素自身是否是真正承载滚动的位置。 */
|
|
||||||
function isScrollableElement(element: HTMLElement) {
|
|
||||||
const { overflowY } = window.getComputedStyle(element)
|
|
||||||
const canScroll = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
|
|
||||||
|
|
||||||
return canScroll && element.scrollHeight > element.clientHeight + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取消息列表所在的真实滚动容器。 */
|
|
||||||
function getScrollContainer() {
|
|
||||||
const element = messageListRef.value?.$el ?? messageListRef.value
|
|
||||||
|
|
||||||
if (!(element instanceof HTMLElement)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let container: HTMLElement | null = element
|
|
||||||
while (container) {
|
|
||||||
if (isScrollableElement(container)) {
|
|
||||||
return container
|
|
||||||
}
|
|
||||||
|
|
||||||
container = container.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialogCardText = element.closest('.v-card-text')
|
|
||||||
|
|
||||||
return dialogCardText instanceof HTMLElement ? dialogCardText : element
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNearBottom(container: HTMLElement) {
|
|
||||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
|
|
||||||
|
|
||||||
return distanceFromBottom <= Math.max(MESSAGE_AUTO_SCROLL_THRESHOLD, container.clientHeight / 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAutoScrollState() {
|
|
||||||
const container = getScrollContainer()
|
|
||||||
if (!container || isSyncingScroll.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldAutoScroll.value = isNearBottom(container)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleScroll() {
|
|
||||||
updateAutoScrollState()
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindScrollListener() {
|
|
||||||
const container = getScrollContainer()
|
|
||||||
if (!container) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boundScrollContainer && boundScrollContainer !== container) {
|
|
||||||
boundScrollContainer.removeEventListener('scroll', handleScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
container.removeEventListener('scroll', handleScroll)
|
|
||||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
|
||||||
boundScrollContainer = container
|
|
||||||
updateAutoScrollState()
|
|
||||||
}
|
|
||||||
|
|
||||||
function unbindScrollListener() {
|
|
||||||
boundScrollContainer?.removeEventListener('scroll', handleScroll)
|
|
||||||
boundScrollContainer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 滚动到底部,并在布局稳定前连续几帧校正滚动位置。 */
|
|
||||||
function scrollContainerToEnd(retryCount = 1) {
|
|
||||||
const container = getScrollContainer()
|
|
||||||
if (!container) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bindScrollListener()
|
|
||||||
isSyncingScroll.value = true
|
|
||||||
container.scrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const latestContainer = getScrollContainer()
|
|
||||||
if (!latestContainer) {
|
|
||||||
isSyncingScroll.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
latestContainer.scrollTop = Math.max(0, latestContainer.scrollHeight - latestContainer.clientHeight)
|
|
||||||
shouldAutoScroll.value = true
|
|
||||||
|
|
||||||
if (retryCount > 0) {
|
|
||||||
scrollContainerToEnd(retryCount - 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollReleaseTimer) {
|
|
||||||
window.clearTimeout(scrollReleaseTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollReleaseTimer = window.setTimeout(() => {
|
|
||||||
isSyncingScroll.value = false
|
|
||||||
updateAutoScrollState()
|
|
||||||
}, 80)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestScrollToEnd(force = false) {
|
|
||||||
if (!force && !shouldAutoScroll.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollTimer) {
|
|
||||||
window.clearTimeout(scrollTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollTimer = window.setTimeout(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
scrollContainerToEnd(force ? 6 : 1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, force ? 0 : 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
function forceScrollToEnd() {
|
|
||||||
requestScrollToEnd(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并消息到当前列表
|
|
||||||
function mergeMessages(items: Message[]) {
|
|
||||||
let hasNewMessage = false
|
|
||||||
|
|
||||||
for (const item of sortMessages(items)) {
|
|
||||||
const messageKey = getMessageKey(item)
|
|
||||||
if (messageKeys.has(messageKey)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
messageKeys.add(messageKey)
|
|
||||||
messages.value.push(item)
|
|
||||||
updateLastTime(item)
|
|
||||||
hasNewMessage = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNewMessage) {
|
|
||||||
messages.value = sortMessages(messages.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasNewMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE消息处理函数
|
|
||||||
function handleSSEMessage(event: MessageEvent) {
|
|
||||||
const message = event.data
|
|
||||||
if (message) {
|
|
||||||
const object = JSON.parse(message)
|
|
||||||
if (mergeMessages([object])) {
|
|
||||||
requestScrollToEnd() // 新消息到达时触发智能滚动
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用SSE连接
|
|
||||||
const { manager, isConnected } = useSSE(
|
|
||||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
|
|
||||||
handleSSEMessage,
|
|
||||||
'message-view',
|
|
||||||
{
|
|
||||||
backgroundCloseDelay: 5000,
|
|
||||||
reconnectDelay: 3000,
|
|
||||||
maxReconnectAttempts: 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 调用API加载存量消息
|
|
||||||
async function loadMessages({ done }: { done: any }) {
|
|
||||||
// 如果正在加载中,直接返回
|
|
||||||
if (loading.value) {
|
|
||||||
done('ok')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 设置加载中
|
|
||||||
loading.value = true
|
|
||||||
const isFirstPage = page.value === 1
|
|
||||||
|
|
||||||
currData.value = await api.get('message/web', {
|
|
||||||
params: {
|
|
||||||
page: page.value,
|
|
||||||
size: 20,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
// 已加载过
|
|
||||||
isLoaded.value = true
|
|
||||||
if (currData.value.length > 0) {
|
|
||||||
mergeMessages(currData.value)
|
|
||||||
|
|
||||||
// 页码+1
|
|
||||||
page.value++
|
|
||||||
// 完成
|
|
||||||
done('ok')
|
|
||||||
|
|
||||||
// 首次加载完成后再滚动,避免列表尚未完成布局时滚动失效。
|
|
||||||
if (isFirstPage) {
|
|
||||||
requestScrollToEnd(true)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 没有新数据
|
|
||||||
done('empty')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载消息失败:', error)
|
|
||||||
done('error')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主动刷新最新一页消息,作为SSE偶发丢流时的兜底
|
|
||||||
async function refreshLatestMessages() {
|
|
||||||
try {
|
|
||||||
const latestMessages = (await api.get('message/web', {
|
|
||||||
params: {
|
|
||||||
page: 1,
|
|
||||||
size: 20,
|
|
||||||
},
|
|
||||||
})) as Message[]
|
|
||||||
|
|
||||||
if (mergeMessages(latestMessages)) {
|
|
||||||
requestScrollToEnd()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('刷新最新消息失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 比较yyyy-MM-dd HH:mm:ss时间大小
|
|
||||||
function compareTime(time1: string, time2: string) {
|
|
||||||
if (!time1 && !time2) return 0
|
|
||||||
if (!time1) return -1
|
|
||||||
if (!time2) return 1
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 统一时间格式处理,支持多种格式
|
|
||||||
const normalizeTime = (time: string) => {
|
|
||||||
// 如果是ISO格式,直接使用
|
|
||||||
if (time.includes('T')) {
|
|
||||||
return new Date(time).getTime()
|
|
||||||
}
|
|
||||||
// 如果是yyyy-MM-dd HH:mm:ss格式,替换-为/
|
|
||||||
return new Date(time.replaceAll(/-/g, '/')).getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp1 = normalizeTime(time1)
|
|
||||||
const timestamp2 = normalizeTime(time2)
|
|
||||||
|
|
||||||
return timestamp1 - timestamp2
|
|
||||||
} catch (error) {
|
|
||||||
console.error('时间比较错误:', error, 'time1:', time1, 'time2:', time2)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片加载完成时触发智能滚动
|
|
||||||
function handleImageLoad() {
|
|
||||||
requestScrollToEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暂停SSE连接
|
|
||||||
function pauseSSE() {
|
|
||||||
if (manager) {
|
|
||||||
manager.removeMessageListener('message-view')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复SSE连接
|
|
||||||
function resumeSSE() {
|
|
||||||
if (manager) {
|
|
||||||
// 先移除再重建监听,确保恢复时拿到一条新的SSE连接。
|
|
||||||
manager.removeMessageListener('message-view')
|
|
||||||
manager.addMessageListener('message-view', handleSSEMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshLatestMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
pauseSSE,
|
|
||||||
resumeSSE,
|
|
||||||
refreshLatestMessages,
|
|
||||||
forceScrollToEnd,
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
bindScrollListener()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (scrollTimer) {
|
|
||||||
window.clearTimeout(scrollTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollReleaseTimer) {
|
|
||||||
window.clearTimeout(scrollReleaseTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
unbindScrollListener()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VInfiniteScroll
|
|
||||||
ref="messageListRef"
|
|
||||||
:mode="!isLoaded ? 'intersect' : 'manual'"
|
|
||||||
side="start"
|
|
||||||
:items="messages"
|
|
||||||
class="overflow-auto h-full"
|
|
||||||
@load="loadMessages"
|
|
||||||
:load-more-text="t('message.loadMore') + ' ...'"
|
|
||||||
>
|
|
||||||
<template #loading>
|
|
||||||
<LoadingBanner />
|
|
||||||
</template>
|
|
||||||
<template #empty> {{ t('message.noMoreData') }} </template>
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in messages"
|
|
||||||
:key="getMessageKey(item) || index"
|
|
||||||
class="chat-group d-flex mt-5 mb-8"
|
|
||||||
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
|
||||||
>
|
|
||||||
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
|
|
||||||
<MessageCard :message="item" @imageload="handleImageLoad" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VInfiniteScroll>
|
|
||||||
</template>
|
|
||||||
Reference in New Issue
Block a user