本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/全站公告弹窗插件开发方案_2026-09-30.md。
全站公告弹窗插件开发方案(notice_center)
编制:2026-09-30 | 状态:✅ 已实施完成(P0 ~ P4 全量交付,2026-09-30)
一句话:前台全站居中弹窗(重要公告 / 紧急通知 / 新用户引导),带受众定向、时间窗、频率控制与已读持久化。
实施结果速览(与原始设计的差异)
| # | 原始设计 | 实施结果 | 详见 |
|---|---|---|---|
| 1 | 弹窗 DOM 走方案①(<template> 放 <head> + JS 克隆) | 改为新增 layout_body_end 钩子,服务端直出到 </body> 前(方案④) | §2.1 |
| 2 | P0.5 阶段:<template> 放 <head> 可行性实测 | 取消 —— 该分叉点随方案④消失 | §五 |
| 3 | 后台排序「拖拽排序」 | ↑ / ↓ 按钮 + 换位后整表重排 priority = n…1 | §3.8 |
| 4 | 后台编辑「Alpine 弹层」 | 独立编辑页(/edit、/edit/{id})+ 右侧实时预览 | §3.8 |
| 5 | i18n 预估 45~55 键 | 实际 88 键(三语集合零差异 + 键序一致) | §3.10 |
| 6 | — | 新增「总开关」设置项(notice_center_enabled,缺省开) | §4 |
| 7 | — | 插件文档:plugins/notice_center/说明.md | — |
本文档保留原始设计原貌,凡与实现不一致处均以
> 实施修订引用块就地标注。
一、结论摘要
1.1 做什么
| 能力 | 说明 |
|---|---|
| 全站弹窗 | 遮罩 + 居中卡片,前台所有页面生效(含帖子/博客详情页,现有横幅条覆盖不到的地方) |
| 三类内容 | notice 普通公告 / urgent 紧急通知 / guide 新用户引导 |
| 受众定向 | 全部 / 仅游客 / 仅登录 / 指定用户组(多选) |
| 时间窗 | 起止日期(留空 = 不限),到点自动生效 / 自动失效 |
| 频率控制 | 仅一次 / 每天一次 / 每会话一次 / 每次都弹(紧急) |
| 页面范围 | 全站 / 仅首页 / 仅论坛 / 仅帖子详情 / 自定义路径前缀 |
| 已读持久化 | 登录用户落库(跨设备一致)+ 游客落 localStorage |
| 后台 | CRUD + 排序 + 启停 + 实时预览 + 已读统计 |
1.2 与现有 announcements 插件的边界(关键,先划清)
| 维度 | `announcements`(现有,作者 SoSite) | `notice_center`(本方案) |
|---|---|---|
| 形态 | 列表页顶部横幅条 | 全站居中弹窗(遮罩 + 卡片) |
| 钩子 | announcements_display(首页/论坛/博客列表页) | layout_head_end(全站) |
| 关闭 | 只加 CSS class 隐藏,刷新即回来(assets/script.js 实测) | 持久化,刷新不回来 |
| 定向 | 无 | 受众 / 时间窗 / 频率 / 页面范围 |
| 引导 | 无 | 有 |
→ 不替换、不修改 announcements,两者共存:横幅条讲日常通知,弹窗讲"必须看见"的公告。
1.3 不做什么
不改核心;不做站内信 / 邮件 / 浏览器推送;不做 A/B;不做多步骤引导(列为 P2 可选项);不做曝光埋点分析。
1.4 需你拍板 5 个点 → 见 §四
二、现状核查(实测,非推断)
2.1 弹窗 DOM 的落点:核心原本只有 1 个布局钩子 → 实施时新增了第 2 个
$ grep -n "hook(" app/Views/layouts/main.php
40: layout_head_end ← </head> 前(唯一布局级)
187: nav_main_links
246: nav_plugin_links
274: nav_user_menu_items (pc)
318: nav_drawer_links
388: nav_user_menu_items (mobile)
410: footer_links ← 页脚( 内)没有 body 末尾钩子。所以弹窗 DOM 原本只有三条路:
| 方案 | 做法 | 评价 |
|---|---|---|
| ①(原方案选它,已弃用) | layout_head_end 输出 <link> + <script defer> + <template>,JS 克隆到 body | 完全脱离父容器;对页面缓存友好;<template> 在 <head> 中是 HTML 规范允许的位置 |
| ② | footer_links 直接输出弹窗 DOM | 嵌在 <footer> 内;position:fixed 虽能脱离文档流,但层叠与语义不干净 |
| ③ | 纯 JS 建 DOM | 文案 / i18n / 富文本净化全被推到 JS 侧 → 与零内联红线冲突,且放大 XSS 面。否决 |
| ④(实际采用) | 给核心新增 layout_body_end 钩子(</body> 前),弹窗 DOM 服务端直出 | 无 <template>、无克隆、无闪烁;DOM 直接在 body 下,语义最干净。代价:核心多一个本地新增钩子(与上游合并时须保留) |
实施修订(2026-09-30):用户拍板走方案④ —— 先给核心加
layout_body_end钩子。落点
app/Views/layouts/main.php:534,源码内带[FLINTHUB-CUSTOM 2026-09-30]标记;钩子定义已写入
docs/插件开发规范.md§9.1 #43(B 组扩展点,编号 43)。⚠️ 该钩子无参数(不像
layout_head_end带template)→ 「是否后台页」只能靠请求路径判定(
Plugin::isBackend('')查/admin前缀),不能只靠模板名。因此
P0.5(<template>放<head>可行性实测)取消 —— 分叉点已不存在。
layout_head_end随之降级为「只输出<link>+<script defer>」。
2.2 页面缓存:只影响游客(决定"游客怎么判已读")
app/Helpers/PageCache.php:9 注释原文:
永久缓存:不按 TTL 过期,仅在发帖/回帖时由
invalidate()主动失效;仅游客生效(登录用户含用户态数据不缓存);index.php 入口前置缓存。
推论(直接决定设计):
- 游客命中整页缓存 → 服务端无法注入"该游客读过哪些" → 游客已读只能走
localStorage - 登录用户不走缓存 → 服务端可以安全注入"该用户已读 id 列表"
- 弹窗 DOM 里只能放与用户无关的公共内容 —— 否则游客缓存会把某个用户的已读状态烘焙进去,串号
2.3 核心没有弹窗组件(实测)
$ grep -rn "\.mn-modal|\.mn-dialog|\.mn-overlay|\.mn-popup|\.mn-mask" assets/css/modern.css → 空
$ grep -rn "modal|Modal" assets/js/*.js → 空→ 弹窗必须自建。可参照核心自带的浮层:.image-lightbox(assets/css/modern.css:3045,
组件 app/Views/_components/image_lightbox.php)—— 它的 role="dialog" aria-modal="true" 写法可直接抄。
2.4 z-index 天花板 = 100005(实测)
assets/css/*.css 的 z-index 分布:9999 ×1、99999 ×2、100000 ×4、100005 ×1
→ 弹窗取 100010(必须高于全部核心浮层,否则被灯箱/顶栏盖住)。
2.5 用户维度数据齐备(实测 users 表)
id / username / role / group_id / status / created_at / last_login / email_verified ...user_groups:1 普通会员、2 VIP会员、3 版主、4 管理员、5 封禁账户
→ 新用户引导可用 created_at 判"注册后 N 天内";受众定向可用 group_id。
2.6 可复用资产(省事清单)
| 资产 | 位置 | 用途 |
|---|---|---|
| 富文本接入范式 | plugins/single_page/views/page.php:18 | $this->formatPostContent($this->decodeContent($raw)) |
| 后台 CRUD + PRG 范式 | plugins/adslot/AdminController.php | Tab / flash 读后即焚 / Csrf::verifyOrDie |
| 插件文件缓存范式 | plugins/hottopics/Plugin.php:128 cacheFile() | .tmp + LOCK_EX + rename 原子写 |
| 前台路由前缀口径 | plugins/suiyu/hook/route_register.php 头注释 | 前台写全 /notice/...;后台不写 /admin |
| 数据表版本戳 | plugins/adslot/Plugin.php:131 SCHEMA_VERSION | 建表自愈 + 改 DDL 必须 bump |
| 会话夹具 / 冒烟套件 | .workbuddy-ai/tmp/mm_mksess.php、mm_smoke.sh | 直接复用(上一插件刚建的) |
2.7 七条硬约束(实测,直接决定设计)
- 每请求 hook 查询 ≤ 5 次(规范 §11.4 第 24 条)→ 候选集必须走文件缓存,且无候选时零查询
- 页面路径零 SQLite(§11.4 第 27 条)→ 用标记文件短路,别每次开库
- 零内联(§7.3):禁止内联
<style>块、内联<script>块、style=""属性、on*事件 - CSP(
app/Core/Controller.php:80=img-src 'self' data: https:)→ 本地图片预览必须FileReader;
富文本里的图已被净化层改成站内 md_ 路径 ✓
- Service Worker(§11.5 第 35 条):同源 GET 非导航一律 Cache-First → 已读上报必须用 POST(POST 不走 SW 缓存)
- 渲染钩子里
$this不可用(§11.24)→layout_head_end里不能$this->icon()/$this->e(),
一律 htmlspecialchars() + I18n::get();改 layout_head_end 后必须 PageCache::invalidate()(§11.5 第 31 条)
layout_body_end无参数(实施后新增)→ 判「是否后台页」只能靠请求路径/admin前缀
(不像 layout_head_end 带 template);同样在闭包作用域执行、同样禁用 $this;
改它之后同样必须清页面缓存
三、技术设计
3.1 目录结构
plugins/notice_center/
├── plugin.json # hooks: init_after / route_register / admin_route_register / layout_head_end / layout_body_end
├── 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 # /notice-center 系列(8 条)
│ ├── layout_head_end.php # ★ 只输出 link + script(不输出 DOM)
│ └── layout_body_end.php # ★ 输出弹窗 DOM(本地新增钩子)
├── views/
│ ├── admin.php # 后台列表页(总开关 + 排序按钮 + 已读列)
│ ├── edit.php # 新增 / 编辑表单(含右侧实时预览)
│ └── popup.php # 弹窗 markup 片段(纯 PHP,不用 $this)
├── assets/
│ ├── style.css # 弹窗样式(前台,z-index 100010)
│ ├── script.js # 筛选 / 显示 / 已读上报 / 焦点管理
│ ├── admin.css # 后台列表与预览样式
│ └── admin.js # 后台交互(删除确认 / 条件显隐 / 实时预览)
├── data/
│ ├── notice_center.sqlite # 插件独立库
│ ├── cache.json # 候选集缓存(可不存在 = 无候选)
│ ├── schema.version # 建表版本戳(存内容,非判存在)
│ └── .htaccess / web.config # 禁 Web 访问(与其它插件一致)
├── lang/{zh,en,zh_tw}.php # 三语,各 88 键(键集合零差异 + 键序一致)
└── 说明.md # 插件说明(已交付)实施修订(2026-09-30):相对原设计新增
hook/layout_body_end.php、views/edit.php、
assets/admin.js;layout_head_end.php不再承载 DOM。
⚠️ 目录名
notice_center含下划线 →Plugin.php末尾必须class_alias(Plugin::class, 'Plugin\notice_center\Plugin', false)(规范 §4.1),否则卸载留孤儿表。
3.2 数据表(插件独立库)
-- 公告主体
CREATE TABLE IF NOT EXISTS ntc_notices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL DEFAULT 'notice', -- notice | urgent | guide
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '', -- 富文本原文,渲染时净化
btn_text TEXT NOT NULL DEFAULT '', -- 主按钮文案(空 = 用默认「我知道了」)
btn_url TEXT NOT NULL DEFAULT '', -- 主按钮跳转(空 = 纯关闭)
audience TEXT NOT NULL DEFAULT 'all', -- all | guest | member | groups
group_ids TEXT NOT NULL DEFAULT '', -- audience=groups 时的组 id 列表(逗号分隔)
new_user_days INTEGER NOT NULL DEFAULT 0, -- >0 = 仅注册后 N 天内可见(新用户引导)
freq TEXT NOT NULL DEFAULT 'once', -- once | daily | session | always
path_scope TEXT NOT NULL DEFAULT 'all', -- all | home | forum | thread | custom
path_custom TEXT NOT NULL DEFAULT '', -- path_scope=custom 时的路径前缀(逗号分隔)
start_at TEXT NOT NULL DEFAULT '', -- 生效开始(YYYY-MM-DD,空 = 不限)
end_at TEXT NOT NULL DEFAULT '', -- 生效结束(YYYY-MM-DD,空 = 不限)
priority INTEGER NOT NULL DEFAULT 0, -- 越大越优先
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 已读记录(只服务 freq=once 的登录用户)
CREATE TABLE IF NOT EXISTS ntc_reads (
notice_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
read_at TEXT NOT NULL,
PRIMARY KEY (notice_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_ntc_reads_user ON ntc_reads (user_id);⚠️ 三条建表红线(记忆 + 规范):
- 索引 DDL 必须写在
CREATE TABLE之后(否则规划器误选 → 回归) CREATE INDEX IF NOT EXISTS不重建同名索引 → 改索引要换名或删旧- 改 DDL 必须 bump
SCHEMA_VERSION+ 手删旧schema.version(否则戳文件命中、新表建不出来) ntc_notices只有十几条量级,不加 status 打头的索引
3.3 路由
前台(hook/route_register.php,index.php:295,在 group('/admin') 之外 → 路径写全):
| 方法 | 路径 | 控制器 | 说明 |
|---|---|---|---|
| POST | /notice/read | FrontController::markRead | 已读上报(JSON)。必须 POST —— GET 会被 SW Cache-First 拦(§11.5 第 35 条) |
后台(hook/admin_route_register.php,在 group 之内 → 不写 /admin):
| 方法 | 路径 | 控制器 |
|---|---|---|
| GET | /notice-center | AdminController::index |
| GET | /notice-center/edit | AdminController::edit(新增态,$id 缺省) |
| GET | /notice-center/edit/{id} | AdminController::edit(编辑态) |
| POST | /notice-center/save | AdminController::save |
| POST | /notice-center/delete | AdminController::delete |
| POST | /notice-center/toggle | AdminController::toggle |
| POST | /notice-center/sort | AdminController::sort |
| POST | /notice-center/settings | AdminController::saveSettings(总开关) |
实施修订(2026-09-30):实际共 8 条后台路由(原设计 5 条)。
浏览器可见路径自动带
/admin前缀(如/admin/notice-center/edit/3)。⚠️
edit($id = null)必须带默认值 —— 核心Router用call_user_func_array()以命名参数传路由变量,两段路由
/notice-center/edit不带id键,无默认值会报错。
plugin.json 的 permissions 需声明 route:admin + system:settings(强制模式下缺了静默 DENY)。
3.4 ★ 前台弹窗的 DOM 注入链路(最关键的一节)
实施修订(2026-09-30):本节原设计(
<template>+ JS 克隆)已作废,改为服务端直出。下方为实际实现的链路。
① 浏览器请求页面
↓
② layout_head_end 钩子(app/Views/layouts/main.php:40,参数 template)
└─ Plugin::shouldInject($template)
├─ Plugin::isEnabled() ← 总开关(Settings,零查询)
├─ Plugin::hasCandidates() ← is_file(data/cache.json) 短路,零 SQLite
├─ Plugin::isBackend($template) ← 模板名 or 请求路径 /admin 前缀 → 后台直接 return
└─ Plugin::resolve() ← 完整规则判定(请求内静态缓存)
├─ 无候选 / 不命中 → return(**连 都不输出**)
└─ 命中 → 输出 + 为什么这样分层(与原设计一致,只是落点从 <template> 换成直出):
| 关注点 | 放在哪 | 理由 |
|---|---|---|
| 总开关 / 时间窗 / 页面范围 / 受众 / 新用户 | 服务端 | 数据来自文件缓存与 session,零查询 |
| 已读 / 频率 | 客户端 | 每用户状态;游客命中整页缓存,服务端注入会被烘焙串号 |
| 优先级排序 | 客户端取首条 | 服务端已按 priority DESC, id DESC 排好 |
已读 id 注入为什么安全:data-ntc-read / data-ntc-csrf 只对登录用户渲染;
登录用户不命中 PageCache(PageCache.php:9),所以这份 HTML 不会被写进游客缓存。
游客版本里这两个属性不存在 —— 已有 HTTP 冒烟断言覆盖(「游客首页不含 data-ntc-read / data-ntc-csrf」)。
查询预算实测(对照 §3.7 的预期值):
| 场景 | 预期 | 实测 |
|---|---|---|
无候选(无 cache.json) | 0 | 0 ✅ |
| 有候选 + 游客 | 0 | 0 ✅ |
| 有候选 + 登录 | 1 | 1 ✅ |
3.5 规则判定细节
受众(服务端,Auth::getCurrentUser(),零查询):
| `audience` | 判定 |
|---|---|
all | 恒真 |
guest | 未登录 |
member | 已登录 |
groups | 已登录 且 group_id ∈ group_ids((int) 强转后比对) |
新用户引导(服务端):new_user_days > 0 时,要求 created_at 距今 ≤ N 天;
created_at 为空的账号视为不满足(不弹),避免老数据误弹。
页面范围(服务端,比对 $_SERVER['REQUEST_URI'] 的 path 段):
all 恒真;home = /;forum = /forum;thread = /thread/;custom = 命中 path_custom 里的任一前缀。
频率(客户端):
| `freq` | 判定键 | 说明 |
|---|---|---|
once | 服务端已读集合(登录)/ localStorage(游客) | 永久一次 |
daily | localStorage['ntc_d_{id}'] === 今天 | 每天一次 |
session | sessionStorage['ntc_s_{id}'] | 每会话一次 |
always | 恒真 | 紧急通知;仍可用关闭按钮关掉当次 |
3.6 已读状态(双轨)
| 用户 | 存储 | 上报方式 |
|---|---|---|
| 登录 | ntc_reads(notice_id, user_id, read_at) | POST /notice/read(fetch + keepalive,关页也能发出) |
| 游客 | localStorage(ntc_seen 数组) | 纯本地,不发请求 |
口径:弹出即已读(显示后立刻上报),不要求点按钮 —— 避免"用户直接关标签页 → 明天再弹一遍"的骚扰。
(若你倾向"点按钮才算已读",见 §四 决策 4。)
3.7 候选集文件缓存(守住 ≤5 查询红线)
- 文件:
plugins/notice_center/data/cache.json - 内容:通过「启用 + 时间窗」筛选的公告(与用户无关的公共数据)
- 读:
is_file()+file_get_contents+json_decode→ 零 SQLite - 写:
file_put_contents($tmp, $json, LOCK_EX)+ rename(抄hottopics/Plugin.php:166-184) - 失效时机:后台任何写操作(save / delete / toggle / sort)→ 重建缓存 +
PageCache::invalidate() - 时间窗在读取时再判一次(纯字符串比较,
YYYY-MM-DD字典序 ≡ 时间序)→ 缓存可以长期不过期
查询预算实测预期:
| 场景 | 查询数 |
|---|---|
| 无生效公告(绝大多数请求) | 0 |
| 有候选 + 游客 | 0(游客无已读查询) |
| 有候选 + 登录 | 1(SELECT notice_id FROM ntc_reads WHERE user_id = ?) |
远低于 §11.4 第 24 条的 ≤5 红线。
3.8 后台页面
列表页(/admin/notice-center):
- 顶部总开关表单(
.ntc-switch-form) - 表格:标题(副行显示
#id · 页面范围)/类型/受众/频率/时间窗/优先级/状态/已读数/操作 - 操作列:
↑↓排序 + 编辑 + 启停 + 删除(删除表单带data-ntc-confirm→window.confirm拦截)
新增 / 编辑页(/admin/notice-center/edit 与 /edit/{id}):
- 字段 = 上表全部业务字段(含时间窗 / 受众 / 频率 / 页面范围 / 优先级 / 启停)
- 右侧实时预览:按前台样式渲染一份弹窗(
aria-hidden="true",无遮罩、无关闭按钮、不可聚焦)
实施修订(2026-09-30):
- 原设计「Alpine 弹层」→ 改为独立编辑页(仓库无 Alpine,且字段量用独立页更合适)
- 原设计「拖拽排序」→ 改为
↑/↓按钮- 排序语义:换位后按新顺序重排整表
priority = n…1,不是相邻两条互换 ——相邻互换在 priority 有重复值时等于没换。事务包裹,已在首位/末位则静默返回(无副作用)
- 预览的 DOM 结构与真实弹窗完全一致(
ntc-dialog > ntc-list > ntc-notice),直接复用
assets/style.css的前台类;admin.css里.ntc-preview .ntc-dialog已重置
max-height/box-shadow,而.ntc-dialog定位本就是relative→ 内嵌不跑偏- 预览正文不净化(管理员自伤级别,且真实弹窗渲染时走
Content::formatPostContent()),已知取舍- ⚠️ 核心表单样式全是
.admin-content作用域(admin.css:237-282)→表单与预览都必须留在
<main class="admin-content">内(规范 §11.3 第 22 条,medal / adslot 两次实测踩过)—— 放在
</main>之后会拿不到任何核心表单样式,且零日志
3.9 富文本内容(走核心编辑器,三步)
- 控制器:
$this->view(..., ['needsEditor' => true]) - 视图:
<textarea name="content" data-editor="content" class="form-input"> - 服务端存原文(
Content::decode()后入库);渲染时Content::formatPostContent($raw)裸 echo
存原文、渲染时净化(与 single_page / adslot 同模型)→ 编辑时看到的是自己贴的内容,不会二次转义。
3.10 i18n
键前缀 plugin.notice_center.*。三语(zh / en / zh_tw)键集合零差异 + 键序一致,
交付前跑 plugin_hygiene.py 与键集合比对脚本断言。
实施修订(2026-09-30):预估 45~55 键 → 实际 88 键(后台表单标签 / 表格表头 /
类型·受众·频率·范围枚举文案 / 前台按钮 / 校验与提示文案 / 排序与预览按钮)。
实测三语各 88 键,集合零差异、键序一致、代码引用全覆盖。
3.11 明确不做的(极简取向)
- ❌ 多步骤引导(步骤条 + 上/下一步)→ 列为 P2 可选,见 §四 决策 3
- ❌ 弹窗内嵌表单(问卷/投票)→ 那是
post_poll/qa的活 - ❌ 定时/循环弹窗(每 N 分钟弹一次)→ 骚扰用户,无实际用途
- ❌ 曝光埋点 / 转化漏斗 → 已读统计已够用
四、需你拍板的 5 个点
决策 1:插件目录名
| 选项 | 说明 |
|---|---|
notice_center(推荐) | 语义最准(不止弹窗,还有引导);含下划线 → 需 class_alias(已按规范处理) |
site_notice | 更直白;同样含下划线 |
popup | 最短,但语义窄(引导类内容装不进去) |
决策 2:游客弹不弹?
| 选项 | 代价 |
|---|---|
| 弹(推荐) | 游客用 localStorage 记已读 → 清缓存会重弹一次,可接受;覆盖面最广 |
| 不弹 | 实现最简(省掉整条游客分支),但"本站开放注册"这类公告触达不到游客 |
决策 3:新用户引导的形态
| 选项 | 工作量 |
|---|---|
| 单弹窗(推荐) | 复用公告引擎,用富文本写引导步骤(有序列表 + 链接)。零额外结构 |
| 多步骤引导(步骤条 + 上/下一步 + 高亮锚点) | 需新增步骤表 + 定位逻辑 + 响应式处理,工作量约等于再来一个插件 |
决策 4:已读口径
| 选项 | 说明 |
|---|---|
| 弹出即已读(推荐) | 显示后立刻上报;用户关标签页也算读过,不会反复骚扰 |
| 点按钮才算已读 | 更"精确",但用户直接关页 → 下次还弹 |
决策 5:多条同时满足时
| 选项 | 说明 |
|---|---|
| 只弹优先级最高的一条(推荐) | 关闭后本次不再弹其他;避免弹窗轰炸 |
| 关闭后顺延弹下一条 | 一次会话内把待读的都弹完;条数多时体验差 |
五、实施阶段(✅ 全部完成)
| 阶段 | 内容 | 交付判据 | 状态 |
|---|---|---|---|
| P0 | 骨架(plugin.json / Plugin.php / class_alias)+ 建表 + 路由注册 + 后台 CRUD(能存能列,前台不弹) | 后台能增删改查;plugin_hygiene.py 0 FAIL | ✅ 完成(冒烟 33 PASS / 探针 54 PASS) |
| ~~P0.5~~ | ~~<template> 放 <head> 的可行性实测~~ | — | ❌ 取消(方案④无此分叉点) |
| P1 | 弹窗注入链路 + 样式 + JS(显示 / 关闭 / ESC / 点遮罩关闭 / 焦点管理) | HTTP 冒烟:前台含弹窗 DOM;后台不含 | ✅ 完成(前台冒烟 44 PASS) |
| P2 | 规则引擎(受众 / 时间窗 / 页面范围 / 频率)+ 已读双轨持久化 + 候选集缓存 | 逻辑探针:各规则组合断言;查询预算实测 ≤1 | ✅ 完成(探针 65 PASS) |
| P3 | 新用户引导(created_at 判定)+ 后台已读统计 + 排序 + 实时预览 | 探针:新老账号分别断言;排序端到端 | ✅ 完成(排序冒烟 25 PASS) |
| P4 | 验证(门禁 + 探针 + HTTP 冒烟)+ 说明.md + 方案文档同步 | 全套门禁绿;docs/ 与 说明.md 更新 | ✅ 完成 |
实施修订(2026-09-30):原约定「每阶段末暂停确认」,实际按用户指示
「一口气做完,我整体进行验收」执行 —— P1~P4 连续完成,由用户整体验收。
5.1 验证结果汇总(2026-09-30)
| 检查项 | 结果 |
|---|---|
php -l 双版本(8.2.9 / 8.0.2) | 14/14 通过 |
node --check(script.js / admin.js) | 通过 |
php_var_cjk_check.php(全角变量陷阱) | 0 命中 |
plugin_hygiene.py 9 项门禁 | FAIL = 0 |
| i18n 键比对 | 三语 88 键零差异 + 键序一致 + 引用全覆盖 |
| P0 后台 CRUD 冒烟 | 33 PASS / 0 FAIL |
| P0 逻辑探针 | 54 PASS / 0 FAIL |
| P1/P2 前台弹窗冒烟 | 44 PASS / 0 FAIL |
| P2 规则引擎探针 | 65 PASS / 0 FAIL |
| P3 排序专项冒烟 | 25 PASS / 0 FAIL |
| 查询预算 | 无候选 0 / 游客 0 / 登录 1(红线 ≤5) |
| 探针残留 | 公告总数 0,error.log 中 notice_center 0 条 |
六、风险与坑(逐条对应规范)
| # | 风险 | 依据 | 对策 |
|---|---|---|---|
| 1 | 游客缓存串号 —— 把某用户的已读状态烘焙进整页缓存,所有游客共享 | PageCache.php:9(仅游客 + 永久缓存) | 弹窗 DOM 里只放公共内容;data-ntc-read / data-ntc-csrf 只对登录用户渲染 |
| 2 | 弹窗被核心浮层盖住 | z-index 实测天花板 100005 | 取 100010 |
| 3 | 后台弹层没有样式(拿不到核心表单外观,且零日志) | 规范 §11.3 第 22 条(medal / adslot 两次踩过) | 弹层 markup 必须留在 <main class="admin-content"> 内 |
| 4 | 改 layout_head_end 后游客仍看到旧页 | 规范 §11.5 第 31 条 | 后台任何写操作后 PageCache::invalidate() |
| 5 | 卸载留孤儿表 | 规范 §4.1(目录名含下划线 → 生命周期静默跳过) | Plugin.php 末尾 class_alias + uninstall() 清表清 settings |
| 6 | 已读上报被 SW 缓存吞掉 | 规范 §11.5 第 35 条(同源 GET 非导航一律 Cache-First) | 上报用 POST |
| 7 | 每请求查询超红线 | 规范 §11.4 第 24 条(≤5) | 候选集文件缓存 + 标记文件短路 + 无候选零查询 |
| 8 | 零内联违规(<style> / <script> / style="" / on*) | 规范 §7.3 | markup 全在 views/.php,样式走 assets/.css,行为走 assets/*.js;交付前跑门禁 |
| 9 | 渲染钩子里 $this 不可用 → HTML 半截 + HTTP 200 + 零告警 | 规范 §11.24 | 钩子/片段里一律 htmlspecialchars() + I18n::get(),icon() 改用 closest() 或 data-* |
| 10 | 改 DDL 不 bump 版本戳 → 新表建不出来 | 记忆 + adslot 范式 | SCHEMA_VERSION + 手删旧 .ok |
| 11 | CREATE INDEX IF NOT EXISTS 不重建同名索引 | 记忆红线 | 改索引换名 |
| 12 | 弹窗与 htmx 局部刷新冲突 | 规范 §11.9(htmx 继承属性污染) | 弹窗只在整页加载注入,不进 htmx 片段 |
| 13 | 富文本二次转义 / XSS | Content::sanitizeHtml() 是唯一防线 | 存原文、渲染时 formatPostContent() 裸 echo;不自己拼 HTML |
| 14 | 插件自造管理员判定 = 越权高危 | 记忆(已复发 2 次) | 后台控制器继承 app\Controllers\Admin\BaseController(构造函数即门禁) |
| 15 | 日期字符串比较 | YYYY-MM-DD 字典序 ≡ 时间序 | 不做 strtotime(),避免时区歧义 |
| 16 | layout_body_end 是本地新增钩子 → 合并上游会丢(实施后新增) | 核心改动标记 [FLINTHUB-CUSTOM 2026-09-30](main.php:532-534) | 与上游合并 main.php 时必须保留该段;钩子定义见规范 §9.1 #43 |
| 17 | layout_body_end 无参数 → 后台页误注入弹窗(实施后新增) | 该钩子不带 template(不像 layout_head_end) | Plugin::isBackend('') 用请求路径 /admin 前缀兜底;已有冒烟断言「后台不注入」 |
| 18 | 改布局级钩子后游客仍看到旧页 | 规范 §11.5 第 31 条 | 后台任何写操作统一走 afterWrite():重建候选集缓存 + PageCache::invalidate();新增写路径别忘了挂它 |
| 19 | 编辑器提交的是 base64 提交态,漏解码 → 前台渲染成一大段乱码(实施后补修,2026-09-30) | 核心同口径:ThreadController:110 / PostController:77 / Admin\ThreadController:83 | 保存时 Content::decode(trim($_POST['content'])) 再入库;症状极隐蔽(后台编辑页因 decodeContent() 回显一切正常)。详见 说明.md §7.12 |
七、决策记录(已全部落定)
| # | 决策点 | 结论 |
|---|---|---|
| 1 | 插件目录名 | notice_center(含下划线 → 已按规范 class_alias) |
| 2 | 游客弹不弹 | 弹(游客已读走 localStorage,不发请求) |
| 3 | 新用户引导形态 | 单弹窗(复用公告引擎 + 富文本;不做多步骤引导) |
| 4 | 已读口径 | 弹出即已读(显示后立刻上报) |
| 5 | 多条同时满足 | 只弹优先级最高的一条 |
| 6 | 已读统计 | 做(列表页「已读」列,取 ntc_reads 计数) |
| 7 | 弹窗 DOM 落点 | 新增 layout_body_end 钩子 + 服务端直出(见 §2.1 实施修订) |
7.1 自主决策(不在原始 5 问内,实施时新增)
| # | 决策 | 理由 |
|---|---|---|
| A | 新增总开关设置项 notice_center_enabled(缺省开) | 出问题能一键停用,不必逐条停用公告;关掉后前台连 CSS/JS 都不加载 |
| B | 排序用整表重排 priority,而非相邻互换 | 相邻互换在 priority 有重复值时等于没换 |
| C | 后台「已读统计」在 P0 就落地(列表页「已读」列) | 取数成本极低(一次 GROUP BY),不必等 P3 |
本文档已按实现结果就地同步(凡与原始设计不一致处均带
> 实施修订标记);插件侧另有
plugins/notice_center/说明.md作为面向使用者的文档。