feat: 增强情绪博物馆项目功能 - 新增用户评论和帖子功能,优化前端架构和WebSocket通信 - 更新文档和部署配置
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="notification-center">
|
||||
<!-- 通知按钮 -->
|
||||
<el-popover
|
||||
:visible="visible"
|
||||
:width="360"
|
||||
trigger="manual"
|
||||
placement="bottom-end"
|
||||
popper-class="notification-popover"
|
||||
@hide="handleHide"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="notification-trigger" @click="toggleNotifications">
|
||||
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
|
||||
<el-button circle>
|
||||
<el-icon :size="18">
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-badge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="notification-content">
|
||||
<!-- 头部 -->
|
||||
<div class="notification-header">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">通知</h3>
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-button
|
||||
v-if="unreadCount > 0"
|
||||
size="small"
|
||||
text
|
||||
@click="markAllAsRead"
|
||||
>
|
||||
全部已读
|
||||
</el-button>
|
||||
<el-button size="small" text @click="clearAll">
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选标签 -->
|
||||
<div class="notification-filters">
|
||||
<div class="filter-tabs">
|
||||
<div
|
||||
v-for="filter in filters"
|
||||
:key="filter.key"
|
||||
class="filter-tab"
|
||||
:class="{ active: activeFilter === filter.key }"
|
||||
@click="switchFilter(filter.key)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
<span v-if="filter.count > 0" class="filter-count">{{ filter.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<div class="notification-list">
|
||||
<div v-if="filteredNotifications.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<el-icon size="32" class="text-gray-400">
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</div>
|
||||
<p class="empty-text">暂无通知</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-items">
|
||||
<div
|
||||
v-for="notification in filteredNotifications"
|
||||
:key="notification.id"
|
||||
class="notification-item"
|
||||
:class="{ unread: !notification.read }"
|
||||
@click="handleNotificationClick(notification)"
|
||||
>
|
||||
<div class="notification-icon">
|
||||
<div
|
||||
class="icon-wrapper"
|
||||
:class="getNotificationIconClass(notification.type)"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="getNotificationIcon(notification.type)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">{{ notification.title }}</div>
|
||||
<div class="notification-message">{{ notification.message }}</div>
|
||||
<div class="notification-time">{{ formatTime(notification.createTime) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-actions">
|
||||
<el-dropdown @command="handleAction">
|
||||
<el-button circle size="small" text>
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-if="!notification.read"
|
||||
:command="`read_${notification.id}`"
|
||||
>
|
||||
标记已读
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-else
|
||||
:command="`unread_${notification.id}`"
|
||||
>
|
||||
标记未读
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:command="`delete_${notification.id}`"
|
||||
divided
|
||||
>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div v-if="hasMore" class="notification-footer">
|
||||
<el-button text @click="loadMore" :loading="loading">
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Bell,
|
||||
MoreFilled,
|
||||
ChatDotRound,
|
||||
User,
|
||||
Setting,
|
||||
Warning,
|
||||
InfoFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
|
||||
interface NotificationItem {
|
||||
id: string
|
||||
type: 'message' | 'system' | 'user' | 'warning' | 'info'
|
||||
title: string
|
||||
message: string
|
||||
read: boolean
|
||||
createTime: number
|
||||
data?: any
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const activeFilter = ref('all')
|
||||
|
||||
// 模拟通知数据
|
||||
const notifications = ref<NotificationItem[]>([
|
||||
{
|
||||
id: '1',
|
||||
type: 'message',
|
||||
title: 'AI助手回复',
|
||||
message: '您的问题已经得到回复,请查看',
|
||||
read: false,
|
||||
createTime: Date.now() - 1000 * 60 * 5
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'system',
|
||||
title: '系统更新',
|
||||
message: '系统已更新到最新版本 v1.2.0',
|
||||
read: false,
|
||||
createTime: Date.now() - 1000 * 60 * 60
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'user',
|
||||
title: '资料完善提醒',
|
||||
message: '完善个人资料可以获得更好的服务体验',
|
||||
read: true,
|
||||
createTime: Date.now() - 1000 * 60 * 60 * 24
|
||||
}
|
||||
])
|
||||
|
||||
const hasMore = ref(true)
|
||||
|
||||
// 筛选选项
|
||||
const filters = computed(() => [
|
||||
{ key: 'all', label: '全部', count: notifications.value.length },
|
||||
{ key: 'unread', label: '未读', count: unreadCount.value },
|
||||
{ key: 'message', label: '消息', count: notifications.value.filter(n => n.type === 'message').length },
|
||||
{ key: 'system', label: '系统', count: notifications.value.filter(n => n.type === 'system').length }
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const unreadCount = computed(() => {
|
||||
return notifications.value.filter(n => !n.read).length
|
||||
})
|
||||
|
||||
const filteredNotifications = computed(() => {
|
||||
let filtered = notifications.value
|
||||
|
||||
switch (activeFilter.value) {
|
||||
case 'unread':
|
||||
filtered = filtered.filter(n => !n.read)
|
||||
break
|
||||
case 'message':
|
||||
filtered = filtered.filter(n => n.type === 'message')
|
||||
break
|
||||
case 'system':
|
||||
filtered = filtered.filter(n => n.type === 'system')
|
||||
break
|
||||
case 'user':
|
||||
filtered = filtered.filter(n => n.type === 'user')
|
||||
break
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => b.createTime - a.createTime)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleNotifications = () => {
|
||||
visible.value = !visible.value
|
||||
}
|
||||
|
||||
const handleHide = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const switchFilter = (filterKey: string) => {
|
||||
activeFilter.value = filterKey
|
||||
}
|
||||
|
||||
const handleNotificationClick = (notification: NotificationItem) => {
|
||||
// 标记为已读
|
||||
if (!notification.read) {
|
||||
markAsRead(notification.id)
|
||||
}
|
||||
|
||||
// 处理点击事件
|
||||
switch (notification.type) {
|
||||
case 'message':
|
||||
// 跳转到聊天页面
|
||||
break
|
||||
case 'system':
|
||||
// 显示系统消息详情
|
||||
break
|
||||
case 'user':
|
||||
// 跳转到个人资料页面
|
||||
break
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
const markAsRead = (notificationId: string) => {
|
||||
const notification = notifications.value.find(n => n.id === notificationId)
|
||||
if (notification) {
|
||||
notification.read = true
|
||||
}
|
||||
}
|
||||
|
||||
const markAsUnread = (notificationId: string) => {
|
||||
const notification = notifications.value.find(n => n.id === notificationId)
|
||||
if (notification) {
|
||||
notification.read = false
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
notifications.value.forEach(n => {
|
||||
n.read = true
|
||||
})
|
||||
ElMessage.success('已标记全部为已读')
|
||||
}
|
||||
|
||||
const deleteNotification = (notificationId: string) => {
|
||||
const index = notifications.value.findIndex(n => n.id === notificationId)
|
||||
if (index > -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
ElMessage.success('通知已删除')
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
notifications.value = []
|
||||
ElMessage.success('已清空所有通知')
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
loading.value = true
|
||||
|
||||
// 模拟加载更多
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
hasMore.value = false
|
||||
ElMessage.info('没有更多通知了')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleAction = (command: string) => {
|
||||
const [action, notificationId] = command.split('_')
|
||||
|
||||
switch (action) {
|
||||
case 'read':
|
||||
markAsRead(notificationId)
|
||||
break
|
||||
case 'unread':
|
||||
markAsUnread(notificationId)
|
||||
break
|
||||
case 'delete':
|
||||
deleteNotification(notificationId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'message':
|
||||
return ChatDotRound
|
||||
case 'system':
|
||||
return Setting
|
||||
case 'user':
|
||||
return User
|
||||
case 'warning':
|
||||
return Warning
|
||||
case 'info':
|
||||
return InfoFilled
|
||||
default:
|
||||
return Bell
|
||||
}
|
||||
}
|
||||
|
||||
const getNotificationIconClass = (type: string) => {
|
||||
switch (type) {
|
||||
case 'message':
|
||||
return 'text-blue-500 bg-blue-100'
|
||||
case 'system':
|
||||
return 'text-green-500 bg-green-100'
|
||||
case 'user':
|
||||
return 'text-purple-500 bg-purple-100'
|
||||
case 'warning':
|
||||
return 'text-orange-500 bg-orange-100'
|
||||
case 'info':
|
||||
return 'text-gray-500 bg-gray-100'
|
||||
default:
|
||||
return 'text-gray-500 bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return formatRelativeTime(timestamp)
|
||||
}
|
||||
|
||||
// 点击外部关闭
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.notification-center') && !target.closest('.notification-popover')) {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-trigger {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
@apply pb-3 border-b border-gray-200 mb-3;
|
||||
}
|
||||
|
||||
.notification-filters {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
@apply flex space-x-1;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
@apply px-3 py-1 text-sm rounded-full cursor-pointer transition-colors flex items-center space-x-1;
|
||||
@apply text-gray-600 hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
@apply bg-blue-100 text-blue-600;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
@apply bg-current text-white rounded-full px-1.5 py-0.5 text-xs min-w-[1.25rem] text-center;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
@apply max-h-96 overflow-y-auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply text-center py-8;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
@apply text-gray-500 text-sm;
|
||||
}
|
||||
|
||||
.notification-items {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
@apply flex items-start space-x-3 p-3 rounded-lg cursor-pointer transition-colors;
|
||||
@apply hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
@apply bg-blue-50 border-l-4 border-blue-500;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
@apply w-8 h-8 rounded-full flex items-center justify-center;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
@apply text-sm font-medium text-gray-900 mb-1;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
@apply text-sm text-gray-600 mb-1 line-clamp-2;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
@apply text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
@apply flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity;
|
||||
}
|
||||
|
||||
.notification-item:hover .notification-actions {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.notification-footer {
|
||||
@apply text-center pt-3 border-t border-gray-200 mt-3;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.notification-list::-webkit-scrollbar {
|
||||
@apply w-1;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 rounded;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded hover:bg-gray-400;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.notification-popover {
|
||||
padding: 16px !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user