本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/排行榜插件开发方案_2026-09-27.md。
排行榜插件开发方案(右栏 · 4 tab)
日期:2026-09-27 | 依据:
docs/插件开发规范.md(1477 行,2026-09-25 版)状态:待确认(本文只出方案,不动手写插件代码)
建议插件名 / 目录名:
leaderboard(单词、无下划线 → 天然绕开规范 §4.1 的class_alias硬坑)
一、结论摘要
在右栏「站点统计」面板正下方新增一个 .mn-panel 面板,内含 4 个 tab(积分 / 主题 / 回复 / 签到),
每 tab 显示 TOP N 用户。视觉完全复用核心右栏类(.mn-panel / .mn-panel-title / .mn-stat-list / .mn-stat-item),
只有 tab 条与排名序号需要插件自建样式。
三个必须先拍板的点(见 §四):
- 挂载点 —— 核心现没有「站点统计下方」这个钩子,要精准就得给核心加 1 行扩展点;
- 站长霸榜 —— 实测 uid=1(站长本人)在积分/主题/回复三榜全部第一,是否排除;
- 签到榜数据源 —— 实测只能读
daily_checkin表,走points_log会严重失真(4 条 vs 63 条)。
二、现状核查(实测,非推断)
2.1 右栏结构(app/Views/_components/right_sidebar.php,274 行)
渲染顺序(行号为实测):
| 序 | 区块 | 行号 | 显示条件 |
|---|---|---|---|
| 1 | 发帖人信息栏 + 主题卡片 或 用户面板 | 41–206 | 详情页 / 其他页 |
| 2 | 🔌 钩子 right_sidebar_after | 210 | 无条件 |
| 3 | 站点统计面板 | 213–240 | $showSiteStats = 首页 / /forum / /forum/category/ / /blog |
| 4 | 热门标签面板 | 243–258 | 有 tags 且非 /tags 页 |
| 5 | 🔌 钩子 sidebar_above_friend_links | 262 | 无条件 |
| 6 | 友情链接面板 | 264–274 | 上述页面 + friend_links 插件已启用 |
- 「站点统计正下方」= 第 240 行之后、第 243 行之前 —— 该位置当前没有钩子。
main.php:340无条件 includeright_sidebar.php;mobile_sidebar.php是独立组件,只有热门标签 + 友链,没有站点统计。- 移动端(≤768px)由
main.php:335includemobile_sidebar.php,sidebar_above_friend_links在移动端也会触发一次(双触发)。
2.2 数据源实测(本机库)
| Tab | 数据源 | 现状行数 | 备注 |
|---|---|---|---|
| 积分 | data/meta/business.sqlite → users.points | 285 用户(278 人 points>0) | — |
| 主题 | data/meta/main_index.sqlite → topic_index | 86 行(83 行有效) | status=0 AND deleted_at IS NULL |
| 回复 | data/meta/main_index.sqlite → reply_index | 1596 行(1580 行 status=0) | 有 (uid,status) 索引 |
| 签到 | plugins/daily_checkin/data/daily_checkin.sqlite → daily_checkin | 63 行 / 13 个用户 | 有 consecutive_days 字段 |
实测 TOP5(当前数据):
积分:flinthub 9573 / Evan 261 / 我自逍遥 185 / ahfanxin 169 / wuliannan6066 115
主题:uid=1 → 64 / uid=275 → 6 / uid=262 → 3 / uid=264 → 3 / uid=263 → 2
回复:uid=1 → 53 / uid=168 → 23 / uid=5 → 23 / uid=96 → 22 / uid=28 → 22
签到:uid=1 → 累计29(最高连续24) / uid=264 → 11(9) / uid=262 → 6(3) / uid=265 → 5(3) / uid=266 → 2(2)⚠️
uid=1=flinthub= 站长本人(见 MEMORY §9 红线),四个榜里三个都是他第一。
签到榜数据源的关键实测:
points_log 里 related_type='daily_checkin' 的记录 = 4 条
daily_checkin 表里的签到记录 = 63 条→ 差 15 倍,说明历史签到并没有全部写 points_log(早期版本/积分为 0 时跳过)。
结论:签到榜不能走 points_log,必须直读 daily_checkin 表。
2.3 UI 可复用资产(assets/css/modern.css)
| 类 | 行号 | 关键样式 | 排行榜能否直接用 |
|---|---|---|---|
.mn-panel | 522 | bg-card / 1px border / radius 10px / padding 16px / margin-bottom 16px | ✅ 直接用 |
.mn-panel-title | 531 | 12px / 700 / uppercase / letter-spacing .6px / 下边框 / flex+gap 6 | ✅ 直接用 |
.mn-stat-list | 1281 | flex column / gap 2px | ✅ 直接用 |
.mn-stat-item | 1287 | flex / space-between / 13px / padding 6px 0 / 下边框 | ✅ 直接用(左=标签,右=数值,天然适配榜单) |
.mn-stat-label | 1298 | text-secondary / flex+gap 5px | ✅ 直接用 |
.mn-stat-value | 1304 | text / 600 / 14px | ✅ 直接用 |
前台没有 tab 组件(实测:modern.css 里无 .mn-tab* / .tabs,只有 .mn-table)。
→ tab 条必须插件自建,且只能用既有 --mn-* 变量(规范 §11.7)。
三、技术设计
3.1 数据口径(4 个 tab)
-- ① 积分(核心库,1 次查询)
SELECT id, username, avatar, level, points FROM users
WHERE status='active' AND points > 0
ORDER BY points DESC, id ASC LIMIT :n
-- ② 主题(main_index,1 次聚合 + 1 次批量取用户名)
SELECT uid, COUNT(*) AS v FROM topic_index
WHERE status=0 AND deleted_at IS NULL
GROUP BY uid ORDER BY v DESC, uid ASC LIMIT :n
-- ③ 回复(main_index)
SELECT uid, COUNT(*) AS v FROM reply_index
WHERE status=0 GROUP BY uid ORDER BY v DESC, uid ASC LIMIT :n
-- ④ 签到(daily_checkin 独立库,只读)
SELECT user_id AS uid, COUNT(*) AS v, MAX(consecutive_days) AS m
FROM daily_checkin GROUP BY user_id ORDER BY v DESC, uid ASC LIMIT :n- ②③④ 拿到 uid 后统一走
\app\Models\User::batchGetUsers($uids)(规范 §11.8.3:批量取用户数据禁止直查 users 表),
返回 [id => [username, avatar, level]],逐请求静态缓存。
- ① 积分榜需要
points字段(batchGetUsers()不返回),走\app\Core\Database::getInstance()->fetchAll()一次查完,避免二次查询。 - 全部
LIMIT :n(默认 10);并列名次按uid ASC稳定排序。
3.2 缓存设计(性能红线)
规范 §十三:「单个插件每请求所有 hook 的查询总和 ≤ 5 次(红线)」,
而右栏每页都渲染 → 不能每次重算全表聚合。
方案:插件独立库缓存表 + TTL 懒刷新
CREATE TABLE IF NOT EXISTS lb_cache (
ckey TEXT PRIMARY KEY, -- 'points' | 'topics' | 'replies' | 'checkins'
cdata TEXT NOT NULL, -- JSON: [{"uid":1,"v":64}, ...]
updated_at INTEGER NOT NULL
);请求路径:
渲染钩子(每页)
└─ SELECT ckey, cdata, updated_at FROM lb_cache ← 1 次查询(全表仅 4 行)
├─ 数据新鲜 → 直接渲染
└─ 已过期 → 仍渲染旧数据(不阻塞),置 $GLOBALS['lb_need_refresh'] = true
route_after_dispatch(响应已产出,index.php:321)
└─ if (!empty($GLOBALS['lb_need_refresh'])) 重算 4 榜 → upsert ← 4 聚合 + 1 写- 正常请求 hook 查询 = 1(缓存表)+ 1(
batchGetUsers)= 2 次 ✅ 红线内。 - 过期请求额外 5 次(发生在响应之后,不影响用户等待)。
- 冷启动(缓存表为空):在渲染钩子里同步算一次(只此一次),避免首屏空面板。
- TTL 默认 300s(与生产云函数 5 分钟
cron_trigger.php节奏一致)。 route_after_dispatch是本项目已有先例的做法(规范 §11.20、user_level/hook/route_after_dispatch.php)。- ⚠️ 缓存表只存 uid + 数值,不存用户名/头像 → 用户改名改头像即时生效。
3.3 UI 结构
视觉一致性要点:
- 外层 / 标题 / 列表 / 条目 / 数值 全部复用核心类,不新造同类名 → 与站点统计面板像素级一致。
- 只有
.lb-tabs/.lb-tab/.lb-pane/.lb-rank/.lb-avatar/.lb-name是插件类。 - 插件类样式必须提权(规范 §5 红线:核心同特异性声明会后胜)——如
.lb-panel .lb-tab而非裸.lb-tab。 - 颜色**只用既有
--mn-***:tab 激活色--mn-primary、未激活--mn-text-muted、分隔线--mn-border-light、
头像占位底 --mn-bg-hover(写前逐条 grep 核对,--mn-danger / --mn-spectrum-* 已知不可用)。
- 前三名不做金银铜硬编码色(夜间主题会失读)→ 仅用
--mn-primary加粗序号。 - 空榜单(该 tab 无数据)→ 渲染一行 muted 文案(i18n 键),不整块隐藏(面板已存在,隐藏会让 tab 空转)。
3.4 文件清单
plugins/leaderboard/
├── plugin.json # hooks: init_after / <侧栏钩子> / route_after_dispatch / layout_head_end
├── Plugin.php # db() / ddl() / activate() / deactivate() / uninstall()
│ # + fetchTop*() 4 个聚合 + getBoard() 读缓存 + refreshCache()
├── hook/
│ ├── init_after.php # 惰性建表兜底(activate 可能被静默跳过)
│ ├── <侧栏渲染钩子>.php # 见 §四·决策 1
│ ├── route_after_dispatch.php # TTL 懒刷新
│ └── layout_head_end.php # 按模板条件加载 CSS/JS(带 ?v=filemtime)
├── assets/
│ ├── style.css # tab 条 + 榜单条目增量样式(~1.5KB)
│ └── script.js # 仅当选「JS tab」方案时才需要
├── lang/{zh,en,zh_tw}.php # 键前缀 plugin.leaderboard.*
└── data/leaderboard.sqlite # 由 Plugin::db() 自动创建不做的部分(极简取向,符合「没有实际用途的控件就不要出现」):
- ❌ 独立榜单页
/leaderboard(无route_register) - ❌ 标题「更多 →」链接(避免死按钮,与站点统计标题行为一致)
- ❌ 后台配置页(TOP N / TTL 先写成
Plugin.php常量)
3.5 i18n 键(初版,三语同步)
plugin.leaderboard.title 排行榜
plugin.leaderboard.tab_points 积分
plugin.leaderboard.tab_topics 主题
plugin.leaderboard.tab_replies 回复
plugin.leaderboard.tab_checkins 签到
plugin.leaderboard.empty 暂无数据⚠️ 验收标准 = 三语键集合零差异(不是「值非空」),规范 §十二 / MEMORY §7。
四、需拍板的 4 个点
决策 1:挂载点(最关键)
| 方案 A(推荐) | 方案 B | |
|---|---|---|
| 做法 | 核心 right_sidebar.php:240 后新增钩子 right_sidebar_after_site_stats | 复用现成钩子 sidebar_above_friend_links(:262) |
| 位置 | 站点统计正下方(热门标签之上) | 热门标签下方、友链上方 |
| 核心改动 | 右栏 +1 行(+ 可选移动端 +1 行)+ 更新规范 §9.1 表 | 零核心改动 |
| 显示页面 | 仅 $showSiteStats 的页面(首页/论坛/分类/博客) | 所有带右栏页面 + 移动端 |
| 移动端 | 需另行决定(移动端无站点统计面板) | 自动双端都有 |
- 有先例:
thread_show_top(#41)就是 2026-09-25 为核心新增的钩子,规范明确允许「核心新增扩展点」。 - 若选 A 且要移动端也显示:在
mobile_sidebar.php:18(热门标签之前)再加一次同名钩子 → 该钩子变双触发,
钩子文件顶部禁声明函数/类(规范 §9.0/§9.3 红线)。
决策 2:站长是否霸榜
实测 uid=1(flinthub,站长本人)在积分 / 主题 / 回复三榜均第一,且差距悬殊(9573 vs 261)。
| 选项 | 说明 |
|---|---|
| 不排除(推荐) | 榜单 = 真实数据,不造假;站长第一本来也是事实 |
| 排除管理员 | 需读 user_groups 判定(必须走 Auth::isAdmin() 口径,禁止自造判定——规范红线,已复发 2 次) |
| 只排除指定 uid | 加一个常量白名单(如 EXCLUDE_UIDS = [1]) |
决策 3:签到榜口径
| 选项 | 展示 | 数据 |
|---|---|---|
| 累计签到天数(推荐) | 29 | COUNT(*) |
| 当前/最高连续天数 | 24 | MAX(consecutive_days) |
| 两者都显示 | 29 (连续24) | 右栏宽度有限,会挤 |
⚠️ 无论选哪个,数据源都必须是
daily_checkin表(见 §2.2 实测)。这属于规范 §11.6「插件间禁止直耦」的灰区:不调用
\Plugin\DailyCheckin\Plugin::db()、不写它的表,仅以只读方式直开它的 sqlite 文件,并用
Plugin::isActivated('daily_checkin')+ 文件存在性双守卫。若你希望严格合规,替代路径是给
daily_checkin加一个自定义钩子(改那个插件),但代价更大。
决策 4:tab 切换实现
| 纯 CSS radio(推荐) | 外链 JS | |
|---|---|---|
| 依赖 | 零 JS | assets/script.js |
| 首屏 | 4 个 pane 全渲染,CSS 控制显隐 | 同左(或 AJAX 按需) |
| 记忆选中 tab | ❌ 刷新回默认 | ✅ 可 localStorage |
| 缺陷面 | 零(无 JS 运行时风险) | 有(本项目有「点按钮没反应 = JS TypeError」的历史坑) |
| 规范符合性 | ✅ 零内联红线只禁内联,不要求必须有 JS | ✅ |
五、实施阶段(每阶段末暂停确认)
| 阶段 | 内容 | 产出 |
|---|---|---|
| P0 | 骨架:plugin.json / Plugin.php(db/ddl/activate/deactivate/uninstall)/ hook/init_after.php / 三语 lang/ | 激活插件 → leaderboard.sqlite 建表成功 |
| P1 | 数据层:4 个聚合方法 + refreshCache() + getBoard() + TTL 判定 | CLI 探针跑出 4 榜 TOP10,与手写 SQL 结果一致 |
| P2 | 渲染:侧栏钩子 + 面板 HTML(复用核心类)+ CSS tab 条 | 右栏面板出现,4 tab 可切,双主题可读 |
| P3 | 缓存钩子:route_after_dispatch 懒刷新 + 冷启动同步算 | 探针验证:TTL 内 0 次重算、过期后恰好 1 次;每请求查询数 = 2 |
| P4 | 收尾:Plugin::refresh() 重建缓存 / 零内联检查 / i18n 三语比对 / 双主题走查 / 说明.md | 可交付 |
六、风险与坑(针对本插件,逐条对应规范)
| # | 风险 | 依据 | 处置 |
|---|---|---|---|
| 1 | 渲染钩子里用 $this->e() → HTML 渲染一半断掉、HTTP 仍 200、无告警 | 规范 §11.24 / MEMORY §8 | 钩子内一律 htmlspecialchars($s, ENT_QUOTES, 'UTF-8'),发真实 HTTP + 查 protected/error.log 才算验过 |
| 2 | 自造管理员判定 → 越权 | 规范 §8 / MEMORY §8(已复发 2 次) | 若做排除管理员,只能用 Auth::isAdmin() |
| 3 | 每请求查询超红线 | 规范 §十三(≤5 次) | 缓存表方案 → 正常请求 2 次 |
| 4 | 核心同特异性声明后胜 → 插件样式静默失效 | MEMORY §5 | 插件类一律提权(.lb-panel .lb-tab) |
| 5 | 夜间主题浅底浅字 | 规范 §11.7 | 只用既有 --mn-*,写前 grep 核对;禁硬编码色 |
| 6 | 内联 <style>/<script>/on* | 规范 §7.3(P0 一票否决) | 全部收敛到 assets/;发布前跑零内联 grep |
| 7 | 钩子文件声明顶层函数/类 → 双触发钩子第二处静默消失 | 规范 §9.0 | 钩子文件不声明任何顶层符号 |
| 8 | 改 hooks 清单后钩子不生效 | 规范 §三 | 改完必须 \app\Helpers\Plugin::refresh() 重建 plugins_cache.json |
| 9 | 直读插件库拿到 WAL 旧快照 | MEMORY §10 | 缓存表读写全走 Plugin::db()(应用内 API),不用裸 PDO 开文件 |
| 10 | 部署后仍看旧右栏 | 规范 §9.5 / MEMORY §2 | 改完走后台「维护→清理缓存」(pages/*.html 永久缓存) |
七、待你确认
- 决策 1:方案 A(核心 +1 行,位置精准)还是方案 B(零核心改动,位置偏下)?若选 A,移动端要不要也加?
- 决策 2:站长/管理员是否排除出榜?
- 决策 3:签到榜按「累计天数」还是「连续天数」(还是两者)?
- 决策 4:tab 用纯 CSS 还是外挂 JS?
- 每榜显示条数:默认 10 条,可否?(右栏面板高度约 10 × 30px + tab 条)
- 缓存 TTL:默认 300s,可否?
确认后我按 P0→P4 分阶段做,每阶段末停下来给你看。