feat: 增强情绪博物馆项目功能 - 新增用户评论和帖子功能,优化前端架构和WebSocket通信 - 更新文档和部署配置

This commit is contained in:
2025-07-29 07:38:47 +08:00
parent cc886cd4d5
commit 2f3d39fb00
142 changed files with 45645 additions and 0 deletions
@@ -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>