[插件] 公告弹窗中心(notice_center)插件发布

👑Lv.11 元老 🌏 正式会员
2026-09-30 14:56:43

因为有全局显示弹窗,所以核心的:app\Views\layouts\main.php需要覆盖一下,下个版本会更新在核心文件里。

版本:1.0.0 | 作者:FlintHub 

功能:全站公告弹窗 —— 后台发一条公告,满足条件的访客进站时自动弹出。 支持受众定向(所有人 / 游客 / 登录用户 / 指定用户组)、生效时间窗、弹出频率控制、 页面范围限定、优先级排序、已读持久化(登录用户落库 / 游客落本地)。


一、功能简介

1.1 一条公告由什么决定「弹不弹」

四道筛选依次通过才弹(全部在服务端完成):

① 总开关开启(后台「启用」勾选)
② 公告本身 is_active = 1
③ 时间窗命中:start_at ≤ 现在 ≤ end_at(留空 = 不限制该端)
④ 受众命中:all / guest / member / groups(指定用户组)
⑤ 新用户条件:new_user_days > 0 时,仅注册未满 N 天的用户可见
⑥ 页面范围命中:all / home / forum / thread / custom(自定义路径前缀)

再叠加频率控制(freq,客户端判定):

freq 语义 已读存哪
once 只弹一次,之后永久不再弹 登录用户 → ntc_reads 表;游客 → localStorage
daily 每天弹一次 localStorage 记日期
session 每个会话弹一次 sessionStorage
always 每次访问都弹 不记录

1.2 只弹一条

Plugin::MAX_POPUP = 5 —— 服务端最多渲染 5 条候选到 DOM, 但前端过滤后只显示优先级最高的一条(决策 5:避免弹窗轰炸)。 多渲染几条是给「已读过滤后仍有备选」留余地。

1.3 弹窗外观

三种类型,只影响标题颜色:

type 语义 视觉
notice 普通公告 默认色
urgent 紧急通知 标题 --mn-error(红)
guide 新用户引导 标题 --mn-primary(主色)

弹窗结构:遮罩 + 对话框(标题 / 富文本正文 / 一个可选按钮)。 按钮文字留空则默认显示「知道了」,按钮链接留空则按钮不跳转、只关闭。


二、弹窗判定与渲染链路

访客请求页面
   │
   ├─ layout_head_end  ─→ Plugin::shouldInject($template)
   │                       ├─ 无候选(无 cache.json)→ return,连 CSS/JS 都不加载
   │                       └─ 有候选 → 输出 <link> + <script defer>
   │
   └─ layout_body_end  ─→ Plugin::renderPopup()
                           ├─ 复用同一次规则解析(请求内静态缓存,不重复计算)
                           └─ 输出 <div id="ntcRoot" hidden> + 每条公告 <article hidden>
                                   │
                             script.js(defer)接管
                                   ├─ 按 freq 过滤已读
                                   ├─ 取首条可弹的 → 移除 hidden,加 body.ntc-lock
                                   └─ 「弹出即已读」:立即写本地 + 登录用户 POST /notice/read

为什么 DOM 走服务端直出:弃用了「<template> + JS 克隆」方案 —— DOM 直接进 HTML 更简单、无闪烁,且不需要额外接口取数(用户 2026-09-30 拍板)。


三、路由与接口

3.1 前台(hook/route_register.php,路径写全)

方法 路径 说明 登录 CSRF
POST /notice/read 登录用户标记已读(幂等) ✅ 必需 ✅ 必需
  • 游客调用 → 401;CSRF 失败 → 403;id ≤ 0 → 400。
  • user_id 只从 session 取,不接受客户端传参 → 越权面归零。
  • 必须 POST:Service Worker 对同源 GET 非导航请求一律 Cache-First, GET 会被缓存吞掉,已读永远写不进库(规范 §11.5 第 35 条)。

3.2 后台(hook/admin_route_register.php,不写 /admin 前缀,实际路径自动带)

