更新
This commit is contained in:
643
resources/admin/src/pages/system/notifications/index.vue
Normal file
643
resources/admin/src/pages/system/notifications/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user