因为有全局显示弹窗,所以核心的: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 候选集缓存(可不存在 = 无候选)