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

@@ -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>