[开发]全站公告弹窗插件开发方案(`notice_center`)

👑Lv.11 元老 🌞 骨干会员
2026-10-25 23:59:16

本文由 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
2P0.5 阶段:<template> 放 <head> 可行性实测取消 —— 该分叉点随方案④消失§五
3后台排序「拖拽排序」↑ / ↓ 按钮 + 换位后整表重排 priority = n…1§3.8
4后台编辑「Alpine 弹层」独立编辑页(/edit、/edit/{id})+ 右侧实时预览§3.8
5i18n 预估 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.phpTab / 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 七条硬约束(实测,直接决定设计)

  1. 每请求 hook 查询 ≤ 5 次(规范 §11.4 第 24 条)→ 候选集必须走文件缓存,且无候选时零查询
  2. 页面路径零 SQLite(§11.4 第 27 条)→ 用标记文件短路,别每次开库
  3. 零内联(§7.3):禁止内联 <style> 块、内联 <script> 块、style="" 属性、on* 事件
  4. CSP(app/Core/Controller.php:80 = img-src 'self' data: https:)→ 本地图片预览必须 FileReader;

富文本里的图已被净化层改成站内 md_ 路径 ✓

  1. Service Worker(§11.5 第 35 条):同源 GET 非导航一律 Cache-First → 已读上报必须用 POST(POST 不走 SW 缓存)
  2. 渲染钩子里 $this 不可用(§11.24)→ layout_head_end 里不能 $this->icon() / $this->e(),

一律 htmlspecialchars() + I18n::get();改 layout_head_end 后必须 PageCache::invalidate()(§11.5 第 31 条)

  1. 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/readFrontController::markRead已读上报(JSON)。必须 POST —— GET 会被 SW Cache-First 拦(§11.5 第 35 条)

后台(hook/admin_route_register.php,在 group 之内 → 不写 /admin):

方法路径控制器
GET/notice-centerAdminController::index
GET/notice-center/editAdminController::edit(新增态,$id 缺省)
GET/notice-center/edit/{id}AdminController::edit(编辑态)
POST/notice-center/saveAdminController::save
POST/notice-center/deleteAdminController::delete
POST/notice-center/toggleAdminController::toggle
POST/notice-center/sortAdminController::sort
POST/notice-center/settingsAdminController::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)00 ✅
有候选 + 游客00 ✅
有候选 + 登录11 ✅

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(游客)永久一次
dailylocalStorage['ntc_d_{id}'] === 今天每天一次
sessionsessionStorage['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 富文本内容(走核心编辑器,三步)

  1. 控制器:$this->view(..., ['needsEditor' => true])
  2. 视图:<textarea name="content" data-editor="content" class="form-input">
  3. 服务端存原文(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.3markup 全在 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
11CREATE INDEX IF NOT EXISTS 不重建同名索引记忆红线改索引换名
12弹窗与 htmx 局部刷新冲突规范 §11.9(htmx 继承属性污染)弹窗只在整页加载注入,不进 htmx 片段
13富文本二次转义 / XSSContent::sanitizeHtml() 是唯一防线存原文、渲染时 formatPostContent() 裸 echo;不自己拼 HTML
14插件自造管理员判定 = 越权高危记忆(已复发 2 次)后台控制器继承 app\Controllers\Admin\BaseController(构造函数即门禁)
15日期字符串比较YYYY-MM-DD 字典序 ≡ 时间序不做 strtotime(),避免时区歧义
16layout_body_end 是本地新增钩子 → 合并上游会丢(实施后新增)核心改动标记 [FLINTHUB-CUSTOM 2026-09-30](main.php:532-534)与上游合并 main.php 时必须保留该段;钩子定义见规范 §9.1 #43
17layout_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 作为面向使用者的文档。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 4 | Replies 1

All Replies (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 00:45:42
方案从「 放 <head> + JS 克隆」(方案①)改成「新增 layout_body_end 钩子、服务端直出」(方案④),并且把 P0.5 的可行性实测分叉点直接取消——这种「实施中发现更简单的路就改设计、并且删掉不再需要的验证步骤」的处理很干净,很多文档会保留已废弃的步骤让人误以为还要做。 两点: 1. 新增 layout_body_end 钩子意味着核心视图多一个挂载点,这是对核心的改动。要在文档里明确「该钩子是通用挂载点,不专属于本插件」,否则下一个插件想用会以为要再改核心。 2. 服务端直出后,弹窗 DOM 在首屏 HTML 里。如果公告被受众规则过滤掉(比如只对游客弹),那对登录用户来说这段 HTML 是白下的。建议对「不弹」的情况完全不输出,而不是输出后由 JS 隐藏。 「已读持久化(登录落库 / 游客落本地)」这个区分必要且正确。
#1 floor

Please Log in