本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/floor_reply/说明.md。
楼中楼(floor_reply)插件说明
版本:1.3.1 | 作者:FlintHub | 状态:✅ 已启用
功能:在帖子楼层(posts)下方支持对楼层的再回复(楼中楼),支持 @ 提及用户(高亮 + 跳转个人主页);
并在有人回复楼层 / @ 到某人时发送通知,通知可深链直达那一条楼中楼。
一、功能简介
- 楼中楼:对某条回复(楼层)进行再回复,数据存独立库,不占用核心楼层数
- @ 提及:回复时 @ 指定用户,前端高亮显示,点击跳转其个人主页
mention_user_id只记录第一个被 @ 的用户,仅用于列表高亮渲染- 通知则覆盖全部被 @ 的用户(见第四节)
- 回复计数联动:回复成功后同步更新帖子的回复数与最后回复时间(
Thread::incrementReplyCount) - 积分奖励:回复成功触发核心积分奖励(
Points::markReplied/Points::award),并计入站点统计 - 通知 + 深链(V1.1.0):楼中楼回复会通知楼层作者与被 @ 的人(楼主可选),
点通知直达该条楼中楼所在页并高亮
- 站内表情(V1.2.0):回复框右侧「表情」按钮弹出表情面板(60 个站内表情,复用核心编辑器同款面板样式)。
插入的是 :code: 文本(如 ),入库仍是纯文本,渲染时才由核心 
Emoji::parse() 转成
<img class="emoji-emotion">;存量纯文本回复与表情可混存,无需数据迁移
- ⚠️ V1.3.1 起面板改为「数据懒取 + JS 建 DOM」:页面源码里不再有任何表情标记
(旧版把 60 个格子内联成页面级 <template class="fr-emoji-tpl"> ≈ 12.6 KB,占帖子页 11.4%)。
现由 script.js 首次点表情按钮时 fetch 一次 GET /floor-reply/emojis,再在 JS 里建 DOM 挂 body 常驻
二、目录结构
plugins/floor_reply/
├── plugin.json # 插件清单(6 个钩子 + admin_url + permissions)
├── Plugin.php # 独立库连接 + 建表 + 楼中楼统计/计数 + 配置读写 + pageOfReply()
├── FrontController.php # 楼中楼回复 / 列表接口(?focus= 深链)/ 表情数据接口(V1.3.1)
├── AdminController.php # 后台通知设置页(V1.1.0 新增)
├── hook/
│ ├── init_after.php # 幂等建表兜底
│ ├── route_register.php # 前台路由(回复/列表/删除/表情数据)
│ ├── admin_route_register.php # 后台路由(V1.1.0 新增,需 route:admin 权限)
│ ├── layout_head_end.php # 注入楼中楼 CSS/JS(仅 thread/show);把表情数据地址挂 三、数据表(插件独立库 plugins/floor_reply/data/floor_reply.sqlite)
| 表 | 用途 |
|---|---|
fr_replies | 楼中楼回复(post_id / thread_id / user_id / mention_user_id / content / created_at,索引 idx_fr_post、idx_fr_thread) |
- 建表:
activate()+init_after双保险(幂等 DDL) - 卸载:
uninstall()删表 并清理核心settings表里本插件的配置键(会清空全部楼中楼数据,请谨慎)
四、路由
前台
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /floor-reply/list/{id} | 某楼层({id} = 楼层 post_id)的楼中楼列表片段(htmx 局部渲染,每页 5 条)<br>可选 ?page=N 翻页;可选 ?focus=<楼中楼ID> 强制渲染该条所在页并标记高亮(通知深链用) |
| POST | /floor-reply/reply | 提交楼中楼回复(CSRF + 频控) |
| POST | /floor-reply/delete | 删除楼中楼回复(作者本人 / 管理员 / 楼主) |
| GET | /floor-reply/emojis | V1.3.1 表情数据(JSON,{v,list:[[code,中文名,文件名],…]},60 条)<br>供 script.js 首次点表情按钮时懒取;与用户无关 → 允许匿名 + 长缓存(public, max-age=604800, immutable,URL 带 ?v=<Emoji.php mtime> 便于改表情即失效) |
后台
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /admin/floor-reply | 通知设置页 |
| POST | /admin/floor-reply/save | 保存设置(CSRF) |
⚠️ 后台路由需在
plugin.json声明route:admin;写全局设置需system:settings。二者均已声明。
五、通知机制(V1.1.0)
收件人规则
一条楼中楼回复可能同时命中三类收件人,按优先级去重,保证「每个收件人最多收到 1 条」:
| 优先级 | 收件人 | 通知类型 | 默认 |
|---|---|---|---|
| 1 | 被 @ 到的人 | mention | ✅ 开 |
| 2 | 楼层作者 | floor_reply | ✅ 开 |
| 3 | 楼主(帖子作者) | floor_reply | ❌ 关 |
- 回复人自己永不收到通知(与核心
notifyReply/notifyQuote/notifyVote一致) - 楼主默认不通知:楼中楼是「楼层内部的对话」,核心
notifyReplyAggregated()已负责「帖子有新回复」的聚合通知 - 通知写入核心
business.sqlite的notifications表:related_id= 楼层 post_id、related_type='floor'
深链格式
/thread/{主题ID}?pid={楼层ID}&fr={楼中楼ID}#fr-{楼中楼ID}三段分工:
?pid=→ 核心ThreadController::show()经Post::pageOf()算出楼层所在页码并跳转?fr=→ 本插件?focus=参数算出该条楼中楼所在页并标记高亮#fr-{id}→ 原生锚点兜底,script.js负责滚动到视野中央
通知文案键(核心语言包 lang/{zh,en,zh_tw}.php)
| 键 | 用途 |
|---|---|
plugin.notifications.floor_reply | 「{user} 回复了你在「{title}」中的回复」 |
plugin.notifications.floor_reply_owner | 「{user} 在「{title}」的楼中楼中回复了 {target}」(楼主专用,避免误称「你的回复」) |
plugin.notifications.mentioned | 「{user} 在回复中提到了你,主题「{title}」」 |
六、后台设置项
配置存核心 settings 表,键前缀 floor_reply_。
| 键 | 默认 | 说明 |
|---|---|---|
floor_reply_notify_floor_author | 1 | 通知楼层作者 |
floor_reply_notify_mention | 1 | 通知被 @ 的人 |
floor_reply_notify_thread_owner | 0 | 通知楼主(默认关) |
floor_reply_auto_expand | 1 | 打开帖子时自动展开已有楼中楼(通知深链不受此开关影响) |
七、安全与规范
- 提交受 RateLimiter 频控(3 次 / 30 秒)保护,防刷分漏洞
- 回复内容入库前
strip_tags(),输出经$this->e()/renderMentions()转义,防 XSS - 全部 PDO 预处理绑定;ID 一律
(int)强转 - 后台设置页表单带
csrf隐藏域,服务端Csrf::verifyOrDie()校验 - 楼中楼仅占独立库,不写核心帖子表结构,卸载/禁用对核心数据零污染
- 样式全部走
--mn-*主题变量(含 fallback),双主题(浅色 / 暗夜星辰)可读 - 表情:入库为
:code:纯文本(与核心编辑器同格式),渲染时才转<img>,天然防注入
(renderMentions() 的顺序固定为「转义 → @ 替换 → Emoji::parse()」,表情标签不会被二次转义)
- ⚠️ 渲染 src 必须查
Emoji::EMOJI_MAP取文件名:code与文件名并非恒等(ok→OK.png,
Windows 大小写不敏感能跑、Linux 会 404)。表情数据接口(V1.3.1)吐的就是 EMOJI_MAP 后的文件名,
前端不再自己拼 code + '.png'
- 表情面板 DOM(V1.3.1 起):由
script.js首次点表情按钮时fetchGET /floor-reply/emojis,
再在 JS 里建 DOM 挂到 body 常驻(htmx 整块换片段也清不掉)。
数据地址 / 面板 aria-label 由 hook/layout_head_end.php 挂在本插件 <script> 自身的
data-fr-emoji-url / data-fr-emoji-label 上,script.js 用 document.currentScript 读(取不到时回落默认值)
- ⚠️ 单一数据源 =
app/Helpers/Emoji.php(EMOJI_LIST+EMOJI_MAP):新增表情只改那一处,
接口与前端自动跟上,不需要重新生成任何产物
八、注意事项
- 依赖
controller_view_before一次性注入全帖楼中楼数据,避免每楼层一次查询 - 删除楼层(post)时不自动清理其楼中楼(历史遗留记录保留)
- 删除楼中楼不撤回已发出的通知(通知按「历史事件」处理)
- 积分奖励 / 回复计数联动为核心约定,流程调整时需同步
Plugin.php内相关方法 - 改动
plugin.json的 hooks 后必须重建plugins/plugins_cache.json(删文件或调Plugin::refresh()),否则新钩子不生效 - 通知深链的页码换算口径与核心
Post::getByThread()严格同桶同表同排序;若核心改了楼层排序规则,Post::pageOf()需同步调整 - 表情面板回归套件:
.workbuddy-ai/tmp/fr_emoji_probe/test_picker.cjs(jsdom 真实 DOM + 真实脚本,
40 断言;--old 跑 1.3.0 旧脚本做 A/B 反证,须 21 条翻红)。改动表情面板前先跑它:
```bash
NODE_PATH=C:/Users/Administrator/.workbuddy-ai/binaries/node/workspace/node_modules \
node .workbuddy-ai/tmp/fr_emoji_probe/test_picker.cjs
```
依赖输入(可重抓):fr79b.html(帖子页)、fr_frag_1422.html(带登录态抓的楼中楼片段)、
fr_emoji.json(接口响应)、fr_ab_old_script.js(A/B 基线,从 1.3.0 发布包里抽出)