fastadmin实现站内通知功能
实现效果如下
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"><i
class="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'}"><a
href="#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> <span
class="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"><i
class="fa fa-list fa-fw"></i> <span>{$referermenu.title}</span> <span
class="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 = 2
updateNotifications();
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)
// 更新角标-未读数加 1
const 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
<?php
namespace 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一下。