实现效果如下
application/admin/view/common/header.html
<style>#notificationMenu {display: none;position: absolute;top: 40px;right: 0;background: #fff;border-radius: 6px;padding: 10px 0;width: 300px;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);z-index: 1000;}#notificationItem {display: block !important;}#notificationIcon {display: inline-block;}#notificationMenu .menu li {padding: 12px 15px;border-bottom: 1px solid #f0f0f0;transition: background-color 0.3s ease;cursor: pointer;}#notificationMenu .menu li:hover {background-color: #f7f7f7;}#notificationMenu .menu {max-height: 250px;overflow-y: auto;}.badge {position: relative;top: -10px;right: -10px;}
</style>
<!-- Logo -->
<a href="javascript:;" class="logo"><!-- 迷你模式下Logo的大小为50X50 --><span class="logo-mini">{$site.name|mb_substr=0,4,'utf-8'|mb_strtoupper='utf-8'|htmlentities}</span><!-- 普通模式下Logo --><span class="logo-lg">{$site.name|htmlentities}</span>
</a><!-- 顶部通栏样式 -->
<nav class="navbar navbar-static-top"><!--第一级菜单--><div id="firstnav"><!-- 边栏切换按钮--><a href="#" class="sidebar-toggle" data-toggle="offcanvas" role="button"><span class="sr-only">{:__('Toggle navigation')}</span></a><!--如果不想在顶部显示角标,则给ul加上disable-top-badge类即可--><ul class="nav nav-tabs nav-addtabs disable-top-badge hidden-xs" role="tablist">{$navlist}</ul><div class="navbar-custom-menu"><ul class="nav navbar-nav"><!-- 通知图标 --><li id="notificationItem" class="dropdown notifications-menu" style="display: block;"><a href="#" id="notificationIcon"><i class="fa fa-bell"></i><span id="notificationBadge" class="badge badge-danger">{$notice|default=0}</span></a><ul id="notificationMenu" class="dropdown-menu"><li style="padding: 10px; display: flex; justify-content: space-between; align-items: center;"><a href="javascript:void(0);" id="doNotDisturbButton" style="display: flex; align-items: center;"><i class="fa fa-bell" id="doNotDisturbIcon" style="margin-right: 5px;"></i> 免打扰</a><a href="javascript:void(0);" id="markAllReadButton"><i class="fa fa-check-circle"></i> 一键已读</a></li><li><ul id="notificationList" class="menu"></ul></li></ul></li><!-- 多语言列表 -->{if $Think.config.lang_switch_on}<li class="hidden-xs"><a href="javascript:;" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-language"></i></a><ul class="dropdown-menu"><li class="{$config['language']=='zh-cn'?'active':''}"><a href="?ref=addtabs&lang=zh-cn">简体中文</a></li><li class="{$config['language']=='en'?'active':''}"><a href="?ref=addtabs&lang=en">English</a></li></ul></li>{/if}<!-- 账号信息下拉框 --><li class="dropdown user user-menu"><a href="#" class="dropdown-toggle" data-toggle="dropdown"><img src="{$admin.avatar|cdnurl|htmlentities}" class="user-image" alt=""><span class="hidden-xs">{$admin.nickname|htmlentities}</span></a><ul class="dropdown-menu"><!-- User image --><li class="user-header"><img src="{$admin.avatar|cdnurl|htmlentities}" class="img-circle" alt=""><p>{$admin.nickname|htmlentities}<small>{$admin.logintime|date="Y-m-d H:i:s",###}</small></p></li><li class="user-body"><div class="visible-xs"><div class="pull-left"><a href="__PUBLIC__" target="_blank"><i class="fa fa-home"style="font-size:14px;"></i>{:__('Home')}</a></div><div class="pull-right"><a href="javascript:;" data-type="all" class="wipecache"><iclass="fa fa-trash fa-fw"></i> {:__('Wipe all cache')}</a></div></div></li><!-- Menu Footer--><li class="user-footer"><div class="pull-left"><a href="general/profile" class="btn btn-primary addtabsit"><i class="fa fa-user"></i>{:__('Profile')}</a></div><div class="pull-right"><a href="{:url('index/logout')}" class="btn btn-danger"><i class="fa fa-sign-out"></i>{:__('Logout')}</a></div></li></ul></li><!-- 控制栏切换按钮 --><li class="hidden-xs"><a href="javascript:;" data-toggle="control-sidebar"><i class="fa fa-gears"></i></a></li></ul></div></div>{if $Think.config.fastadmin.multiplenav}<!--第二级菜单,只有在multiplenav开启时才显示--><div id="secondnav"><ul class="nav nav-tabs nav-addtabs disable-top-badge" role="tablist">{if $fixedmenu}<li role="presentation" id="tab_{$fixedmenu.id}" class="{:$referermenu?'':'active'}"><ahref="#con_{$fixedmenu.id}" node-id="{$fixedmenu.id}" aria-controls="{$fixedmenu.id}" role="tab"data-toggle="tab"><i class="fa fa-dashboard fa-fw"></i> <span>{$fixedmenu.title}</span> <spanclass="pull-right-container"> </span></a></li>{/if}{if $referermenu}<li role="presentation" id="tab_{$referermenu.id}" class="active"><a href="#con_{$referermenu.id}"node-id="{$referermenu.id}"aria-controls="{$referermenu.id}"role="tab" data-toggle="tab"><iclass="fa fa-list fa-fw"></i> <span>{$referermenu.title}</span> <spanclass="pull-right-container"> </span></a> <i class="close-tab fa fa-remove"></i></li>{/if}</ul></div>{/if}
</nav><script>function initNotifications() {const notificationIcon = document.getElementById("notificationIcon");const notificationMenu = document.getElementById("notificationMenu");const notificationList = document.getElementById("notificationList");const notificationBadge = document.getElementById("notificationBadge");const doNotDisturbButton = document.getElementById("doNotDisturbButton");const doNotDisturbIcon = document.getElementById("doNotDisturbIcon");let notifications = []; // 初始通知列表let doNotDisturb = localStorage.getItem("doNotDisturb") === "true";// 更新免打扰图标状态function updateDoNotDisturbIcon() {doNotDisturbIcon.className = doNotDisturb ? "fa fa-bell-slash" : "fa fa-bell";// 同步主通知图标样式const notificationIconBell = document.querySelector("#notificationIcon i");if (notificationIconBell) {notificationIconBell.className = doNotDisturb ? "fa fa-bell-slash" : "fa fa-bell";}}// 切换免打扰模式doNotDisturbButton.addEventListener("click", () => {doNotDisturb = !doNotDisturb;localStorage.setItem("doNotDisturb", doNotDisturb);updateDoNotDisturbIcon();// 同步主通知图标样式const notificationIconBell = document.querySelector("#notificationIcon i");if (notificationIconBell) {notificationIconBell.className = doNotDisturb ? "fa fa-bell-slash" : "fa fa-bell";}Toastr.info(doNotDisturb ? "已开启免打扰模式" : "已关闭免打扰模式");});// 初始化免打扰图标updateDoNotDisturbIcon();// 一键已读功能document.getElementById("markAllReadButton").addEventListener("click", markAllAsRead);//全部已读function markAllAsRead() {$.ajax({url: "notice/markAllAsRead",method: "POST",success: function (data) {if (data.code === 1) {notifications.forEach((notif) => (notif.read = 2)); // 更新本地状态updateNotifications();Toastr.success("全部已读");} else {Toastr.error(data.msg || "一键已读失败");}},error: function () {Toastr.error("网络错误,无法一键已读");},});}// 获取通知列表function fetchNotifications() {$.ajax({url: "notice/noticelist",method: "GET",success: function (response) {if (response.code === 1 && Array.isArray(response.data)) {notifications = response.data.map((item) => ({id: item.id,text: item.title,read: item.read,}));updateNotifications();} else {Toastr.error(response.msg || "获取通知失败");}},error: function () {Toastr.error("网络错误,无法获取通知");},});}// 更新通知列表和未读数function updateNotifications() {const fragment = document.createDocumentFragment();let unreadCount = 0;notificationList.innerHTML = "";notifications.forEach((notif) => {const li = document.createElement("li");li.textContent = notif.text;li.style.opacity = notif.read > 1 ? "0.5" : "1";if (notif.read == 1) unreadCount++;li.addEventListener("click", () => {notif.read = 2updateNotifications();markAsRead(notif.id); // 标记为已读});fragment.appendChild(li);});notificationList.appendChild(fragment);notificationBadge.textContent = unreadCount;}// 单个标记为已读function markAsRead(id) {$.ajax({url: "notice/markAsRead",method: "GET",data: { id: id },success: function (data) {if (data.code !== 1) {console.error(data.msg || "标记已读失败");}},error: function () {Toastr.error("网络错误,无法标记为已读");},});}// 点击通知图标时notificationIcon.addEventListener("click", () => {notificationMenu.classList.toggle("show");if (notificationMenu.classList.contains("show")) {fetchNotifications();}});// 点击外部关闭菜单document.addEventListener("click", (e) => {if (!notificationMenu.contains(e.target) && e.target !== notificationIcon) {notificationMenu.classList.remove("show");}});}// 等待 DOM 加载完成后运行document.addEventListener("DOMContentLoaded", initNotifications);
</script>
public/assets/js/backend/index.js
var connectWebSocket = function () {var ws = new WebSocket(Config.socket_url);ws.onopen = function () {console.log("WebSocket连接已建立");};ws.onmessage = function (event) {var message = JSON.parse(event.data);//处理消息通知if (message && message.type === "ping") {// console.log(message)let doNotDisturb = localStorage.getItem("doNotDisturb");if (!doNotDisturb||doNotDisturb === 'false') {//判断是否设置免打扰模式Toastr.info(message.content || "您有一条新消息");}console.log(doNotDisturb)// 更新角标-未读数加 1const badgeElement = document.getElementById("notificationBadge");badgeElement.textContent = parseInt(badgeElement.textContent || 0) + 1;}// 处理 "init" 消息类型并发送 AJAX 请求else if (message && message.type === "init") {//Toastr.success(message.content || "Socket 链接成功");// 发送 AJAX 请求到 admin/index/bind_admin 接口,传递 client_id$.ajax({url: 'index/bind_admin',type: 'POST',data: {client_id: message.client_id},dataType: 'json',success: function (response) {if (response.code === 1) {Toastr.info(response.content || "Socket 绑定成功");} else {Toastr.error(response.content || "Socket 绑定失败");}},error: function () {Toastr.error("网络错误,绑定失败");}});}};ws.onclose = function () {console.log("WebSocket连接已关闭,正在重连...");setTimeout(connectWebSocket, 10000); // 延迟10秒自动重连};ws.onerror = function () {Toastr.error("socket链接失败");};return ws;};// 初始化WebSocket连接var ws = connectWebSocket();
application/admin/controller/Notice.php
<?phpnamespace app\admin\controller;use app\common\controller\Backend;/*** 消息通知管理** @icon fa fa-circle-o*/
class Notice extends Backend
{/*** Notice模型对象** @var \app\admin\model\Notice*/protected $model = null;public function _initialize(){parent::_initialize();$this->model = new \app\admin\model\Notice;$this->view->assign("roleList", $this->model->getRoleList());$this->view->assign("readList", $this->model->getReadList());}//消息通知列表public function noticelist(){$notifications = $this->model->where(['role' => 1, 'uid' => $this->auth->id])->order('id desc')->select();$this->success('获取成功', '', $notifications);}//标记已读public function markAsRead(){$id = input('id');if (!$id) {$this->error('参数错误');}$info = $this->model->where(['id' => $id])->find();if (!$info) {$this->error('数据读取失败');}$info->save(['read' => 2]);$this->success('已标记为已读');}//全部已读public function markAllAsRead(){$uid = $this->auth->id;$sql = $this->model->where(['role' => 1, 'uid' => $uid])->update(['read' => 2]);if (!$sql){$this->error('无数据更新');}$this->success('已标记为已读');}
}
musql表结构如下
CREATE TABLE `fa_notice` (`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,`title` VARCHAR(255) NULL DEFAULT '0' COMMENT '消息标题' COLLATE 'utf8_general_ci',`content` TEXT NULL DEFAULT NULL COMMENT '消息内容' COLLATE 'utf8_general_ci',`uid` INT(11) NULL DEFAULT '0' COMMENT '收消息方用户id',`role` ENUM('1','2') NOT NULL COMMENT '推送:1=向后台推送,2=向用户推送' COLLATE 'utf8_general_ci',`read` ENUM('1','2') NULL DEFAULT '1' COMMENT '查看状态:1=未读,2=已读' COLLATE 'utf8_general_ci',`createtime` BIGINT(16) NULL DEFAULT NULL,`updatetime` BIGINT(16) NULL DEFAULT NULL,PRIMARY KEY (`id`) USING BTREE
)
COMMENT='消息通知表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB
AUTO_INCREMENT=5
;
index.php映射
//获取websocket链接
$this->assignconfig('socket_url', Config::get('websocket.url')?Config::get('websocket.url'):'ws://127.0.0.1:8282');//js中Config.socket_url就可以读取值
config.php
//配置websocket地址'websocket' => ['url' => 'ws://127.0.0.1:8282',//本地测试],
Backend.php 映射未读消息数量
//渲染消息总数量--判断是否登录if ($this->auth->id){$notice_num = Db::name('notice')->where(['role'=>1,'read'=>1,'uid'=>$this->auth->id])->count();$this->assign('notice', $notice_num);}
整体实现效果逻辑
映射页面时,先查询统计消息表未读消息,让角标加载后显示未读消息数量
index.js连接websocket,实现角标未读数值更新,如果有新推送消息,角标数字+1,并弹出新消息提示框(如果设置了免打扰,免打扰的值使用localStorage.setItem
存储,判断有没有该值进行是否弹框提醒。免打扰下只更新角标)
点击icon访问列表接口,渲染出未读消息,点击消息实现消息更新已读状态。点击一键已读,把当前用户的消息update一下。