通知公告新增阅读用户&详细

This commit is contained in:
RuoYi
2026-04-14 16:43:50 +08:00
parent 61f9792395
commit 7a4f61cf5e
11 changed files with 580 additions and 64 deletions

View File

@@ -68,3 +68,12 @@ export function markNoticeReadAll(ids) {
params: { ids }
})
}
// 查询公告已读用户列表
export function listNoticeReadUsers(query) {
return request({
url: '/system/notice/readUsers/list',
method: 'get',
params: query
})
}

View File

@@ -0,0 +1,362 @@
<template>
<el-drawer title="公告详情" :visible.sync="visible" direction="rtl" size="50%" append-to-body :before-close="handleClose" custom-class="notice-detail-drawer">
<div v-loading="loading" class="notice-detail-drawer__body">
<div v-if="!detail" class="notice-empty">
<i class="el-icon-document"></i>
<span>暂无数据</span>
</div>
<div v-else class="notice-page">
<div class="notice-type-wrap">
<span v-if="detail.noticeType === '1'" class="notice-type-tag type-notify">
<i class="el-icon-bell"></i> 通知
</span>
<span v-else-if="detail.noticeType === '2'" class="notice-type-tag type-announce">
<i class="el-icon-message"></i> 公告
</span>
<span v-else class="notice-type-tag type-notify">
<i class="el-icon-document"></i> 消息
</span>
</div>
<h1 class="notice-title">{{ detail.noticeTitle }}</h1>
<div class="notice-meta">
<span class="meta-item">
<i class="el-icon-user"></i>
<span>{{ detail.createBy || '—' }}</span>
</span>
<span class="meta-item">
<i class="el-icon-time"></i>
<span>{{ detail.createTime || '—' }}</span>
</span>
<span class="meta-item">
<span :class="['status-dot', isStatusNormal ? 'status-ok' : 'status-off']"></span>
<span>{{ isStatusNormal ? '正常' : '已关闭' }}</span>
</span>
</div>
<div class="notice-divider">
<span class="notice-divider-dot"></span>
<span class="notice-divider-dot"></span>
<span class="notice-divider-dot"></span>
</div>
<div class="notice-body">
<div v-if="hasContent" class="notice-content" v-html="detail.noticeContent" />
<div v-else class="notice-empty notice-empty--inner">
<i class="el-icon-document"></i> 暂无内容
</div>
</div>
</div>
</div>
</el-drawer>
</template>
<script>
import { getNotice } from '@/api/system/notice'
export default {
name: 'NoticeDetailView',
data() {
return {
visible: false,
loading: false,
detail: null
}
},
computed: {
isStatusNormal() {
const s = this.detail && this.detail.status
return s === '0' || s === 0
},
hasContent() {
const c = this.detail && this.detail.noticeContent
return c != null && String(c).trim() !== ''
}
},
methods: {
open(payload) {
let id = null
let preset = null
if (payload != null && typeof payload === 'object') {
id = payload.noticeId
if (payload.noticeContent != null) {
preset = payload
}
} else {
id = payload
}
this.visible = true
if (preset) {
this.detail = preset
return
}
if (id == null || id === '') {
this.detail = null
return
}
this.loading = true
this.detail = null
getNotice(id).then(res => {
this.detail = res.data
}).catch(() => {
this.detail = null
}).finally(() => {
this.loading = false
})
},
handleClose() {
this.visible = false
this.detail = null
this.loading = false
}
}
}
</script>
<style lang="scss" scoped>
.notice-page {
max-width: 760px;
margin: 0 auto;
padding: 8px 8px 20px;
animation: notice-fade-up 0.28s ease both;
}
@keyframes notice-fade-up {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.notice-type-tag {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 12px;
border-radius: 2px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 14px;
}
.type-notify {
background: #fff8e6;
color: #b7791f;
border-left: 3px solid #d97706;
}
.type-announce {
background: #e8f5e9;
color: #276749;
border-left: 3px solid #38a169;
}
.notice-title {
font-size: 22px;
font-weight: 700;
color: #1a202c;
line-height: 1.45;
margin: 0 0 16px;
letter-spacing: -0.2px;
}
.notice-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 12px 0;
border-top: 1px solid #e9ecef;
border-bottom: 1px solid #e9ecef;
margin-bottom: 28px;
}
.meta-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: #718096;
}
.meta-item i {
font-size: 12px;
color: #a0aec0;
}
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 4px;
}
.status-ok {
background: #38a169;
}
.status-off {
background: #e53e3e;
}
.notice-divider {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.notice-divider::before,
.notice-divider::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, #dee2e6, transparent);
}
.notice-divider-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #cbd5e0;
}
.notice-body {
background: #fff;
border-radius: 6px;
padding: 28px 32px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.04);
min-height: 120px;
}
.notice-content {
font-size: 14px;
line-height: 1.85;
color: #2d3748;
word-break: break-word;
}
.notice-content ::v-deep p {
margin: 0 0 1em;
}
.notice-content ::v-deep h1,
.notice-content ::v-deep h2,
.notice-content ::v-deep h3 {
font-weight: 700;
color: #1a202c;
margin: 1.4em 0 0.6em;
}
.notice-content ::v-deep h1 {
font-size: 18px;
}
.notice-content ::v-deep h2 {
font-size: 16px;
}
.notice-content ::v-deep h3 {
font-size: 14px;
}
.notice-content ::v-deep a {
color: #3182ce;
text-decoration: underline;
}
.notice-content ::v-deep a:hover {
color: #2b6cb0;
}
.notice-content ::v-deep img {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
.notice-content ::v-deep ul,
.notice-content ::v-deep ol {
padding-left: 20px;
margin: 0 0 1em;
}
.notice-content ::v-deep li {
margin-bottom: 4px;
}
.notice-content ::v-deep blockquote {
border-left: 3px solid #cbd5e0;
margin: 1em 0;
padding: 6px 16px;
color: #718096;
background: #f7fafc;
}
.notice-content ::v-deep table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: 13px;
}
.notice-content ::v-deep table th,
.notice-content ::v-deep table td {
border: 1px solid #e2e8f0;
padding: 7px 12px;
}
.notice-content ::v-deep table th {
background: #f7fafc;
font-weight: 600;
}
.notice-empty {
text-align: center;
padding: 40px 0;
color: #a0aec0;
font-size: 13px;
}
.notice-empty i {
font-size: 28px;
display: block;
margin-bottom: 10px;
}
.notice-empty--inner {
padding: 32px 0;
}
.notice-empty--inner i {
font-size: 28px;
}
::v-deep .notice-detail-drawer {
.el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.el-drawer__body {
background: #f5f6f8;
}
}
.notice-detail-drawer__body {
height: 100%;
overflow: auto;
padding: 10px 16px 22px;
}
</style>

