RuoYi
2026-04-14 7a4f61cf5e8d42d24907ccc3d7345d25012d3a55
通知公告新增阅读用户&详细
2 files added
9 files modified
644 ■■■■■ changed files
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysNoticeController.java 14 ●●●●● patch | view | raw | blame | history
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysNoticeReadMapper.java 12 ●●●●● patch | view | raw | blame | history
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysNoticeReadService.java 10 ●●●●● patch | view | raw | blame | history
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysNoticeService.java 2 ●●● patch | view | raw | blame | history
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysNoticeReadServiceImpl.java 12 ●●●●● patch | view | raw | blame | history
ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysNoticeReadMapper.xml 22 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/api/system/notice.js 9 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/layout/components/HeaderNotice/DetailView.vue 362 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/layout/components/HeaderNotice/index.vue 60 ●●●● patch | view | raw | blame | history
ruoyi-ui/src/views/system/notice/ReadUsers.vue 109 ●●●●● patch | view | raw | blame | history
ruoyi-ui/src/views/system/notice/index.vue 32 ●●●● patch | view | raw | blame | history
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/controller/SysNoticeController.java
@@ -54,7 +54,6 @@
    /**
     * 根据通知公告编号获取详细信息
     */
    @RequiresPermissions("system:notice:query")
    @GetMapping(value = "/{noticeId}")
    public AjaxResult getInfo(@PathVariable Long noticeId)
    {
@@ -126,6 +125,19 @@
    }
    /**
     * 已读用户列表数据
     */
    @RequiresPermissions("system:notice:list")
    @GetMapping("/readUsers/list")
    @ResponseBody
    public TableDataInfo readUsersList(Long noticeId, String searchValue)
    {
        startPage();
        List<?> list = noticeReadService.selectReadUsersByNoticeId(noticeId, searchValue);
        return getDataTable(list);
    }
    /**
     * 删除通知公告
     */
    @RequiresPermissions("system:notice:remove")
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysNoticeReadMapper.java
@@ -1,9 +1,10 @@
package com.ruoyi.system.mapper;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.SysNoticeRead;
import com.ruoyi.system.domain.SysNotice;
import com.ruoyi.system.domain.SysNoticeRead;
/**
 * 公告已读记录 数据层
@@ -56,6 +57,15 @@
    public List<SysNotice> selectNoticeListWithReadStatus(@Param("userId") Long userId, @Param("limit") int limit);
    /**
     * 查询已阅读某公告的用户列表
     *
     * @param noticeId 公告ID
     * @param searchValue 搜索值
     * @return 已读用户列表
     */
    public List<Map<String, Object>> selectReadUsersByNoticeId(@Param("noticeId") Long noticeId, @Param("searchValue") String searchValue);
    /**
     * 公告删除时清理对应已读记录
     *
     * @param noticeIds 公告ID数组
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysNoticeReadService.java
@@ -1,6 +1,7 @@
package com.ruoyi.system.service;
import java.util.List;
import java.util.Map;
import com.ruoyi.system.domain.SysNotice;
/**
@@ -44,6 +45,15 @@
    public void markReadBatch(Long userId, Long[] noticeIds);
    /**
     * 查询已阅读某公告的用户列表
     *
     * @param noticeId  公告ID
     * @param searchValue 搜索值
     * @return 已读用户列表
     */
    public List<Map<String, Object>> selectReadUsersByNoticeId(Long noticeId, String searchValue);
    /**
     * 删除公告时清理对应已读记录
     *
     * @param noticeIds 公告ID数组
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysNoticeService.java
@@ -49,7 +49,7 @@
     * @return 结果
     */
    public int deleteNoticeById(Long noticeId);
    /**
     * 批量删除公告信息
     * 
ruoyi-modules/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysNoticeReadServiceImpl.java
@@ -1,10 +1,11 @@
package com.ruoyi.system.service.impl;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.system.domain.SysNoticeRead;
import com.ruoyi.system.domain.SysNotice;
import com.ruoyi.system.domain.SysNoticeRead;
import com.ruoyi.system.mapper.SysNoticeReadMapper;
import com.ruoyi.system.service.ISysNoticeReadService;
@@ -63,6 +64,15 @@
    }
    /**
     * 查询已阅读某公告的用户列表
     */
    @Override
    public List<Map<String, Object>> selectReadUsersByNoticeId(Long noticeId, String searchValue)
    {
        return noticeReadMapper.selectReadUsersByNoticeId(noticeId, searchValue);
    }
    /**
     * 删除公告时清理对应已读记录
     */
    @Override
ruoyi-modules/ruoyi-system/src/main/resources/mapper/system/SysNoticeReadMapper.xml
@@ -63,4 +63,26 @@
        </foreach>
    </delete>
    <!-- 查询已阅读某公告的用户列表,支持按登录名/用户名模糊筛选 -->
    <select id="selectReadUsersByNoticeId" resultType="java.util.Map">
        select
            u.user_id      as userId,
            u.user_name    as userName,
            u.nick_name    as nickName,
            d.dept_name    as deptName,
            u.phonenumber  as phonenumber,
            r.read_time    as readTime
        from sys_notice_read r
        inner join sys_user u on u.user_id = r.user_id and u.del_flag = '0'
        left join sys_dept d on d.dept_id = u.dept_id
        where r.notice_id = #{noticeId}
        <if test="searchValue != null and searchValue != ''">
            and (
                u.user_name like concat('%', #{searchValue}, '%')
                or u.nick_name like concat('%', #{searchValue}, '%')
            )
        </if>
        order by r.read_time desc
    </select>
</mapper>
ruoyi-ui/src/api/system/notice.js
@@ -68,3 +68,12 @@
    params: { ids }
  })
}
// 查询公告已读用户列表
export function listNoticeReadUsers(query) {
  return request({
    url: '/system/notice/readUsers/list',
    method: 'get',
    params: query
  })
}
ruoyi-ui/src/layout/components/HeaderNotice/DetailView.vue
New 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>
ruoyi-ui/src/layout/components/HeaderNotice/index.vue
@@ -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 @@
        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() {
@@ -199,31 +177,5 @@
  flex-shrink: 0;
  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>
ruoyi-ui/src/views/system/notice/ReadUsers.vue
New 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>
ruoyi-ui/src/views/system/notice/index.vue
@@ -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 @@
        }
      })
    },
    /** 查看公告详情 */
    handleViewData(row) {
      this.$refs.noticeViewRef.open(row)
    },
    /** 查看已读用户 */
    handleReadUsers(row) {
      this.$refs.readUsersRef.open(row)
    },
    /** 删除按钮操作 */
    handleDelete(row) {
      const noticeIds = row.noticeId || this.ids