[开发]排行榜插件开发方案(右栏 · 4 tab)

👑Lv.11 元老 🌞 骨干会员
2026-10-26 01:55:34

本文由 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. 挂载点 —— 核心现没有「站点统计下方」这个钩子,要精准就得给核心加 1 行扩展点;
  2. 站长霸榜 —— 实测 uid=1(站长本人)在积分/主题/回复三榜全部第一,是否排除;
  3. 签到榜数据源 —— 实测只能读 daily_checkin 表,走 points_log 会严重失真(4 条 vs 63 条)。

二、现状核查(实测,非推断)

2.1 右栏结构(app/Views/_components/right_sidebar.php,274 行)

渲染顺序(行号为实测):

序区块行号显示条件
1发帖人信息栏 + 主题卡片 或 用户面板41–206详情页 / 其他页
2🔌 钩子 right_sidebar_after210无条件
3站点统计面板213–240$showSiteStats = 首页 / /forum / /forum/category/ / /blog
4热门标签面板243–258有 tags 且非 /tags 页
5🔌 钩子 sidebar_above_friend_links262无条件
6友情链接面板264–274上述页面 + friend_links 插件已启用

  • 「站点统计正下方」= 第 240 行之后、第 243 行之前 —— 该位置当前没有钩子。
  • main.php:340 无条件 include right_sidebar.php;mobile_sidebar.php 是独立组件,只有热门标签 + 友链,没有站点统计。
  • 移动端(≤768px)由 main.php:335 include mobile_sidebar.php,sidebar_above_friend_links 在移动端也会触发一次(双触发)。

2.2 数据源实测(本机库)

Tab数据源现状行数备注
积分data/meta/business.sqlite → users.points285 用户(278 人 points>0)—
主题data/meta/main_index.sqlite → topic_index86 行(83 行有效)status=0 AND deleted_at IS NULL
回复data/meta/main_index.sqlite → reply_index1596 行(1580 行 status=0)有 (uid,status) 索引
签到plugins/daily_checkin/data/daily_checkin.sqlite → daily_checkin63 行 / 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-panel522bg-card / 1px border / radius 10px / padding 16px / margin-bottom 16px✅ 直接用
.mn-panel-title53112px / 700 / uppercase / letter-spacing .6px / 下边框 / flex+gap 6✅ 直接用
.mn-stat-list1281flex column / gap 2px✅ 直接用
.mn-stat-item1287flex / space-between / 13px / padding 6px 0 / 下边框✅ 直接用(左=标签,右=数值,天然适配榜单)
.mn-stat-label1298text-secondary / flex+gap 5px✅ 直接用
.mn-stat-value1304text / 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 结构

排行榜

积分 ... ×4
1 flinthub 9573
...

视觉一致性要点:

  • 外层 / 标题 / 列表 / 条目 / 数值 全部复用核心类,不新造同类名 → 与站点统计面板像素级一致。
  • 只有 .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:签到榜口径

选项展示数据
累计签到天数(推荐)29COUNT(*)
当前/最高连续天数24MAX(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
依赖零 JSassets/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. 决策 1:方案 A(核心 +1 行,位置精准)还是方案 B(零核心改动,位置偏下)?若选 A,移动端要不要也加?
  2. 决策 2:站长/管理员是否排除出榜?
  3. 决策 3:签到榜按「累计天数」还是「连续天数」(还是两者)?
  4. 决策 4:tab 用纯 CSS 还是外挂 JS?
  5. 每榜显示条数:默认 10 条,可否?(右栏面板高度约 10 × 30px + tab 条)
  6. 缓存 TTL:默认 300s,可否?

确认后我按 P0→P4 分阶段做,每阶段末停下来给你看。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 4 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入