View File

@@ -23,38 +23,24 @@
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount }}</span>
</div>
<el-dialog :title="previewTitle" :visible.sync="previewVisible" width="680px" append-to-body custom-class="notice-preview-dialog">
<div class="notice-preview-meta">
<el-tag size="small" :type="previewNoticeType === '1' ? 'warning' : 'success'">
{{ previewNoticeType === '1' ? '通知' : '公告' }}
</el-tag>
<span class="notice-preview-info"><i class="el-icon-user"></i> {{ previewCreateBy }}</span>
<span class="notice-preview-info"><i class="el-icon-time"></i> {{ previewCreateTime }}</span>
</div>
<div class="notice-preview-divider"></div>
<div class="notice-preview-content" v-html="previewContent"></div>
</el-dialog>
<notice-detail-view ref="noticeViewRef" />
</div>
</template>
<script>
import { listNoticeTop, markNoticeRead, markNoticeReadAll, getNotice } from '@/api/system/notice'
import NoticeDetailView from './DetailView'
import { listNoticeTop, markNoticeRead, markNoticeReadAll } from '@/api/system/notice'
export default {
name: 'HeaderNotice',
components: { NoticeDetailView },
data() {
return {
noticeList: [], // 通知列表
unreadCount: 0, // 未读数量
noticeLoading: false, // 加载状态
noticeVisible: false, // 弹出层显示状态
noticeLeaveTimer: null, // 鼠标离开计时器
previewVisible: false, // 预览弹窗显示状态
previewTitle: '', // 预览弹窗标题
previewContent: '', // 预览弹窗内容
previewNoticeType: '', // 预览弹窗类型
previewCreateBy: '', // 预览弹窗创建人
previewCreateTime: '' // 预览弹窗创建时间
noticeLeaveTimer: null // 鼠标离开计时器
}
},
mounted() {
@@ -99,15 +85,7 @@ export default {
if (idx !== -1) this.$set(this.noticeList, idx, { ...item, isRead: true })
this.unreadCount = Math.max(0, this.unreadCount - 1)
}
getNotice(item.noticeId).then(res => {
const notice = res.data
this.previewTitle = notice.noticeTitle
this.previewContent = notice.noticeContent
this.previewNoticeType = notice.noticeType
this.previewCreateBy = notice.createBy
this.previewCreateTime = notice.createTime
this.previewVisible = true
})
this.$refs.noticeViewRef.open(item.noticeId)
},
// 全部已读
markAllRead() {
@@ -200,30 +178,4 @@ export default {
font-size: 11px;
color: #bbb;
}
::v-deep .notice-preview-dialog {
.el-dialog__body { padding: 0 20px 20px; }
.notice-preview-meta {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 0;
font-size: 12px;
color: #888;
.notice-preview-info { display: flex; align-items: center; gap: 4px; }
}
.notice-preview-divider {
height: 1px;
background: linear-gradient(to right, transparent, #e2e8f0, transparent);
margin-bottom: 16px;
}
.notice-preview-content {
font-size: 14px;
line-height: 1.85;
color: #2d3748;
word-break: break-word;
img { max-width: 100%; border-radius: 4px; }
p { margin: 0 0 1em; }
a { color: #409EFF; text-decoration: underline; }
}
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<el-dialog :title="`「${noticeTitle}」已读用户`" :visible.sync="visible" width="760px" top="6vh" append-to-body @close="handleClose">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" style="margin-bottom: 4px;">
<el-form-item prop="searchValue">
<el-input
v-model="queryParams.searchValue"
placeholder="登录名称 / 用户名称"
clearable
prefix-icon="el-icon-search"
style="width: 220px;"
@keyup.enter.native="handleQuery"
@clear="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
<el-form-item style="float: right; margin-right: 0;">
<span class="read-stat">
<strong>{{ total }}</strong> 人已读
</span>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="userList" size="small" stripe height="340px">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column label="登录名称" prop="userName" align="center" :show-overflow-tooltip="true" />
<el-table-column label="用户名称" prop="nickName" align="center" :show-overflow-tooltip="true" />
<el-table-column label="所属部门" prop="deptName" align="center" :show-overflow-tooltip="true" />
<el-table-column label="手机号码" prop="phonenumber" align="center" width="120" />
<el-table-column label="阅读时间" prop="readTime" align="center" width="160">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.readTime) }}</span>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" style="padding: 6px 0px;"/>
</el-dialog>
</template>
<script>
import { listNoticeReadUsers } from "@/api/system/notice"
export default {
name: "ReadUsers",
data() {
return {
visible: false,
loading: false,
noticeId: undefined,
noticeTitle: "",
total: 0,
userList: [],
queryParams: {
pageNum: 1,
pageSize: 10,
noticeId: undefined,
searchValue: undefined
}
}
},
methods: {
open(row) {
this.noticeId = row.noticeId
this.noticeTitle = row.noticeTitle
this.queryParams.noticeId = row.noticeId
this.queryParams.searchValue = undefined
this.queryParams.pageNum = 1
this.visible = true
this.getList()
},
getList() {
this.loading = true
listNoticeReadUsers(this.queryParams).then(res => {
this.userList = res.rows
this.total = res.total
}).finally(() => {
this.loading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.resetForm("queryForm")
this.handleQuery()
},
handleClose() {
this.userList = []
this.total = 0
this.queryParams.searchValue = undefined
}
}
}
</script>
<style scoped>
.read-stat {
font-size: 13px;
color: #606266;
line-height: 28px;
}
.read-stat strong {
color: #409eff;
font-size: 15px;
margin: 0 2px;
}
</style>

View File

@@ -72,12 +72,11 @@
<el-table v-loading="loading" :data="noticeList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="序号" align="center" prop="noticeId" width="100" />
<el-table-column
label="公告标题"
align="center"
prop="noticeTitle"
:show-overflow-tooltip="true"
/>
<el-table-column label="公告标题" align="center" :show-overflow-tooltip="true">
<template slot-scope="scope">
<a class="link-type" style="cursor:pointer" @click="handleViewData(scope.row)">{{ scope.row.noticeTitle }}</a>
</template>
</el-table-column>
<el-table-column label="公告类型" align="center" prop="noticeType" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_notice_type" :value="scope.row.noticeType"/>
@@ -96,6 +95,13 @@
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-user"
@click="handleReadUsers(scope.row)"
v-hasPermi="['system:notice:list']"
>阅读用户</el-button>
<el-button
size="mini"
type="text"
@@ -166,14 +172,20 @@
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
<notice-detail-view ref="noticeViewRef" />
<read-users-dialog ref="readUsersRef" />
</div>
</template>
<script>
import NoticeDetailView from "@/layout/components/HeaderNotice/DetailView"
import ReadUsersDialog from "./ReadUsers"
import { listNotice, getNotice, delNotice, addNotice, updateNotice } from "@/api/system/notice"
export default {
name: "Notice",
components: { NoticeDetailView, ReadUsersDialog },
dicts: ['sys_notice_status', 'sys_notice_type'],
data() {
return {
@@ -297,6 +309,14 @@ export default {
}
})
},
/** 查看公告详情 */
handleViewData(row) {
this.$refs.noticeViewRef.open(row)
},
/** 查看已读用户 */
handleReadUsers(row) {
this.$refs.readUsersRef.open(row)
},
/** 删除按钮操作 */
handleDelete(row) {
const noticeIds = row.noticeId || this.ids