492 lines
12 KiB
Vue
492 lines
12 KiB
Vue
<template>
|
||
<DefaultLayout>
|
||
<div class="notifications-page">
|
||
<div class="container">
|
||
<div class="header-actions">
|
||
<a-badge :count="unreadCount" :overflow-count="99">
|
||
<a-button @click="loadNotifications(true)">
|
||
<template #icon><Icon name="bell" size="sm" /></template>
|
||
未读消息
|
||
</a-button>
|
||
</a-badge>
|
||
<a-button @click="handleMarkAllRead" :disabled="unreadCount === 0">
|
||
<template #icon><Icon name="check" size="sm" /></template>
|
||
全部已读
|
||
</a-button>
|
||
</div>
|
||
|
||
<!-- 筛选标签 -->
|
||
<div class="filter-tabs">
|
||
<a-radio-group v-model:value="filterType" button-style="solid" @change="handleFilterChange">
|
||
<a-radio-button value="all">全部消息</a-radio-button>
|
||
<a-radio-button value="unread">未读消息</a-radio-button>
|
||
<a-radio-button value="system">系统通知</a-radio-button>
|
||
<a-radio-button value="task">任务消息</a-radio-button>
|
||
<a-radio-button value="score">积分消息</a-radio-button>
|
||
<a-radio-button value="review">评审消息</a-radio-button>
|
||
</a-radio-group>
|
||
</div>
|
||
|
||
<!-- 通知列表 -->
|
||
<a-spin :spinning="loading">
|
||
<div v-if="notifications.length > 0" class="notifications-list">
|
||
<div
|
||
v-for="notification in notifications"
|
||
:key="notification.id"
|
||
class="notification-item"
|
||
:class="{ 'unread': notification.isRead === 0 }"
|
||
@click="handleNotificationClick(notification)"
|
||
>
|
||
<!-- 左侧图标 -->
|
||
<div class="notification-icon" :class="getIconClass(notification)">
|
||
<Icon :name="getIconName(notification)" size="md" color="white" />
|
||
</div>
|
||
|
||
<!-- 中间内容 -->
|
||
<div class="notification-content">
|
||
<div class="notification-header">
|
||
<span class="notification-title">{{ notification.title }}</span>
|
||
<a-tag v-if="notification.isRead === 0" color="blue" size="small">未读</a-tag>
|
||
<a-tag :color="getPriorityColor(notification.priority)" size="small">
|
||
{{ getPriorityText(notification.priority) }}
|
||
</a-tag>
|
||
</div>
|
||
<div class="notification-body">{{ notification.content }}</div>
|
||
<div class="notification-footer">
|
||
<span class="notification-time">
|
||
<Icon name="clock" size="sm" />
|
||
{{ formatTime(notification.createTime) }}
|
||
</span>
|
||
<span v-if="notification.linkUrl" class="notification-link">
|
||
点击查看详情 →
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧操作 -->
|
||
<div class="notification-actions">
|
||
<a-button
|
||
v-if="notification.isRead === 0"
|
||
type="text"
|
||
size="small"
|
||
@click.stop="handleMarkRead(notification.id)"
|
||
>
|
||
<Icon name="check" size="sm" />
|
||
</a-button>
|
||
<a-popconfirm
|
||
title="确定删除这条通知?"
|
||
@confirm="handleDelete(notification.id)"
|
||
@click.stop
|
||
>
|
||
<a-button type="text" danger size="small" @click.stop>
|
||
<Icon name="delete" size="sm" />
|
||
</a-button>
|
||
</a-popconfirm>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-else class="empty-state">
|
||
<a-empty description="暂无消息">
|
||
<template #image>
|
||
<Icon name="inbox" size="xl" color="#d9d9d9" />
|
||
</template>
|
||
</a-empty>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div v-if="total > 0" class="pagination">
|
||
<a-pagination
|
||
v-model:current="pageNum"
|
||
v-model:page-size="pageSize"
|
||
:total="total"
|
||
show-size-changer
|
||
show-quick-jumper
|
||
:show-total="total => `共 ${total} 条`"
|
||
@change="handlePageChange"
|
||
/>
|
||
</div>
|
||
</a-spin>
|
||
</div>
|
||
</div>
|
||
</DefaultLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||
import { message } from 'ant-design-vue'
|
||
import Icon from '@/components/Icon.vue'
|
||
import {
|
||
getNotifications,
|
||
getUnreadCount,
|
||
markAsRead,
|
||
markAllAsRead,
|
||
deleteNotification,
|
||
type Notification
|
||
} from '@/api/notification'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
const router = useRouter()
|
||
|
||
const loading = ref(false)
|
||
const notifications = ref<Notification[]>([])
|
||
const unreadCount = ref(0)
|
||
const total = ref(0)
|
||
const pageNum = ref(1)
|
||
const pageSize = ref(20)
|
||
const filterType = ref('all')
|
||
|
||
// 模拟当前用户ID(实际应该从用户状态管理获取)
|
||
const currentUserId = ref(1)
|
||
|
||
// 加载通知列表
|
||
const loadNotifications = async (unreadOnly = false) => {
|
||
loading.value = true
|
||
try {
|
||
const params: any = {
|
||
receiverId: currentUserId.value,
|
||
pageNum: pageNum.value,
|
||
pageSize: pageSize.value
|
||
}
|
||
|
||
if (unreadOnly || filterType.value === 'unread') {
|
||
params.unreadOnly = true
|
||
}
|
||
|
||
const result = await getNotifications(params)
|
||
|
||
// 根据类型筛选
|
||
let filteredRecords = result.records
|
||
if (filterType.value !== 'all' && filterType.value !== 'unread') {
|
||
filteredRecords = result.records.filter(n => n.notificationType === filterType.value)
|
||
}
|
||
|
||
notifications.value = filteredRecords
|
||
total.value = result.total
|
||
} catch (error) {
|
||
message.error('加载通知失败')
|
||
console.error(error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 加载未读数量
|
||
const loadUnreadCount = async () => {
|
||
try {
|
||
unreadCount.value = await getUnreadCount(currentUserId.value)
|
||
} catch (error) {
|
||
console.error('加载未读数量失败', error)
|
||
}
|
||
}
|
||
|
||
// 标记为已读
|
||
const handleMarkRead = async (id: number) => {
|
||
try {
|
||
await markAsRead(id, currentUserId.value)
|
||
message.success('已标记为已读')
|
||
await Promise.all([loadNotifications(), loadUnreadCount()])
|
||
} catch (error) {
|
||
message.error('操作失败')
|
||
}
|
||
}
|
||
|
||
// 全部标记为已读
|
||
const handleMarkAllRead = async () => {
|
||
try {
|
||
const count = await markAllAsRead(currentUserId.value)
|
||
message.success(`已标记 ${count} 条消息为已读`)
|
||
await Promise.all([loadNotifications(), loadUnreadCount()])
|
||
} catch (error) {
|
||
message.error('操作失败')
|
||
}
|
||
}
|
||
|
||
// 删除通知
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await deleteNotification(id, currentUserId.value)
|
||
message.success('删除成功')
|
||
await Promise.all([loadNotifications(), loadUnreadCount()])
|
||
} catch (error) {
|
||
message.error('删除失败')
|
||
}
|
||
}
|
||
|
||
// 点击通知
|
||
const handleNotificationClick = async (notification: Notification) => {
|
||
// 标记为已读
|
||
if (notification.isRead === 0) {
|
||
await markAsRead(notification.id, currentUserId.value)
|
||
loadUnreadCount()
|
||
}
|
||
|
||
// 跳转到相关页面
|
||
if (notification.linkUrl) {
|
||
router.push(notification.linkUrl)
|
||
}
|
||
}
|
||
|
||
// 筛选改变
|
||
const handleFilterChange = () => {
|
||
pageNum.value = 1
|
||
loadNotifications()
|
||
}
|
||
|
||
// 分页改变
|
||
const handlePageChange = () => {
|
||
loadNotifications()
|
||
}
|
||
|
||
// 获取图标名称
|
||
const getIconName = (notification: Notification) => {
|
||
switch (notification.notificationType) {
|
||
case 'system':
|
||
return 'info'
|
||
case 'task':
|
||
return 'document'
|
||
case 'score':
|
||
return 'trophy'
|
||
case 'review':
|
||
return 'star'
|
||
default:
|
||
return 'bell'
|
||
}
|
||
}
|
||
|
||
// 获取图标样式类
|
||
const getIconClass = (notification: Notification) => {
|
||
return `type-${notification.notificationType}`
|
||
}
|
||
|
||
// 获取优先级颜色
|
||
const getPriorityColor = (priority: string) => {
|
||
switch (priority) {
|
||
case 'urgent':
|
||
return 'red'
|
||
case 'high':
|
||
return 'orange'
|
||
case 'normal':
|
||
return 'blue'
|
||
case 'low':
|
||
return 'default'
|
||
default:
|
||
return 'blue'
|
||
}
|
||
}
|
||
|
||
// 获取优先级文本
|
||
const getPriorityText = (priority: string) => {
|
||
switch (priority) {
|
||
case 'urgent':
|
||
return '紧急'
|
||
case 'high':
|
||
return '重要'
|
||
case 'normal':
|
||
return '普通'
|
||
case 'low':
|
||
return '低'
|
||
default:
|
||
return '普通'
|
||
}
|
||
}
|
||
|
||
// 格式化时间
|
||
const formatTime = (time: string) => {
|
||
const date = new Date(time)
|
||
const now = new Date()
|
||
const diff = now.getTime() - date.getTime()
|
||
|
||
const minute = 60 * 1000
|
||
const hour = 60 * minute
|
||
const day = 24 * hour
|
||
|
||
if (diff < minute) {
|
||
return '刚刚'
|
||
} else if (diff < hour) {
|
||
return `${Math.floor(diff / minute)}分钟前`
|
||
} else if (diff < day) {
|
||
return `${Math.floor(diff / hour)}小时前`
|
||
} else if (diff < 7 * day) {
|
||
return `${Math.floor(diff / day)}天前`
|
||
} else {
|
||
return date.toLocaleDateString('zh-CN')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadNotifications()
|
||
loadUnreadCount()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.notifications-page {
|
||
min-height: 100vh;
|
||
padding: $space-xl 0;
|
||
background: $bg-primary;
|
||
}
|
||
|
||
.container {
|
||
max-width: $container-max-width;
|
||
margin: 0 auto;
|
||
padding: 0 $space-xl;
|
||
|
||
@media (max-width: $breakpoint-tablet) {
|
||
padding: 0 $space-lg;
|
||
}
|
||
|
||
@media (max-width: $breakpoint-mobile) {
|
||
padding: 0 $space-md;
|
||
}
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: $space-md;
|
||
margin-bottom: $space-2xl;
|
||
}
|
||
|
||
.filter-tabs {
|
||
margin-bottom: $space-xl;
|
||
}
|
||
|
||
// 图表卡片 - Cursor 风格
|
||
.chart-card {
|
||
background: $bg-card;
|
||
border: 1px solid $border-primary;
|
||
border-radius: $radius-lg;
|
||
padding: $space-xl;
|
||
transition: all $transition-base $ease-out;
|
||
|
||
&:hover {
|
||
border-color: $border-color;
|
||
background: $bg-card-hover;
|
||
}
|
||
}
|
||
|
||
.notifications-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: $space-md;
|
||
}
|
||
|
||
.notification-item {
|
||
background: $bg-card;
|
||
border: 1px solid $border-primary;
|
||
border-radius: $radius-lg;
|
||
padding: $space-lg;
|
||
display: flex;
|
||
gap: $space-lg;
|
||
transition: all $transition-base $ease-out;
|
||
cursor: pointer;
|
||
|
||
&:hover {
|
||
border-color: $border-color;
|
||
background: $bg-card-hover;
|
||
box-shadow: $shadow-base;
|
||
}
|
||
|
||
&.unread {
|
||
background: rgba($brand-primary, 0.05);
|
||
border-color: $brand-primary;
|
||
|
||
.notification-title {
|
||
font-weight: $font-weight-bold;
|
||
}
|
||
}
|
||
}
|
||
|
||
.notification-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: $radius-md;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 24px;
|
||
flex-shrink: 0;
|
||
|
||
&.type-system,
|
||
&.type-task,
|
||
&.type-score,
|
||
&.type-review {
|
||
background: rgba(#31f08b, 0.1);
|
||
border: 1px solid rgba(#31f08b, 0.3);
|
||
color: #31f08b;
|
||
}
|
||
}
|
||
|
||
.notification-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.notification-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $space-sm;
|
||
margin-bottom: $space-sm;
|
||
|
||
.notification-title {
|
||
font-size: $font-size-lg;
|
||
color: $text-primary;
|
||
flex: 1;
|
||
}
|
||
}
|
||
|
||
.notification-body {
|
||
font-size: $font-size-base;
|
||
color: $text-secondary;
|
||
line-height: 1.6;
|
||
margin-bottom: $space-sm;
|
||
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.notification-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: $font-size-sm;
|
||
color: $text-tertiary;
|
||
|
||
.notification-time {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: $space-xs;
|
||
}
|
||
|
||
.notification-link {
|
||
color: $primary-color;
|
||
font-weight: $font-weight-medium;
|
||
}
|
||
}
|
||
|
||
.notification-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: $space-xs;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.empty-state {
|
||
padding: $space-3xl 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.pagination {
|
||
margin-top: $space-2xl;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
</style>
|
||
|