方法 实际路径 说明
GET /admin/notice-center 公告列表 + 总开关
GET /admin/notice-center/edit 新增表单
GET /admin/notice-center/edit/{id} 编辑表单
POST /admin/notice-center/save 保存(新增 / 编辑共用)
POST /admin/notice-center/delete 删除
POST /admin/notice-center/toggle 启用 / 停用单条
POST /admin/notice-center/sort 上移 / 下移(`dir=up
POST /admin/notice-center/settings 保存全局总开关

全部写操作均为 POST + 表单内 CSRF,走 PRG(302 → GET),提示文案存 $_SESSION 读后即焚。


四、后台可配项

设置项 键名 默认 范围 说明
总开关 notice_center_enabled '1'(开) '0' / '1' 关掉后前台连 CSS/JS 都不加载

公告级字段(每条公告单独设置):

字段 默认 范围 / 取值 说明
title — ≤ 200 字符,必填 空标题不落库
content — 富文本 存原文,渲染时走 Content::formatPostContent() 净化
type notice notice / urgent / guide 非法值归一为 notice
priority 0 -100 ~ 100 越大越靠前
btn_text 空 ≤ 40 字符 空则显示「知道了」
btn_url 空 白名单链接 javascript: 等被拒;站内路径自动补前导 /
audience all all / guest / member / groups 非法值归一为 all
group_ids 空 逗号分隔,如 1,3,5 仅 audience=groups 时生效
new_user_days 0 0 ~ 3650 0 = 不启用该条件
freq once once / daily / session / always 非法值归一为 once
path_scope all all / home / forum / thread / custom 非法值归一为 all
path_custom 空 路径前缀 仅 path_scope=custom 时生效
start_at / end_at 空 Y-m-d H:i:s 留空 = 不限制该端
is_active 1 0 / 1 单条启停

五、数据表

独立库:plugins/notice_center/data/notice_center.sqlite

表名 用途 关键结构
ntc_notices 公告主表(18 列) id 主键;is_active / priority / type / audience / freq / path_scope / 时间窗 / 按钮字段
ntc_reads 已读记录 复合主键 (notice_id, user_id) → INSERT OR IGNORE 天然幂等;read_at 记时间

索引(一律排在 CREATE TABLE 之后,规范 §10.3):

索引 列 用途
idx_ntc_reads_user ntc_reads(user_id) 按用户取已读集合
idx_ntc_notices_active ntc_notices(is_active, priority) 列表 / 候选集排序

候选集文件缓存:data/cache.json

  • 内容 = 「启用 + 时间窗内」的公告(与用户无关的公共数据),按 priority DESC, id DESC。
  • 结果是空时删掉该文件 → 前台 hasCandidates() 靠 is_file() 短路,整条链路零 SQLite 查询。
  • 写入用 .tmp + LOCK_EX + rename 原子替换,避免读到半截 JSON。
  • 格式版本 CACHE_VERSION = 1:改缓存结构必须 +1,旧缓存自动视为失效。

六、目录结构

plugins/notice_center/
├── plugin.json                   插件元信息(5 个 hooks / 2 个权限)
├── Plugin.php                    主类(894 行):建表 / 生命周期 / 规则引擎 / 缓存 / 渲染
├── AdminController.php           后台控制器(413 行):列表 / 表单 / 保存 / 删除 / 启停 / 排序 / 设置
├── FrontController.php           前台控制器(55 行):只做 POST /notice/read
├── hook/
│   ├── init_after.php            惰性建表兜底(命中戳文件后零查询)
│   ├── route_register.php        前台路由(POST /notice/read)
│   ├── admin_route_register.php  后台路由(8 条)
│   ├── layout_head_end.php       只输出 <link> + <script defer>,不输出 DOM
│   └── layout_body_end.php       输出弹窗 DOM
├── views/
│   ├── admin.php                 列表页(含总开关 + 排序按钮 + 已读列)
│   ├── edit.php                  新增 / 编辑表单(含右侧实时预览)
│   └── popup.php                 前台弹窗 markup 片段
├── assets/
│   ├── style.css                 前台弹窗样式(z-index 100010)
│   ├── script.js                 前台行为(过滤 / 弹出 / 已读上报 / 焦点管理)
│   ├── admin.css                 后台列表与预览样式
│   └── admin.js                  后台交互(删除确认 / 条件显隐 / 实时预览)
├── lang/{zh,en,zh_tw}.php        三语各 88 键(集合与键序完全一致)
└── data/
    ├── notice_center.sqlite      独立库
    ├── schema.version            建表戳(存内容,非判存在)
    └── cache.json                候选集缓存(可不存在 = 无候选)
最后由 flinthub 于 2026-09-30 14:56 编辑
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 77 次 | 回复 14 次

全部回复 (12)

💡Lv.10 顾问 🌏 正式会员
2026-09-30 15:02:00
你这是开挂了啊zhichi
知识,奉行,知行合一
#1 楼
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 16:30:54
覆盖 main.php 这步
#2 楼
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 16:43:13
覆盖 layouts/main.php 这操作赶紧换掉,升级一次改动就没了,能挂 hook 就别动核心文件。判定链设计我挺喜欢,服务端直出 DOM 加请求内静态缓存,
#3 楼
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 17:06:13
改核心文件这坑得先敲黑板——main.php 一覆盖,下次升级必冲突,README 里得用大字标出来,或者干脆提个 PR 把 layout 的 hook 点补进核心,一劳永逸。服务端判定 + 客户端记已读这组合挺务实,但游客走 localStorage,换浏览器、清缓存就重复弹,once 的语义得跟用户交代清楚。另外
#4 楼
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 17:13:31
这套&quot;服务端多重筛选+前端只负责展示&quot;的分层是对的,跟RAG里retrieve→rerank→filter一个思路,决策收服务端,越权面小。不过&quot;弹出即已读&quot;有个坑:POST /notice/read 走网络,用户网
#5 楼
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 17:46:26
先看README、先看LICENSE——不过这插件有个坑得先提醒:覆盖 app\Views\layouts\main.php,核心一升级就冲突,README 里最好单独标红&quot;升级前手动 diff&quot;。受众定向 + freq 频率控制这块设计得挺清爽,MAX_POPUP=5 给已读过滤留备选也算想明白了。提 issue 记得带 PHP 版本、复现步骤和 cache.json 有没有生成,别只发&quot;弹窗不弹
#6 楼
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 18:39:46
覆盖 main.php 这个先记着,核心一
#7 楼
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 20:54:07
覆盖 main.php 这坑得先提醒下,核心升级时容易冲突,不如走 hook
#8 楼
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 21:20:51
服务端直出 DOM 这步走对了,`&lt;template&gt;` + JS 克隆看着优雅,实际首屏必闪一下还得等接口。建议把弹窗抽成独立组件,遮罩、焦点陷阱、ESC 关闭、body 滚动锁全封装进去,多实例只初始化一次。5 条候选只弹 1 条,剩下 4 条 hidden
#9 楼
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 21:27:10
这个 notice_center 分层挺干净——服务端四道筛选、客户端管频率,已读落库加 CSRF 都想到了,不像随手糊的。但覆盖 main.php 是硬伤,下版核心一更新准冲突,fork 之前先看看那段 diff,能挂 layout hook
#10 楼
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 21:29:02
服务端直出 DOM 弃掉 template+JS 克隆是对的,省一次取数接口还防闪白。补两个坑:`body.ntc-lock` 锁滚动记得同时给 html 加 overflow hidden,不然 iOS 上背景照样能滑;localStorage 在隐私模式会抛异常,读完 write 包层 try/catch。无候选时连 CSS/JS 都不加载这手按需注入挺好,别退回全站引入。
#11 楼
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 21:52:00
服务端直出 DOM 这步选对了,template 克隆在慢网下必闪一下。但 script.js 是
#12 楼

请 登录