This commit is contained in:
2026-02-18 19:41:03 +08:00
parent a0c2350662
commit 6543e2ccdd
18 changed files with 4885 additions and 1196 deletions

View File

@@ -338,6 +338,75 @@ export default {
},
},
// 通知管理
notifications: {
list: {
get: async function (params) {
return await request.get('system/notifications', { params })
},
},
unread: {
get: async function (params) {
return await request.get('system/notifications/unread', { params })
},
},
unreadCount: {
get: async function () {
return await request.get('system/notifications/unread-count')
},
},
detail: {
get: async function (id) {
return await request.get(`system/notifications/${id}`)
},
},
markAsRead: {
post: async function (id) {
return await request.post(`system/notifications/${id}/read`)
},
},
batchMarkAsRead: {
post: async function (params) {
return await request.post('system/notifications/batch-read', params)
},
},
markAllAsRead: {
post: async function () {
return await request.post('system/notifications/read-all')
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/notifications/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post('system/notifications/batch-delete', params)
},
},
clearRead: {
post: async function () {
return await request.post('system/notifications/clear-read')
},
},
statistics: {
get: async function () {
return await request.get('system/notifications/statistics')
},
},
send: {
post: async function (params) {
return await request.post('system/notifications/send', params)
},
},
retryUnsent: {
post: async function (params) {
return await request.post('system/notifications/retry-unsent', params)
},
},
},
// 公共接口 (无需认证)
public: {
configs: {

View File

@@ -3,6 +3,7 @@ import { getWebSocket } from '@/utils/websocket'
import { useUserStore } from '@/stores/modules/user'
import { useMessageStore } from '@/stores/modules/message'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import { useNotificationStore } from '@/stores/modules/notification'
import { message, notification } from 'ant-design-vue'
import config from '@/config'
@@ -16,6 +17,7 @@ export function useWebSocket() {
const userStore = useUserStore()
const messageStore = useMessageStore()
const dictionaryStore = useDictionaryStore()
const notificationStore = useNotificationStore()
const reconnectTimer = ref(null)
/**
@@ -167,9 +169,27 @@ export function useWebSocket() {
function handleNotification(data) {
console.log('收到系统通知:', data)
const { title, message: content, type, timestamp } = data
const { title, message: content, type, timestamp, ...rest } = data
// 添加到消息 store
// 构建通知数据格式
const notificationData = {
id: rest.id || Date.now(),
title: title || '系统通知',
content: content || '',
type: type || 'info',
category: rest.category || 'system',
is_read: false,
created_at: rest.created_at || new Date().toISOString(),
...rest
}
// 更新通知 store
notificationStore.handleWebSocketMessage({
type: 'notification',
data: notificationData
})
// 添加到消息 store保持兼容性
messageStore.addMessage({
type: type || 'notification',
title: title || '系统通知',

View File

@@ -8,7 +8,7 @@
</a-tooltip>
<!-- 消息通知 -->
<a-dropdown v-model:open="messageVisible" :trigger="['click']" placement="bottomRight">
<a-dropdown v-model:open="messageVisible" :trigger="['click']" placement="bottomRight" @openChange="handleMessageDropdownOpen">
<a-badge :count="messageCount" :offset="[-5, 5]">
<a-button type="text" class="action-btn">
<BellOutlined />
@@ -51,9 +51,9 @@
<div class="message-content">
<div class="message-title">{{ msg.title }}</div>
<div class="message-content-text">{{ msg.content }}</div>
<div class="message-time">{{ messageStore.formatMessageTime(msg.timestamp) }}</div>
<div class="message-time">{{ notificationStore.formatNotificationTime(msg.created_at) }}</div>
</div>
<a-badge v-if="!msg.read" dot />
<a-badge v-if="!msg.is_read" dot />
<a-button
type="text"
size="small"
@@ -160,11 +160,12 @@ import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/modules/user'
import { useI18nStore } from '@/stores/modules/i18n'
import { useMessageStore, MessageType } from '@/stores/modules/message'
import { useNotificationStore } from '@/stores/modules/notification'
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
import search from './search.vue'
import task from './task.vue'
import { useWebSocket } from '@/composables/useWebSocket'
// 定义组件名称(多词命名)
defineOptions({
@@ -175,7 +176,7 @@ const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const i18nStore = useI18nStore()
const messageStore = useMessageStore()
const notificationStore = useNotificationStore()
const isFullscreen = ref(false)
const searchVisible = ref(false)
@@ -184,28 +185,16 @@ const messageVisible = ref(false)
const currentMessageType = ref('all')
const messagesPage = ref(1)
const messagesPageSize = ref(10)
// 从 store 获取消息数据
const messages = computed(() => {
const result = messageStore.getMessages({
page: messagesPage.value,
pageSize: messagesPageSize.value,
type: currentMessageType.value === 'all' ? null : currentMessageType.value
})
return result.list
})
// 消息总数(用于分页)
const messagesTotal = computed(() => {
return messageStore.getMessages({
page: messagesPage.value,
pageSize: messagesPageSize.value,
type: currentMessageType.value === 'all' ? null : currentMessageType.value
}).total
})
const notificationsList = ref([])
// 未读消息数量
const messageCount = computed(() => messageStore.unreadCount)
const messageCount = computed(() => notificationStore.unreadCount)
// 消息总数(用于分页)
const messagesTotal = computed(() => notificationStore.total)
// 从 store 获取消息数据
const messages = computed(() => notificationsList.value)
// 任务数据
const tasks = ref([
@@ -232,8 +221,58 @@ const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
// 加载未读通知
const loadNotifications = async () => {
try {
await notificationStore.fetchUnreadCount()
await loadUnreadNotifications()
} catch (error) {
console.error('加载通知失败:', error)
}
}
// 加载未读通知列表
const loadUnreadNotifications = async () => {
try {
const res = await notificationStore.fetchUnreadNotifications({
page: messagesPage.value,
page_size: messagesPageSize.value,
type: currentMessageType.value === 'all' ? null : currentMessageType.value
})
notificationsList.value = res.list || []
} catch (error) {
console.error('加载未读通知列表失败:', error)
}
}
// 加载所有通知
const loadAllNotifications = async () => {
try {
await notificationStore.fetchNotifications({
page: messagesPage.value,
page_size: messagesPageSize.value,
type: currentMessageType.value === 'all' ? null : currentMessageType.value
})
notificationsList.value = notificationStore.notifications
} catch (error) {
console.error('加载通知列表失败:', error)
}
}
// WebSocket 消息处理
const handleWebSocketMessage = (data) => {
notificationStore.handleWebSocketMessage(data)
}
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
// 加载通知数据
loadNotifications()
// 连接 WebSocket
const { initWebSocket } = useWebSocket()
initWebSocket()
})
onUnmounted(() => {
@@ -246,46 +285,113 @@ const showSearch = () => {
}
// 清除消息
const clearMessages = () => {
const clearMessages = async () => {
Modal.confirm({
title: t('common.confirmClear'),
content: t('common.confirmClearMessages'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: () => {
messageStore.clearAll()
message.success(t('common.cleared'))
onOk: async () => {
try {
await notificationStore.clearReadNotifications()
message.success(t('common.cleared'))
notificationsList.value = []
} catch (error) {
console.error('清空消息失败:', error)
}
},
})
}
// 标记消息为已读
const handleMessageRead = (msg) => {
if (!msg.read) {
messageStore.markAsRead(msg.id)
const handleMessageRead = async (msg) => {
if (!msg.is_read) {
try {
await notificationStore.markAsRead(msg.id)
// 更新本地状态
const notification = notificationsList.value.find(n => n.id === msg.id)
if (notification) {
notification.is_read = true
notification.read_at = new Date().toISOString()
}
} catch (error) {
console.error('标记已读失败:', error)
}
}
}
// 标记所有消息为已读
const markAllAsRead = () => {
messageStore.markAllAsRead()
message.success(t('common.markedAsRead'))
const markAllAsRead = async () => {
Modal.confirm({
title: '确认全部已读',
content: '确定要将所有消息标记为已读吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await notificationStore.markAllAsRead()
message.success(t('common.markedAsRead'))
// 更新本地状态
notificationsList.value.forEach(n => {
n.is_read = true
n.read_at = n.read_at || new Date().toISOString()
})
} catch (error) {
console.error('标记全部已读失败:', error)
}
}
})
}
// 删除消息
const handleDeleteMessage = (msgId) => {
messageStore.removeMessage(msgId)
const handleDeleteMessage = async (msgId) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条消息吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await notificationStore.deleteNotification(msgId)
message.success('删除成功')
// 更新本地状态
const index = notificationsList.value.findIndex(n => n.id === msgId)
if (index !== -1) {
notificationsList.value.splice(index, 1)
}
} catch (error) {
console.error('删除消息失败:', error)
}
}
})
}
// 切换消息类型
const changeMessageType = (type) => {
const changeMessageType = async (type) => {
currentMessageType.value = type
messagesPage.value = 1
if (type === 'all') {
await loadAllNotifications()
} else {
await loadUnreadNotifications()
}
}
// 分页变化
const handleMessagePageChange = (page) => {
const handleMessagePageChange = async (page) => {
messagesPage.value = page
if (currentMessageType.value === 'all') {
await loadAllNotifications()
} else {
await loadUnreadNotifications()
}
}
// 下拉框打开时加载数据
const handleMessageDropdownOpen = async (open) => {
if (open) {
await loadNotifications()
}
}
// 显示任务抽屉

View File

@@ -0,0 +1,643 @@
<template>
<div class="pages-base-layout system-notifications-page">
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.keyword" placeholder="通知标题/内容" allow-clear style="width: 180px" />
<a-select
v-model:value="searchForm.is_read"
placeholder="阅读状态"
allow-clear
style="width: 120px"
>
<a-select-option :value="true">已读</a-select-option>
<a-select-option :value="false">未读</a-select-option>
</a-select>
<a-select
v-model:value="searchForm.type"
placeholder="通知类型"
allow-clear
style="width: 120px"
:options="typeOptions"
/>
<a-select
v-model:value="searchForm.category"
placeholder="通知分类"
allow-clear
style="width: 120px"
:options="categoryOptions"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><search-outlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><redo-outlined /></template>
重置
</a-button>
</a-space>
</div>
<div class="right-panel">
<a-space>
<a-badge :count="unreadCount" :offset="[-5, 5]" :number-style="{ backgroundColor: '#f5222d' }">
<a-button @click="handleMarkAllRead">
<template #icon><check-outlined /></template>
全部已读
</a-button>
</a-badge>
<a-button @click="handleClearRead">
<template #icon><delete-outlined /></template>
清空已读
</a-button>
<a-dropdown>
<a-button>
批量操作
<down-outlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleBatchMarkRead">
<check-outlined />
批量已读
</a-menu-item>
<a-menu-item @click="handleBatchDelete">
<delete-outlined />
批量删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</div>
</div>
<div class="table-content">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:row-key="(record) => record.id"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="notification-title" :class="{ unread: !record.is_read }" @click="handleViewDetail(record)">
<WarningOutlined v-if="!record.is_read" class="unread-icon" />
<span>{{ record.title }}</span>
</div>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">
<component :is="getTypeIcon(record.type)" class="type-icon" />
{{ getTypeText(record.type) }}
</a-tag>
</template>
<template v-if="column.key === 'category'">
<a-tag color="blue">{{ getCategoryText(record.category) }}</a-tag>
</template>
<template v-if="column.key === 'is_read'">
<a-badge :status="record.is_read ? 'default' : 'processing'" :text="record.is_read ? '已读' : '未读'" />
</template>
<template v-if="column.key === 'created_at'">
<span>{{ formatTime(record.created_at) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record)">
<eye-outlined />
查看
</a-button>
<a-button
v-if="!record.is_read"
type="link"
size="small"
@click="handleMarkRead(record)"
>
<check-outlined />
标为已读
</a-button>
<a-button type="link" size="small" danger @click="handleDelete(record)">
<delete-outlined />
删除
</a-button>
</a-space>
</template>
</template>
</scTable>
</div>
<!-- 通知详情弹窗 -->
<a-drawer v-model:open="showDetailDrawer" title="通知详情" placement="right" width="600">
<template v-if="currentNotification">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="标题">
{{ currentNotification.title }}
</a-descriptions-item>
<a-descriptions-item label="类型">
<a-tag :color="getTypeColor(currentNotification.type)">
<component :is="getTypeIcon(currentNotification.type)" class="type-icon" />
{{ getTypeText(currentNotification.type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="分类">
<a-tag color="blue">{{ getCategoryText(currentNotification.category) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="currentNotification.is_read ? 'default' : 'processing'"
:text="currentNotification.is_read ? '已读' : '未读'"
/>
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(currentNotification.created_at) }}
</a-descriptions-item>
<a-descriptions-item v-if="currentNotification.read_at" label="阅读时间">
{{ formatTime(currentNotification.read_at) }}
</a-descriptions-item>
</a-descriptions>
<div class="notification-content">
<div class="content-label">通知内容:</div>
<div class="content-text">{{ currentNotification.content }}</div>
</div>
<div v-if="currentNotification.data && Object.keys(currentNotification.data).length > 0" class="notification-data">
<div class="data-label">附加数据:</div>
<pre class="data-text">{{ JSON.stringify(currentNotification.data, null, 2) }}</pre>
</div>
<div v-if="currentNotification.action_type && currentNotification.action_type !== 'none'" class="notification-action">
<a-button type="primary" @click="handleAction">
<template #icon><arrow-right-outlined /></template>
{{ getActionText(currentNotification.action_type) }}
</a-button>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined,
RedoOutlined,
CheckOutlined,
DeleteOutlined,
DownOutlined,
EyeOutlined,
WarningOutlined,
InfoCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
CloseCircleOutlined,
BellOutlined,
MessageOutlined,
ClockCircleOutlined,
BulbOutlined,
ArrowRightOutlined
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import { useWebSocket } from '@/composables/useWebSocket'
// 表格引用
const tableRef = ref(null)
// WebSocket
const ws = useWebSocket()
let unreadCountInterval = null
// 搜索表单
const searchForm = reactive({
keyword: '',
is_read: undefined,
type: undefined,
category: undefined
})
// 未读数量
const unreadCount = ref(0)
// 当前通知
const currentNotification = ref(null)
// 显示详情抽屉
const showDetailDrawer = ref(false)
// 通知类型选项
const typeOptions = [
{ label: '信息', value: 'info' },
{ label: '成功', value: 'success' },
{ label: '警告', value: 'warning' },
{ label: '错误', value: 'error' },
{ label: '任务', value: 'task' },
{ label: '系统', value: 'system' }
]
// 通知分类选项
const categoryOptions = [
{ label: '系统通知', value: 'system' },
{ label: '任务通知', value: 'task' },
{ label: '消息通知', value: 'message' },
{ label: '提醒通知', value: 'reminder' },
{ label: '公告通知', value: 'announcement' }
]
// 使用 useTable Hook
const { tableData, loading, pagination, rowSelection, handleSearch, handleReset, handlePaginationChange, refreshTable } =
useTable({
api: systemApi.notifications.list.get,
searchForm,
needPagination: true
})
// 表格列配置
const columns = [
{
title: '通知标题',
dataIndex: 'title',
key: 'title',
width: 300,
ellipsis: true
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 120,
align: 'center'
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
align: 'center'
},
{
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
width: 100,
align: 'center'
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right'
}
]
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan'
}
return colors[type] || 'default'
}
// 获取类型图标
const getTypeIcon = (type) => {
const icons = {
info: InfoCircleOutlined,
success: CheckCircleOutlined,
warning: ExclamationCircleOutlined,
error: CloseCircleOutlined,
task: BellOutlined,
system: MessageOutlined
}
return icons[type] || InfoCircleOutlined
}
// 获取类型文本
const getTypeText = (type) => {
const texts = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统'
}
return texts[type] || type
}
// 获取分类文本
const getCategoryText = (category) => {
const texts = {
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知'
}
return texts[category] || category
}
// 获取操作文本
const getActionText = (actionType) => {
const texts = {
link: '查看详情',
modal: '打开弹窗',
none: ''
}
return texts[actionType] || '查看'
}
// 格式化时间
const formatTime = (time) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 获取未读数量
const loadUnreadCount = async () => {
try {
const res = await systemApi.notifications.unreadCount.get()
unreadCount.value = res.data.count
} catch (error) {
console.error('获取未读数量失败:', error)
}
}
// 查看详情
const handleViewDetail = async (record) => {
currentNotification.value = { ...record }
showDetailDrawer.value = true
// 自动标记为已读
if (!record.is_read) {
await handleMarkRead(record)
}
}
// 标记已读
const handleMarkRead = async (record) => {
try {
await systemApi.notifications.markAsRead.post(record.id)
message.success('已标记为已读')
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
refreshTable()
} catch (error) {
message.error(error.message || '操作失败')
}
}
// 批量标记已读
const handleBatchMarkRead = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
if (selectedRowKeys.length === 0) {
message.warning('请先选择要操作的通知')
return
}
Modal.confirm({
title: '确认标记为已读',
content: `确定要将选中的 ${selectedRowKeys.length} 条通知标记为已读吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({ ids: selectedRowKeys })
message.success('批量标记成功')
rowSelection.selectedRowKeys = []
unreadCount.value = Math.max(0, unreadCount.value - res.data.count)
refreshTable()
} catch (error) {
message.error(error.message || '操作失败')
}
}
})
}
// 标记全部已读
const handleMarkAllRead = () => {
Modal.confirm({
title: '确认全部已读',
content: '确定要将所有未读通知标记为已读吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.markAllAsRead.post()
message.success('已标记全部为已读')
unreadCount.value = 0
refreshTable()
} catch (error) {
message.error(error.message || '操作失败')
}
}
})
}
// 删除
const handleDelete = (record) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除通知"${record.title}"吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await systemApi.notifications.delete.delete(record.id)
message.success('删除成功')
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
refreshTable()
} catch (error) {
message.error(error.message || '删除失败')
}
}
})
}
// 批量删除
const handleBatchDelete = () => {
const selectedRowKeys = rowSelection.selectedRowKeys
if (selectedRowKeys.length === 0) {
message.warning('请先选择要删除的通知')
return
}
Modal.confirm({
title: '确认删除',
content: `确定要删除选中的 ${selectedRowKeys.length} 条通知吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.batchDelete.post({ ids: selectedRowKeys })
message.success('批量删除成功')
rowSelection.selectedRowKeys = []
loadUnreadCount()
refreshTable()
} catch (error) {
message.error(error.message || '批量删除失败')
}
}
})
}
// 清空已读
const handleClearRead = () => {
Modal.confirm({
title: '确认清空',
content: '确定要清空所有已读通知吗?此操作不可恢复。',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await systemApi.notifications.clearRead.post()
message.success('已清空已读通知')
refreshTable()
} catch (error) {
message.error(error.message || '操作失败')
}
}
})
}
// 处理操作
const handleAction = () => {
const notification = currentNotification.value
if (!notification) return
if (notification.action_type === 'link' && notification.action_data?.url) {
window.open(notification.action_data.url, '_blank')
} else if (notification.action_type === 'modal') {
// 打开弹窗的逻辑
message.info('打开弹窗功能')
}
}
// WebSocket 消息处理
const handleWebSocketMessage = (msg) => {
if (msg.type === 'notification') {
// 收到新通知
const notification = msg.data
message.info(`新通知: ${notification.title}`)
unreadCount.value++
refreshTable()
}
}
// 初始化
onMounted(() => {
loadUnreadCount()
// 连接 WebSocket
ws.connect()
ws.onMessage(handleWebSocketMessage)
// 定时刷新未读数量每30秒
unreadCountInterval = setInterval(() => {
loadUnreadCount()
}, 30000)
})
// 清理
onUnmounted(() => {
if (unreadCountInterval) {
clearInterval(unreadCountInterval)
}
})
</script>
<style scoped lang="scss">
.system-notifications-page {
.notification-title {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: color 0.3s;
&.unread {
color: #1890ff;
font-weight: 500;
}
.unread-icon {
color: #f5222d;
}
&:hover {
color: #40a9ff;
}
}
.type-icon {
margin-right: 4px;
}
.notification-content {
margin-top: 20px;
.content-label {
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
.content-text {
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
line-height: 1.6;
color: #666;
white-space: pre-wrap;
}
}
.notification-data {
margin-top: 20px;
.data-label {
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
.data-text {
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
color: #666;
}
}
.notification-action {
margin-top: 20px;
text-align: right;
}
}
</style>

View File

@@ -0,0 +1,431 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { customStorage } from '../persist'
import systemApi from '@/api/system'
import { message } from 'ant-design-vue'
/**
* 通知类型枚举
*/
export const NotificationType = {
INFO: 'info', // 信息
SUCCESS: 'success', // 成功
WARNING: 'warning', // 警告
ERROR: 'error', // 错误
TASK: 'task', // 任务
SYSTEM: 'system' // 系统
}
/**
* 通知分类枚举
*/
export const NotificationCategory = {
SYSTEM: 'system', // 系统通知
TASK: 'task', // 任务通知
MESSAGE: 'message', // 消息通知
REMINDER: 'reminder', // 提醒通知
ANNOUNCEMENT: 'announcement' // 公告通知
}
/**
* 通知操作类型枚举
*/
export const NotificationActionType = {
NONE: 'none', // 无操作
LINK: 'link', // 跳转链接
MODAL: 'modal' // 打开弹窗
}
export const useNotificationStore = defineStore(
'notification',
() => {
// 通知列表
const notifications = ref([])
// 未读数量
const unreadCount = ref(0)
// 加载状态
const loading = ref(false)
// 当前页码
const currentPage = ref(1)
// 每页数量
const pageSize = ref(20)
// 总数量
const total = ref(0)
// 获取未读数量(计算属性)
const unreadCountComputed = computed(() => unreadCount.value)
// 获取已读数量
const readCount = computed(() => total.value - unreadCount.value)
// 获取通知列表
async function fetchNotifications(params = {}) {
try {
loading.value = true
const res = await systemApi.notifications.list.get({
page: params.page || currentPage.value,
page_size: params.page_size || pageSize.value,
...params
})
notifications.value = res.data.list || []
total.value = res.data.total || 0
currentPage.value = res.data.page || 1
pageSize.value = res.data.page_size || 20
return res.data
} catch (error) {
message.error(error.message || '获取通知列表失败')
throw error
} finally {
loading.value = false
}
}
// 获取未读通知列表
async function fetchUnreadNotifications(params = {}) {
try {
loading.value = true
const res = await systemApi.notifications.unread.get({
page: params.page || 1,
page_size: params.page_size || 10,
...params
})
return res.data
} catch (error) {
message.error(error.message || '获取未读通知失败')
throw error
} finally {
loading.value = false
}
}
// 获取未读数量
async function fetchUnreadCount() {
try {
const res = await systemApi.notifications.unreadCount.get()
unreadCount.value = res.data.count || 0
return unreadCount.value
} catch (error) {
console.error('获取未读数量失败:', error)
return 0
}
}
// 标记为已读
async function markAsRead(id) {
try {
await systemApi.notifications.markAsRead.post(id)
// 更新本地状态
const notification = notifications.value.find(n => n.id === id)
if (notification && !notification.is_read) {
notification.is_read = true
notification.read_at = new Date().toISOString()
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
return true
} catch (error) {
message.error(error.message || '标记已读失败')
throw error
}
}
// 批量标记为已读
async function batchMarkAsRead(ids) {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({ ids })
// 更新本地状态
ids.forEach(id => {
const notification = notifications.value.find(n => n.id === id)
if (notification && !notification.is_read) {
notification.is_read = true
notification.read_at = new Date().toISOString()
}
})
// 更新未读数量
unreadCount.value = Math.max(0, unreadCount.value - (res.data.count || ids.length))
return res.data
} catch (error) {
message.error(error.message || '批量标记已读失败')
throw error
}
}
// 标记全部为已读
async function markAllAsRead() {
try {
await systemApi.notifications.markAllAsRead.post()
// 更新本地状态
notifications.value.forEach(n => {
n.is_read = true
n.read_at = n.read_at || new Date().toISOString()
})
unreadCount.value = 0
return true
} catch (error) {
message.error(error.message || '标记全部已读失败')
throw error
}
}
// 删除通知
async function deleteNotification(id) {
try {
await systemApi.notifications.delete.delete(id)
// 更新本地状态
const index = notifications.value.findIndex(n => n.id === id)
if (index !== -1) {
const notification = notifications.value[index]
if (!notification.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
notifications.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
}
return true
} catch (error) {
message.error(error.message || '删除通知失败')
throw error
}
}
// 批量删除通知
async function batchDeleteNotification(ids) {
try {
const res = await systemApi.notifications.batchDelete.post({ ids })
// 更新本地状态
const deletedCount = 0
ids.forEach(id => {
const index = notifications.value.findIndex(n => n.id === id)
if (index !== -1) {
const notification = notifications.value[index]
if (!notification.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
notifications.value.splice(index, 1)
}
})
total.value = Math.max(0, total.value - ids.length)
return res.data
} catch (error) {
message.error(error.message || '批量删除通知失败')
throw error
}
}
// 清空已读通知
async function clearReadNotifications() {
try {
await systemApi.notifications.clearRead.post()
// 更新本地状态
notifications.value = notifications.value.filter(n => !n.is_read)
total.value = notifications.value.length
return true
} catch (error) {
message.error(error.message || '清空已读通知失败')
throw error
}
}
// 获取通知详情
async function getNotificationDetail(id) {
try {
const res = await systemApi.notifications.detail.get(id)
return res.data
} catch (error) {
message.error(error.message || '获取通知详情失败')
throw error
}
}
// 发送通知(管理员功能)
async function sendNotification(params) {
try {
const res = await systemApi.notifications.send.post(params)
message.success('发送通知成功')
return res.data
} catch (error) {
message.error(error.message || '发送通知失败')
throw error
}
}
// 重试发送失败的通知(管理员功能)
async function retryUnsentNotifications(params = {}) {
try {
const res = await systemApi.notifications.retryUnsent.post(params)
message.success('重试发送成功')
return res.data
} catch (error) {
message.error(error.message || '重试发送失败')
throw error
}
}
// 获取通知统计信息
async function fetchStatistics() {
try {
const res = await systemApi.notifications.statistics.get()
return res.data
} catch (error) {
message.error(error.message || '获取统计信息失败')
throw error
}
}
// 处理 WebSocket 消息
function handleWebSocketMessage(data) {
if (data.type === 'notification') {
const notification = data.data
// 添加到通知列表顶部
notifications.value.unshift(notification)
total.value++
// 如果是未读,增加未读数量
if (!notification.is_read) {
unreadCount.value++
}
// 限制本地存储的通知数量
if (notifications.value.length > 100) {
notifications.value = notifications.value.slice(0, 100)
}
// 显示通知提示
message.info(`新通知: ${notification.title}`)
}
}
// 格式化通知时间
function formatNotificationTime(timestamp) {
if (!timestamp) return '-'
const now = Date.now()
const diff = now - new Date(timestamp).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 {
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
}
// 获取通知类型文本
function getNotificationTypeText(type) {
const texts = {
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统'
}
return texts[type] || type
}
// 获取通知类型颜色
function getNotificationTypeColor(type) {
const colors = {
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan'
}
return colors[type] || 'default'
}
// 获取通知分类文本
function getNotificationCategoryText(category) {
const texts = {
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知'
}
return texts[category] || category
}
// 重置状态
function reset() {
notifications.value = []
unreadCount.value = 0
currentPage.value = 1
total.value = 0
loading.value = false
}
return {
notifications,
unreadCount,
loading,
currentPage,
pageSize,
total,
unreadCountComputed,
readCount,
fetchNotifications,
fetchUnreadNotifications,
fetchUnreadCount,
markAsRead,
batchMarkAsRead,
markAllAsRead,
deleteNotification,
batchDeleteNotification,
clearReadNotifications,
getNotificationDetail,
sendNotification,
retryUnsentNotifications,
fetchStatistics,
handleWebSocketMessage,
formatNotificationTime,
getNotificationTypeText,
getNotificationTypeColor,
getNotificationCategoryText,
reset,
NotificationType,
NotificationCategory,
NotificationActionType
}
},
{
persist: {
key: 'notification-store',
storage: customStorage,
pick: [] // 不自动持久化,通知数据从服务器获取
}
}
)