From c7598123089fbe28888f7a10c678717a2362ba1c Mon Sep 17 00:00:00 2001 From: augushong Date: Mon, 12 Oct 2026 12:09:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(notice):=20=E6=88=91=E7=9A=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=B8=8E=E8=AE=A2=E9=98=85=E8=AE=BE=E7=BD=AE=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E5=9F=9F=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/Notice.php | 14 + extend/base/admin/controller/NoticeBase.php | 267 +++++++++++++++++++ extend/base/admin/view/notice/_common.js | 11 + extend/base/admin/view/notice/mine.html | 7 + extend/base/admin/view/notice/mine.js | 99 +++++++ extend/base/admin/view/notice/read.html | 39 +++ extend/base/admin/view/notice/read.js | 16 ++ extend/base/admin/view/notice/subscribe.html | 52 ++++ extend/base/admin/view/notice/subscribe.js | 16 ++ 9 files changed, 521 insertions(+) create mode 100644 app/admin/controller/Notice.php create mode 100644 extend/base/admin/controller/NoticeBase.php create mode 100644 extend/base/admin/view/notice/_common.js create mode 100644 extend/base/admin/view/notice/mine.html create mode 100644 extend/base/admin/view/notice/mine.js create mode 100644 extend/base/admin/view/notice/read.html create mode 100644 extend/base/admin/view/notice/read.js create mode 100644 extend/base/admin/view/notice/subscribe.html create mode 100644 extend/base/admin/view/notice/subscribe.js diff --git a/app/admin/controller/Notice.php b/app/admin/controller/Notice.php new file mode 100644 index 0000000..f76186f --- /dev/null +++ b/app/admin/controller/Notice.php @@ -0,0 +1,14 @@ + '站内信', + NoticeService::CHANNEL_EMAIL => '邮箱', + ]; + + /** + * @param App $app + */ + public function __construct(App $app) + { + parent::__construct($app); + + $this->model = new SystemNoticeTarget(); + + // 场景中文映射(mine 列表场景下拉/展示用) + $sceneOptions = []; + foreach (NoticeService::scenes() as $scene => $info) { + $sceneOptions[$scene] = $info['title']; + } + $this->assign('select_list_notice_scene', $sceneOptions, true); + } + + /** + * 我的站内信列表. + * @NodeAnotation(title="我的消息", auth=false) + */ + public function mine() + { + if ($this->request->isAjax()) { + if (input('selectFields')) { + return $this->selectList(); + } + list($page, $limit, $where) = $this->buildTableParames(); + $adminId = (int) $this->getAdminId(); + $count = $this->model + ->withJoin('notice', 'LEFT') + ->where('system_notice_target.admin_id', $adminId) + ->where('system_notice_target.channel', NoticeService::CHANNEL_INMAIL) + ->where($where) + ->count(); + $list = $this->model + ->withJoin('notice', 'LEFT') + ->where('system_notice_target.admin_id', $adminId) + ->where('system_notice_target.channel', NoticeService::CHANNEL_INMAIL) + ->where($where) + ->page($page, $limit) + ->order($this->sort) + ->select(); + $data = [ + 'code' => 0, + 'msg' => '', + 'count' => $count, + 'data' => $list, + ]; + + return json($data); + } + + return $this->fetch(); + } + + /** + * 消息详情(进入即自动标记已读;先校验归属再标记,防越权). + * @NodeAnotation(title="消息详情", auth=false) + */ + public function read($id) + { + $adminId = (int) $this->getAdminId(); + $row = $this->model + ->withJoin('notice', 'LEFT') + ->where('system_notice_target.id', (int) $id) + ->where('system_notice_target.admin_id', $adminId) + ->find(); + if (empty($row)) { + $this->error('消息不存在或无权查看'); + } + + if (empty($row->read_time)) { + NoticeService::markRead([(int) $id], $adminId); + $row->read_time = time(); + } + + // 提取平铺变量供模板渲染(notice 关联模型直接取值,规避模板链式访问歧义) + $notice = $row->notice; + $sceneTitleMap = []; + foreach (NoticeService::scenes() as $scene => $info) { + $sceneTitleMap[$scene] = $info['title']; + } + $msg = [ + 'id' => (int) $row->id, + 'title' => (string) ($notice->title ?? '(通知已删除)'), + 'scene_title' => $sceneTitleMap[(string) ($notice->scene ?? '')] ?? (string) ($notice->scene ?? '-'), + 'content' => (string) ($notice->content ?? ''), + 'read_time' => (int) $row->read_time, + 'create_time' => (int) $row->create_time, + ]; + $this->assign('msg', $msg); + + return $this->fetch(); + } + + /** + * 标记已读(批量). + * @NodeAnotation(title="标记已读", auth=false) + */ + public function markRead() + { + $this->checkPostRequest(); + $targetIds = $this->request->post('target_ids'); + if (empty($targetIds)) { + // 表格勾选批量提交(data-request+checkbox)的默认字段名是 id + $targetIds = $this->request->post('id'); + } + $targetIds = is_array($targetIds) ? $targetIds : explode(',', (string) $targetIds); + $targetIds = array_values(array_filter(array_map('intval', $targetIds))); + + $count = NoticeService::markRead($targetIds, (int) $this->getAdminId()); + + $this->success('已标记 ' . $count . ' 条消息为已读'); + } + + /** + * 全部站内信标记已读. + * @NodeAnotation(title="全部标记已读", auth=false) + */ + public function markAllRead() + { + $this->checkPostRequest(); + NoticeService::markAllRead((int) $this->getAdminId()); + + $this->success('已全部标记为已读'); + } + + /** + * 未读站内信数(顶栏铃铛轮询接口). + * @NodeAnotation(title="未读消息数", auth=false) + */ + public function unreadCount() + { + $count = NoticeService::unreadCount((int) $this->getAdminId()); + + $this->success(null, ['count' => $count]); + } + + /** + * 订阅设置页(场景×渠道矩阵,纯渲染不写库). + * + * 无记录=默认订阅;locked 场景恒选中且置灰(必选)。 + * @NodeAnotation(title="订阅设置", auth=false) + */ + public function subscribe() + { + $adminId = (int) $this->getAdminId(); + + $rows = Db::name('system_notice_subscribe') + ->where('admin_id', $adminId) + ->select(); + $subscribeMap = []; + foreach ($rows as $row) { + $subscribeMap[$row['scene'] . '|' . $row['channel']] = (int) $row['status']; + } + + $matrix = []; + foreach (NoticeService::scenes() as $scene => $info) { + $locked = !empty($info['locked']); + $cells = []; + foreach (array_keys(self::SUBSCRIBE_CHANNELS) as $channel) { + $checked = $locked || ($subscribeMap[$scene . '|' . $channel] ?? 1) === 1; + $cells[] = [ + 'name' => 'matrix[' . $scene . '][' . $channel . ']', + 'checked' => $checked ? 'checked' : '', + 'disabled' => $locked ? 'disabled' : '', + ]; + } + $matrix[] = [ + 'scene' => $scene, + 'title' => $info['title'], + 'locked' => $locked, + 'cells' => $cells, + ]; + } + + $this->assign('matrix', $matrix); + $this->assign('channels', self::SUBSCRIBE_CHANNELS); + + return $this->fetch(); + } + + /** + * 保存订阅设置(POST matrix:逐场景×渠道 upsert). + * + * locked 场景直接忽略不入库(不可关闭语义,不报错); + * 仅写当前会话管理员的订阅记录。 + * @NodeAnotation(title="保存订阅设置", auth=false) + */ + public function saveSubscribe() + { + $this->checkPostRequest(); + $adminId = (int) $this->getAdminId(); + $matrix = (array) $this->request->post('matrix'); + if ($matrix === []) { + $this->error('没有需要保存的订阅设置'); + } + + $scenes = NoticeService::scenes(); + $now = time(); + + foreach ($matrix as $scene => $channels) { + // 仅注册场景可保存;locked 场景忽略(不写任何记录) + if (!isset($scenes[$scene]) || !empty($scenes[$scene]['locked'])) { + continue; + } + foreach ((array) $channels as $channel => $value) { + if (!isset(self::SUBSCRIBE_CHANNELS[$channel])) { + continue; + } + $status = ((int) $value === 1) ? 1 : 0; + $row = Db::name('system_notice_subscribe') + ->where('admin_id', $adminId) + ->where('scene', (string) $scene) + ->where('channel', (string) $channel) + ->find(); + if ($row) { + Db::name('system_notice_subscribe') + ->where('id', $row['id']) + ->update(['status' => $status, 'update_time' => $now]); + } else { + Db::name('system_notice_subscribe')->insert([ + 'admin_id' => $adminId, + 'scene' => (string) $scene, + 'channel' => (string) $channel, + 'status' => $status, + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } + } + + $this->success('订阅设置已保存'); + } +} diff --git a/extend/base/admin/view/notice/_common.js b/extend/base/admin/view/notice/_common.js new file mode 100644 index 0000000..c0e66ef --- /dev/null +++ b/extend/base/admin/view/notice/_common.js @@ -0,0 +1,11 @@ +var init = { + tableElem: '#currentTable', + tableRenderId: 'currentTableRenderId', + indexUrl: 'notice/mine', + addUrl: '', + editUrl: '', + readUrl: 'notice/read', + deleteUrl: '', + exportUrl: '', + modifyUrl: '', +}; diff --git a/extend/base/admin/view/notice/mine.html b/extend/base/admin/view/notice/mine.html new file mode 100644 index 0000000..9475165 --- /dev/null +++ b/extend/base/admin/view/notice/mine.html @@ -0,0 +1,7 @@ +
+
+ +
+
+
diff --git a/extend/base/admin/view/notice/mine.js b/extend/base/admin/view/notice/mine.js new file mode 100644 index 0000000..02dff6b --- /dev/null +++ b/extend/base/admin/view/notice/mine.js @@ -0,0 +1,99 @@ +$(function(){ + // HTML 转义(标题/场景等来自发送方输入,防注入) + var escapeHtml = function(text) { + return String(text).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); + }; + + // 标题:关联 notice,通知主体被删时兜底;未读加粗强调 + var titleTemplet = function(d) { + var notice = d.notice || {}; + if (notice.title) { + var title = escapeHtml(notice.title); + if (d.read_time == 0) { + return '' + title + ''; + } + return title; + } + return '(通知已删除)#' + d.notice_id + ''; + }; + + // 场景中文映射 + var sceneTemplet = function(d) { + var notice = d.notice || {}; + var map = ua.getDataBrage('select_list_notice_scene') || {}; + var text = map[notice.scene] || notice.scene || '-'; + return escapeHtml(text); + }; + + // 未读/已读徽章(未读行同时以加粗标题高亮) + var readTemplet = function(d) { + if (d.read_time == 0) { + return '未读'; + } + return '已读'; + }; + + ua.table.render({ + init: init, + toolbar: [ + 'refresh', + [{ + text: '标记已读', + title: '确定将勾选的消息标记为已读?', + url: 'notice/mark_read', + method: 'request', + auth: 'notice/markRead', + class: 'layui-btn layui-btn-normal layui-btn-sm', + icon: 'fa fa-check', + checkbox: true, + }], + [{ + text: '全部已读', + title: '确定将全部站内信标记为已读?', + url: 'notice/mark_all_read', + method: 'request', + auth: 'notice/markAllRead', + class: 'layui-btn layui-btn-warm layui-btn-sm', + icon: 'fa fa-check-square-o', + }], + ], + cols: [[ + {type: 'checkbox'}, + {field: 'id', title: 'ID', width: 80, search: false}, + {field: 'title', title: '标题', minWidth: 220, fieldAlias: 'notice.title', templet: titleTemplet}, + {field: 'scene', title: '场景', width: 110, align: 'center', search: 'select', searchOp: '=', selectList: ua.getDataBrage('select_list_notice_scene'), templet: sceneTemplet}, + {field: 'read_time', title: '状态', width: 80, align: 'center', search: false, templet: readTemplet}, + {field: 'create_time', title: '接收时间', width: 170, templet: ua.table.date, search: 'range'}, + { + width: 90, title: '操作', fixed: 'right', templet: ua.table.tool, + operat: [[ + { + class: 'layui-btn layui-btn-primary layui-btn-xs notice-mine-read-btn', + method: 'none', + field: 'id', + text: '查看', + title: '查看消息', + auth: 'notice/read', + url: init.readUrl, + } + ]] + } + ]], + }); + + ua.listen(); + + // 查看:弹窗打开详情,关闭后刷新列表以更新未读状态 + $(document).on('click', '.notice-mine-read-btn', function () { + var id = $(this).attr('data-id'); + layui.layer.open({ + type: 2, + title: '查看消息', + area: ['760px', '560px'], + content: ua.url(init.readUrl + '?id=' + id), + end: function () { + ua.table.reloadData(init.tableRenderId); + } + }); + }); +}) diff --git a/extend/base/admin/view/notice/read.html b/extend/base/admin/view/notice/read.html new file mode 100644 index 0000000..6780a17 --- /dev/null +++ b/extend/base/admin/view/notice/read.html @@ -0,0 +1,39 @@ +
+
+
+
+ {$msg.title} + {if condition="$msg.read_time eq 0"} + 未读 + {else /} + 已读 + {/if} +
+
+ + + + + + + + + + + + + + + + + + + +
场景{$msg.scene_title}
接收时间{$msg.create_time|date="Y-m-d H:i:s"}
内容{$msg.content}
+
+ +
+
+
+
+
diff --git a/extend/base/admin/view/notice/read.js b/extend/base/admin/view/notice/read.js new file mode 100644 index 0000000..2917b50 --- /dev/null +++ b/extend/base/admin/view/notice/read.js @@ -0,0 +1,16 @@ +$(function(){ + ua.listen(); + + // 关闭按钮:弹窗内关闭当前 iframe;非弹窗场景(直接以标签页打开)返回上一页 + $('#notice-read-close').on('click', function () { + var parentLayer = (window.parent && window.parent.layui) ? window.parent.layui.layer : null; + if (parentLayer) { + var index = parentLayer.getFrameIndex(window.name); + if (index !== undefined && index !== null) { + parentLayer.close(index); + return; + } + } + history.back(); + }); +}) diff --git a/extend/base/admin/view/notice/subscribe.html b/extend/base/admin/view/notice/subscribe.html new file mode 100644 index 0000000..0594799 --- /dev/null +++ b/extend/base/admin/view/notice/subscribe.html @@ -0,0 +1,52 @@ +
+
+ +
+ +
+
+ 选择每类通知通过哪个渠道发送给您;未保存过的组合默认为订阅。标注「必选」的场景为系统强制通知,不可关闭。 +
+ + + + {volist name="channels" id="channelTitle"} + + {/volist} + + + + + {volist name="channels" id="channelTitle"} + + {/volist} + + + + {volist name="matrix" id="sceneRow"} + + + {volist name="sceneRow.cells" id="cell"} + + {/volist} + + {/volist} + +
场景{$channelTitle}
+ {$sceneRow.title} + {if condition="$sceneRow.locked"} + 必选 + {/if} + + +
+
+
+ +
+
+ +
+ +
+
diff --git a/extend/base/admin/view/notice/subscribe.js b/extend/base/admin/view/notice/subscribe.js new file mode 100644 index 0000000..a6278c2 --- /dev/null +++ b/extend/base/admin/view/notice/subscribe.js @@ -0,0 +1,16 @@ +$(function(){ + // 提交前补齐矩阵:layui 只序列化勾选的开关, + // 这里把全部未禁用开关按当前状态补齐为 1/0,保证逐格显式 upsert + function buildMatrix(dataField) { + $('#subscribe-matrix input[type="checkbox"][name^="matrix["]').each(function () { + var $input = $(this); + if ($input.prop('disabled')) { + return; + } + dataField[$input.attr('name')] = $input.prop('checked') ? 1 : 0; + }); + return dataField; + } + + ua.listen(buildMatrix); +})