LiteLog 首页模式(Homepage Mode)开发文档
版本:1.0 适用范围:LiteLog 内核 ≥ 1.0 更新日期:2026-10-08
目录
1. 系统概述
首页模式(Homepage Mode) 是 LiteLog 首页中间栏可插拔的"内容提供者"。用户可以在后台通过卡片式选择器,随时切换首页显示的内容类型。
核心理念:
- 🎯 单一职责:每个模式只负责"渲染首页中间栏的内容"
- 🧩 可插拔:放在指定目录即自动注册,无需修改核心代码
- 🎨 主题化:每个模式有独立的主题色(蓝 / 绿 / 紫 / 橙 / 粉 / 青)
- 🔌 依赖声明:
isAvailable()让后台自动识别模式是否可用 - 📦 多来源:内置 / 模块 / 插件 三种注册来源
当前已支持的模式:
| ID | 名称 | 主题色 | 来源 |
|---|---|---|---|
blog | 博客 | blue | 内置 |
forum | 论坛 | green | 内置 |
novel | 读书 | purple | 模块 |
2. 核心概念
| 概念 | 说明 |
|---|---|
| HomepageMode | 首页模式的类定义,继承 HomepageMode 抽象基类 |
| HomepageModeRegistry | 全局注册表,收集所有已注册的模式 |
| Module | 放在 content/homepage_modes/ 下的独立模块(自动扫描加载) |
| Builtin | 内核内置的模式(blog / forum) |
| Plugin Mode | 插件通过钩子动态注册的模式 |
| Priority | 排序权重,数字越小越靠前 |
| Theme | 卡片主题色,影响后台选择器和前台可选样式 |
数据流
内核启动
→ 引入 homepage_mode.php
→ 定义 HomepageMode 基类 + HomepageModeRegistry
→ 注册内置模式(Blog / Forum)
首次访问首页
→ HomepageModeRegistry::init()
→ 注册内置模式
→ 扫描 content/homepage_modes/ 目录
→ 触发 register_homepage_modes 钩子(插件注册)
→ 按 priority 排序
后台设置页
→ HomepageModeRegistry::all() 获取全部可用模式
→ 每个模式渲染一张卡片
→ 用户选择 → 保存到 Option('homepage_main_content')
前台首页
→ HomepageModeRegistry::getCurrent() 获取当前模式
→ mode->render($ctx) 输出主栏内容
3. 目录结构
3.1 模块模式(推荐)
独立的模式放在 content/homepage_modes/:
content/
└── homepage_modes/
├── novel/ # 模式目录名 = 文件名
│ └── novel.php # 主文件(必须同名)
├── video/ # 另一个模式
│ └── video.php
└── your-mode/ # 自定义模式
└── your-mode.php
关键规则:
- 目录名与主文件名必须一致
- 主文件必须
require时自动执行HomepageModeRegistry::register() - 目录名只允许
[a-zA-Z0-9_-]
3.2 插件模式
如果模式与插件强绑定(如小说插件里定义读书模式),可放在插件目录:
content/plugins/your-plugin/
├── your-plugin.php
└── homepage_mode.php # 通过 register_homepage_modes 钩子注册
在插件主文件中注册:
Hook::addAction('register_homepage_modes', function () {
if (!class_exists('HomepageModeRegistry')) return;
require_once __DIR__ . '/homepage_mode.php';
HomepageModeRegistry::register(new HomepageMode_YourMode());
});
4. HomepageMode 基类 API
4.1 可覆写的属性
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
$id | string | ✅ | 唯一标识,仅允许 [a-z0-9_],建议与目录名一致 |
$name | string | ✅ | 后台显示名称(简短,2-4 字) |
$description | string | ✅ | 可用时的说明文案 |
$theme | string | ✅ | 主题色,见第 6 节 |
$icon | string | ✅ | SVG 字符串(不含 width/height) |
$priority | int | ⬜ | 排序权重,默认 100,越小越靠前 |
$source | string | ⬜ | 来源标识:builtin / module / plugin:插件名 |
4.2 必须实现的方法
abstract public function render($ctx = []);
| 参数 | 类型 | 说明 |
|---|---|---|
$ctx | array | 上下文数组(见下) |
$ctx 结构:
| 键 | 类型 | 说明 |
|---|---|---|
logs | array | 文章列表(仅博客模式有意义) |
pageHtml | string | 分页 HTML(仅博客模式有意义) |
sort | array\ | null |
tag | array\ | null |
author | array\ | null |
recordYear | int\ | null |
recordMonth | int\ | null |
⚠️ 注意:只有首页会调用getCurrent()选择模式;分类页、标签页、作者页、归档页始终使用 blog 模式。因此大多数情况下$ctx['logs']在非 blog 模式中为空。
4.3 可覆写的方法
// 当前是否可用(依赖项检查,返回 false 则后台卡片置灰)
public function isAvailable() { return true; }
// 不可用时的提示(显示在卡片描述位置)
public function getDisabledHint() { return '此模式当前不可用'; }
4.4 父类提供的辅助方法
| 方法 | 说明 |
|---|---|
$this->h($str) | HTML 转义(推荐所有输出都用它) |
class_exists('Hook') + Hook::doAction(...) | 二级钩子扩展点 |
5. 快速开始:第一个模式
一个最简单的"公告模式",在首页显示一段静态公告。
5.1 创建文件
content/homepage_modes/notice/notice.php
5.2 编写代码
<?php
/**
* Homepage Mode: 公告
* ------------------------------------------------------------
* 在首页主栏显示一段公告文字
*/
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('HomepageMode')) return;
if (!class_exists('HomepageMode_Notice')) {
class HomepageMode_Notice extends HomepageMode {
public $id = 'notice';
public $name = '公告';
public $description = '显示一段站点公告';
public $theme = 'orange';
public $priority = 50;
public $source = 'module';
public $icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
public function render($ctx = []) {
$text = Option::get('notice_text', '欢迎来到本站!');
if (class_exists('Hook')) Hook::doAction('notice_homepage_before');
?>
<div class="hm-notice">
<div class="hm-notice-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</div>
<div class="hm-notice-text"><?= nl2br($this->h($text)) ?></div>
</div>
<?php
if (class_exists('Hook')) Hook::doAction('notice_homepage_after');
}
}
HomepageModeRegistry::register(new HomepageMode_Notice());
}
5.3 效果
- 刷新后台「系统设置」页面
- 首页主栏内容出现新的「公告」卡片(橙色主题)
- 选中 → 保存 → 访问首页 → 主栏显示公告内容
6. 主题色规范
模式必须指定一个 $theme,决定后台卡片的配色。目前支持 6 种主题色:
| Theme | 色值 | 适用场景 |
|---|---|---|
blue | #2563eb | 默认、通用 |
green | #059669 | 社区、论坛、交流 |
purple | #7c3aed | 阅读、文学、创作 |
orange | #ea580c | 提示、公告、工具 |
pink | #db2777 | 生活、时尚、娱乐 |
cyan | #0891b2 | 科技、数据、技术 |
每个主题色会自动应用到:
- 卡片图标背景(渐变)
- 卡片悬停边框色
- 卡片选中边框 + 光晕
- 卡片右上角对勾徽章
- 卡片选中时的名称色
如何选择?
- 与内容类型相关(论坛 → 绿,读书 → 紫)
- 与前一模式区分明显
- 避免同类色相邻(例如 two blue 相邻会混淆)
💡 新增主题色:需要在admin/views/options.php的.opt-mode-item[data-theme="xxx"]处添加一组 CSS 变量。建议向 LiteLog 提交 PR。
7. 图标规范
$icon 是一个完整 SVG 字符串(不含 <svg> 外层的 width / height)。
规范:
viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"
推荐图标来源:
- Feather Icons
- Lucide
- 自定义 SVG
示例:
// ✅ 正确
public $icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="..."/></svg>';
// ❌ 错误(包含 width/height 会破坏布局)
public $icon = '<svg width="24" height="24" viewBox="...">...</svg>';
// ❌ 错误(不写 stroke 会导致图标不可见或颜色不对)
public $icon = '<svg viewBox="0 0 24 24"><path d="..."/></svg>';
图标尺寸: 系统会自动渲染为 24×24 px 显示在 52×52 的容器内。
8. 依赖检测
如果你的模式依赖某个插件或数据表,通过 isAvailable() 声明:
public function isAvailable() {
// 示例 1:依赖插件类
if (!class_exists('ForumThread')) return false;
// 示例 2:依赖配置开关
if (Option::get('forum_enable', 'y') !== 'y') return false;
// 示例 3:依赖工具函数
if (!function_exists('forum_pretty_url')) return false;
return true;
}
public function getDisabledHint() {
return '需先安装并启用论坛插件';
}
后台效果:
isAvailable()返回false→ 卡片自动置灰,radio 被禁用- 描述显示
getDisabledHint()的返回内容 - 额外显示一个红色警告徽章
- 如果用户保存时该模式已不可用,
getCurrent()会自动回退到blog
推荐写法(组合检查):
public function isAvailable() {
return class_exists('YourPluginClass')
&& Option::get('your_plugin_enable', 'y') === 'y'
&& function_exists('your_plugin_url');
}
9. 分页与 URL 参数规范
多个模式都可能需要分页,必须使用独立的分页参数名,避免冲突。
9.1 参数命名约定
| 模式 | 分页参数 | 其他参数 |
|---|---|---|
blog | page | — |
forum | fpage | — |
novel | npage | ncat(分类)、norder(排序) |
| 你的模式 | xxxpage | xxxcat、xxxorder |
规则:
- 每个模式用 2-3 个字母做前缀
- 前缀必须与该模式的
$id相关 - 切换模式时,其他模式的参数在 URL 中会保留,但不会互相影响
9.2 分页 URL 生成器
推荐在类中定义一个闭包或私有方法:
private function pageUrl($p) {
$q = $_GET;
$q['xxxpage'] = $p;
// 清理其他模式的分页参数(可选)
unset($q['page'], $q['fpage'], $q['npage']);
return '?' . http_build_query($q);
}
9.3 分页 HTML 模板
LiteLog 主题提供了 .pagination 通用样式,直接套用:
if ($total > $perPage) {
$pages = (int)ceil($total / $perPage);
if ($pages > 1) {
echo '<div class="pagination">';
if ($page > 1) {
echo '<a href="' . $this->h($this->pageUrl($page - 1)) . '">上一页</a>';
}
$start = max(1, $page - 3);
$end = min($pages, $page + 3);
if ($start > 1) {
echo '<a href="' . $this->h($this->pageUrl(1)) . '">1</a><span>...</span>';
}
for ($i = $start; $i <= $end; $i++) {
echo ($i == $page)
? '<span class="current">' . $i . '</span>'
: '<a href="' . $this->h($this->pageUrl($i)) . '">' . $i . '</a>';
}
if ($end < $pages) {
echo '<span>...</span><a href="' . $this->h($this->pageUrl($pages)) . '">' . $pages . '</a>';
}
if ($page < $pages) {
echo '<a href="' . $this->h($this->pageUrl($page + 1)) . '">下一页</a>';
}
echo '</div>';
}
}
9.4 空状态处理
if (empty($items)) {
?>
<div class="hm-empty">
<div class="hm-empty-icon">...</div>
还没有内容
<?php if ($isAdmin): ?>
,<a href="<?= BLOG_URL ?>admin/?action=xxx_add">去添加 →</a>
<?php endif; ?>
</div>
<?php
return;
}
10. 样式规范
10.1 命名前缀
为避免样式冲突,所有 class 必须使用 hm- 前缀(Homepage Mode 缩写)+ 模式 ID:
.hm-novel /* 模式容器 */
.hm-novel-bar /* 顶部工具栏 */
.hm-novel-item /* 列表项 */
.hm-novel-btn /* 按钮 */
或者使用你在小工具文档中约定的 lf- 前缀(LiteLog Frontend):
.lf-novel
.lf-novel-bar
核心原则:
- ❌ 不要用通用名
.box、.item、.title - ✅ 用带前缀的
.hm-xxx - ✅ 但可以复用主题通用类:
.pagination、.widget
10.2 内联样式输出(推荐)
由于首页只有一个模式在渲染,可以在 render() 开头输出一次:
private static $styleRendered = false;
private function outputStyle() {
if (self::$styleRendered) return;
self::$styleRendered = true;
?>
<style>
.hm-your-mode { ... }
</style>
<?php
}
static 保证多次调用(虽然目前只调用一次)不会重复输出。
10.3 复用主题变量
主题内置了一些色值,可以直接用:
/* 主色 */
#2563eb /* 蓝色主色 */
#1a73e8 /* 链接色 */
/* 中性 */
#0f172a /* 主标题 */
#334155 /* 正文 */
#64748b /* 次要文字 */
#94a3b8 /* 辅助文字 */
#cbd5e1 /* 边框 */
#eef2f7 /* 浅边框 */
#f8fafc /* 浅背景 */
/* 卡片 */
box-shadow: 0 1px 3px rgba(15, 23, 42, .04);
border-radius: 10px;
10.4 响应式断点
主题使用这些断点:
| 断点 | 适用 |
|---|---|
≤ 1100px | 隐藏左栏 |
≤ 900px | 隐藏右栏,只保留主栏 |
≤ 768px | 单栏堆叠,移动端 |
≤ 480px | 小屏手机 |
你的模式也要适配:
@media (max-width: 768px) {
.hm-your-mode-bar { padding: 12px 14px; flex-wrap: wrap; }
.hm-your-mode-grid { grid-template-columns: repeat(2, 1fr); }
}
11. 完整示例
示例 1:极简模式(无依赖)
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('HomepageMode')) return;
class HomepageMode_About extends HomepageMode {
public $id = 'about';
public $name = '关于';
public $description = '显示站点简介';
public $theme = 'cyan';
public $priority = 80;
public $source = 'module';
public $icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>';
public function render($ctx = []) {
$desc = Option::get('site_description', '');
?>
<div class="hm-about" style="background:#fff;border-radius:10px;padding:32px;text-align:center">
<h2 style="margin:0 0 12px;color:#0f172a">
<?= $this->h(Option::get('site_title')) ?>
</h2>
<p style="color:#64748b;margin:0"><?= $this->h($desc) ?></p>
</div>
<?php
}
}
HomepageModeRegistry::register(new HomepageMode_About());
示例 2:依赖插件 + 分页
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('HomepageMode')) return;
class HomepageMode_Video extends HomepageMode {
public $id = 'video';
public $name = '视频';
public $description = '显示视频列表';
public $theme = 'pink';
public $priority = 40;
public $source = 'module';
public $icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>';
private static $styleRendered = false;
public function isAvailable() {
return class_exists('VideoModel')
&& Option::get('video_enable', 'y') === 'y';
}
public function getDisabledHint() {
return '需先安装并启用视频插件';
}
private function pageUrl($p) {
$q = $_GET;
$q['vpage'] = $p;
unset($q['page'], $q['fpage'], $q['npage']);
return '?' . http_build_query($q);
}
private function outputStyle() {
if (self::$styleRendered) return;
self::$styleRendered = true;
?>
<style>
.hm-video-bar{display:flex;align-items:center;padding:14px 20px;background:#fff;border-radius:10px;border:1px solid #eef2f7;margin-bottom:14px}
.hm-video-bar h2{display:flex;align-items:center;gap:8px;margin:0;font-size:16px;font-weight:700;color:#0f172a}
.hm-video-bar h2 svg{width:20px;height:20px;color:#db2777}
.hm-video-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-bottom:16px}
.hm-video-item{background:#fff;border-radius:10px;border:1px solid #eef2f7;overflow:hidden;text-decoration:none;color:inherit;transition:transform .2s}
.hm-video-item:hover{transform:translateY(-4px);box-shadow:0 8px 20px rgba(15,23,42,.08)}
.hm-video-cover{aspect-ratio:16/9;background:#f1f5f9;overflow:hidden;position:relative}
.hm-video-cover img{width:100%;height:100%;object-fit:cover;display:block}
.hm-video-body{padding:12px 14px}
.hm-video-title{font-size:14px;font-weight:600;color:#0f172a;margin:0 0 6px;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.hm-video-meta{font-size:12px;color:#94a3b8}
.hm-empty{background:#fff;border-radius:10px;padding:60px 20px;text-align:center;color:#94a3b8}
@media (max-width:768px){.hm-video-grid{grid-template-columns:repeat(2,1fr);gap:12px}}
</style>
<?php
}
public function render($ctx = []) {
if (!$this->isAvailable()) return;
$this->outputStyle();
$perPage = 12;
$page = max(1, (int)($_GET['vpage'] ?? 1));
$total = 0;
$videos = [];
try {
$videos = VideoModel::query($page, $perPage, [], $total);
} catch (Throwable $e) {
$videos = [];
}
if (class_exists('Hook')) Hook::doAction('video_homepage_before');
?>
<div class="hm-video">
<div class="hm-video-bar">
<h2>
<?= $this->icon ?>
<span>视频广场</span>
<?php if ($total > 0): ?>
<span style="font-size:12.5px;color:#94a3b8;font-weight:400;margin-left:4px">
<?= number_format($total) ?> 个视频
</span>
<?php endif; ?>
</h2>
</div>
<?php if (empty($videos)): ?>
<div class="hm-empty">还没有视频</div>
<?php else: ?>
<div class="hm-video-grid">
<?php foreach ($videos as $v): ?>
<a class="hm-video-item" href="<?= $this->h($v['url']) ?>">
<div class="hm-video-cover">
<?php if (!empty($v['cover'])): ?>
<img src="<?= $this->h($v['cover']) ?>" alt="<?= $this->h($v['title']) ?>" loading="lazy">
<?php endif; ?>
</div>
<div class="hm-video-body">
<div class="hm-video-title"><?= $this->h($v['title']) ?></div>
<div class="hm-video-meta"><?= $this->h($v['author']) ?> · <?= (int)$v['views'] ?> 浏览</div>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php
// 分页
if ($total > $perPage) {
$pages = (int)ceil($total / $perPage);
if ($pages > 1) {
echo '<div class="pagination">';
if ($page > 1) echo '<a href="' . $this->h($this->pageUrl($page - 1)) . '">上一页</a>';
for ($i = 1; $i <= $pages; $i++) {
if ($i == $page) {
echo '<span class="current">' . $i . '</span>';
} elseif ($i <= 3 || $i > $pages - 3 || abs($i - $page) <= 2) {
echo '<a href="' . $this->h($this->pageUrl($i)) . '">' . $i . '</a>';
} elseif ($i == 4 || $i == $pages - 3) {
echo '<span>...</span>';
}
}
if ($page < $pages) echo '<a href="' . $this->h($this->pageUrl($page + 1)) . '">下一页</a>';
echo '</div>';
}
}
if (class_exists('Hook')) Hook::doAction('video_homepage_after');
}
}
HomepageModeRegistry::register(new HomepageMode_Video());
示例 3:模式内的二级分类筛选
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('HomepageMode')) return;
class HomepageMode_Gallery extends HomepageMode {
public $id = 'gallery';
public $name = '图库';
public $description = '展示图片集';
public $theme = 'orange';
public $priority = 45;
public $source = 'module';
public $icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
private static $styleRendered = false;
public function isAvailable() {
return class_exists('GalleryModel');
}
private function catUrl($cid) {
$q = $_GET;
if ($cid) $q['gcat'] = $cid; else unset($q['gcat']);
$q['gpage'] = 1;
unset($q['page'], $q['fpage'], $q['npage']);
return '?' . http_build_query($q);
}
private function pageUrl($p) {
$q = $_GET;
$q['gpage'] = $p;
unset($q['page'], $q['fpage'], $q['npage']);
return '?' . http_build_query($q);
}
private function outputStyle() {
if (self::$styleRendered) return;
self::$styleRendered = true;
echo '<style>
.hm-gallery-cats{display:flex;gap:8px;flex-wrap:wrap;padding:12px 14px;background:#fff;border-radius:10px;border:1px solid #eef2f7;margin-bottom:14px}
.hm-gallery-cat{padding:6px 14px;border-radius:18px;background:#f8fafc;border:1px solid #eef2f7;color:#475569;text-decoration:none;font-size:13px}
.hm-gallery-cat.active{background:linear-gradient(135deg,#ea580c,#f97316);color:#fff;border-color:transparent}
.hm-gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:16px}
.hm-gallery-item{aspect-ratio:1;background:#fff;border-radius:10px;overflow:hidden;border:1px solid #eef2f7;position:relative;text-decoration:none}
.hm-gallery-item img{width:100%;height:100%;object-fit:cover;transition:transform .3s}
.hm-gallery-item:hover img{transform:scale(1.08)}
</style>';
}
public function render($ctx = []) {
if (!$this->isAvailable()) return;
$this->outputStyle();
$perPage = 20;
$page = max(1, (int)($_GET['gpage'] ?? 1));
$catId = (int)($_GET['gcat'] ?? 0);
$total = 0;
$items = [];
$cats = [];
try {
$cond = [];
if ($catId) $cond['cat_id'] = $catId;
$items = GalleryModel::query($page, $perPage, $cond, $total);
$cats = GalleryModel::allCats();
} catch (Throwable $e) {}
if (class_exists('Hook')) Hook::doAction('gallery_homepage_before');
?>
<div class="hm-gallery">
<div style="display:flex;align-items:center;padding:14px 20px;background:#fff;border-radius:10px;border:1px solid #eef2f7;margin-bottom:14px">
<h2 style="display:flex;align-items:center;gap:8px;margin:0;font-size:16px;font-weight:700;color:#0f172a">
<?= $this->icon ?>
<span>图片集</span>
<?php if ($total > 0): ?>
<span style="font-size:12.5px;color:#94a3b8;font-weight:400"><?= number_format($total) ?> 张</span>
<?php endif; ?>
</h2>
</div>
<?php if (!empty($cats)): ?>
<nav class="hm-gallery-cats">
<a class="hm-gallery-cat <?= !$catId ? 'active' : '' ?>" href="<?= $this->h($this->catUrl(0)) ?>">全部</a>
<?php foreach ($cats as $c): ?>
<a class="hm-gallery-cat <?= $catId == $c['id'] ? 'active' : '' ?>"
href="<?= $this->h($this->catUrl($c['id'])) ?>">
<?= $this->h($c['name']) ?>
</a>
<?php endforeach; ?>
</nav>
<?php endif; ?>
<?php if (empty($items)): ?>
<div class="hm-empty" style="background:#fff;border-radius:10px;padding:60px 20px;text-align:center;color:#94a3b8">
还没有图片
</div>
<?php else: ?>
<div class="hm-gallery-grid">
<?php foreach ($items as $it): ?>
<a class="hm-gallery-item" href="<?= $this->h($it['url']) ?>">
<img src="<?= $this->h($it['thumb']) ?>" alt="<?= $this->h($it['title']) ?>" loading="lazy">
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php
// 分页
if ($total > $perPage) {
$pages = (int)ceil($total / $perPage);
if ($pages > 1) {
echo '<div class="pagination">';
if ($page > 1) echo '<a href="' . $this->h($this->pageUrl($page - 1)) . '">上一页</a>';
$start = max(1, $page - 3);
$end = min($pages, $page + 3);
for ($i = $start; $i <= $end; $i++) {
echo ($i == $page)
? '<span class="current">' . $i . '</span>'
: '<a href="' . $this->h($this->pageUrl($i)) . '">' . $i . '</a>';
}
if ($page < $pages) echo '<a href="' . $this->h($this->pageUrl($page + 1)) . '">下一页</a>';
echo '</div>';
}
}
if (class_exists('Hook')) Hook::doAction('gallery_homepage_after');
}
}
HomepageModeRegistry::register(new HomepageMode_Gallery());
12. 高级话题
12.1 排序权重
$priority 决定后台卡片顺序:
| 优先级 | 建议用途 |
|---|---|
| 10-19 | 内置/最常用 |
| 20-39 | 官方模块 |
| 40-59 | 第三方模块 |
| 60-89 | 实验性 / 低频 |
| 90+ | 插件注册 |
推荐分配:
10 博客
20 论坛
30 读书
40 视频
45 图库
50 公告
80 关于
12.2 数据回退与容错
当模式渲染失败(数据表缺失、类方法不存在)时,不要抛出异常,返回友好提示:
public function render($ctx = []) {
if (!class_exists('TargetModel')) {
echo '<div class="hm-empty">模式数据未就绪</div>';
return;
}
try {
$data = TargetModel::query(...);
} catch (Throwable $e) {
echo '<div class="hm-empty">数据加载失败</div>';
return;
}
// ...
}
12.3 二级钩子
为其他插件提供扩展点:
public function render($ctx = []) {
if (class_exists('Hook')) Hook::doAction('xxx_homepage_before');
// 主内容
if (class_exists('Hook')) Hook::doAction('xxx_homepage_after');
}
推荐钩子命名:
| 钩子 | 时机 |
|---|---|
{mode_id}_homepage_before | 主内容前 |
{mode_id}_homepage_after | 主内容后 |
{mode_id}_homepage_item | 每个条目后(传 item) |
12.4 数据缓存
如果模式查询较重,用 Cache:
public function render($ctx = []) {
$cache = new Cache(CONTENT_PATH . 'cache/');
$key = 'hm_xxx_' . md5(json_encode($_GET));
$data = $cache->read($key);
if ($data === false) {
$data = heavyQuery();
$cache->write($key, $data);
}
// 渲染 $data
}
12.5 权限检测
只对管理员显示"添加"按钮:
$isAdmin = ($_SESSION['role'] ?? '') === 'admin';
if ($isAdmin) {
echo '<a href="' . BLOG_URL . 'admin/?action=xxx_add">+ 添加</a>';
}
通用权限检测(如果安装了 UserGroup 插件):
if (function_exists('UserGroup_checkPermission')
&& UserGroup_checkPermission((int)$_SESSION['uid'], 'xxx_post')) {
// 显示发帖按钮
}
12.6 深色模式兼容
如果主题未来支持深色模式,使用 CSS 变量:
.hm-xxx {
background: var(--bg-card, #fff);
color: var(--text-primary, #0f172a);
}
目前主题未使用 CSS 变量,但你的模式可以提前准备。
13. 常见问题
Q1:模式注册了但后台不显示?
排查步骤:
- 目录/文件名是否一致?(
novel/novel.php) - 文件是否
require时执行了HomepageModeRegistry::register()? - 类是否继承
HomepageMode? $id是否为空?是否只包含[a-z0-9_]?HomepageModeRegistry::init()是否被调用?(admin/views/options.php会调用)- 是否有 PHP 语法错误?查看
content/backup/mode-error.log
Q2:模式渲染后样式错乱?
检查:
- 是否所有 class 都用了
hm-或lf-前缀? - 是否有元素意外撑破容器?(用
box-sizing: border-box) - 是否使用了
position: fixed?(会覆盖全屏) - 是否与主题的
.layout、.main-col冲突?
Q3:如何让模式支持"子路由"?
模式只负责首页主栏,如果需要详情页,应该由插件路由处理。例如小说模式:
- 首页显示书籍网格 →
novel_url('book', ['id' => 1]) - 详情页由
Novel_dispatchPluginRoute处理
模式不处理详情页,只提供入口链接。
Q4:模式内如何获取当前登录用户?
$uid = (int)($_SESSION['uid'] ?? 0);
if (!$uid) {
echo '请先登录';
return;
}
$user = UserModel::getOne($uid);
Q5:多个模式同 URL 参数冲突怎么办?
统一规则:每个模式使用独立前缀。
| 模式 | 分页参数 |
|---|---|
| blog | page |
| forum | fpage |
| novel | npage |
| 你的模式 | xxxpage |
不要在模式内复用其他模式的参数名。
Q6:如何调试模式?
在模式 render() 顶部临时输出:
if (isset($_GET['hmdebug'])) {
echo '<pre style="background:#f8fafc;padding:12px;border-radius:6px;font-size:12px">';
var_dump([
'mode' => $this->id,
'ctx' => array_keys($ctx),
'get' => $_GET,
]);
echo '</pre>';
}
访问 ?hmdebug=1 即可查看。
Q7:模式如何兼容不同的主题?
模式应完全自包含:
- 输出完整 HTML 结构,不依赖主题的
.post-item等 class - 但可以复用主题的
.pagination - 使用内联
<style>(用 static 标记只输出一次) - 避免依赖主题的 CSS 变量(除非明确声明)
Q8:模式与主题是同一个作者,如何共享样式?
如果模式和主题强相关,可以在模式的 outputStyle() 里 echo '<link rel="stylesheet" href="' . TEMPLATE_URL . 'css/xxx.css">',但不推荐——会污染主题切换。
Q9:模式如何实现"用户偏好"?
存在 localStorage 中:
<script>
(function(){
var pref = localStorage.getItem('hm_xxx_view') || 'grid';
var root = document.querySelector('.hm-xxx');
if (root) root.classList.add('hm-xxx-view-' + pref);
})();
</script>
Q10:模式可以被删除吗?
可以。直接删除 content/homepage_modes/xxx/ 目录即可。系统会在下次访问时不再加载它。
注意:
- 如果用户当前选择的就是这个模式,
getCurrent()会自动回退到blog - 相关配置(如
homepage_main_content = xxx)会保留,但不会生效
附录 A:完整 API 速查表
HomepageMode 属性
public $id; // 唯一 ID
public $name; // 显示名
public $description; // 描述
public $theme; // 主题色
public $icon; // SVG 字符串
public $priority; // 排序
public $source; // 来源
HomepageMode 方法
abstract public function render($ctx = []);
public function isAvailable();
public function getDisabledHint();
protected function h($str);
HomepageModeRegistry 静态方法
HomepageModeRegistry::register($mode);
HomepageModeRegistry::all(); // [id => mode]
HomepageModeRegistry::get($id); // mode 或 null
HomepageModeRegistry::exists($id); // bool
HomepageModeRegistry::getCurrentId(); // 当前模式 ID
HomepageModeRegistry::getCurrent(); // 当前模式对象
HomepageModeRegistry::scanModules(); // 手动触发扫描
相关钩子
| 钩子 | 时机 |
|---|---|
register_homepage_modes | 注册表初始化时触发,插件注册模式的入口 |
主题色
blue / green / purple / orange / pink / cyan
附录 B:命名建议
| 场景 | 推荐 | 避免 |
|---|---|---|
| 目录名 | novel、video、your-mode | mode1、w |
| 类名 | HomepageMode_Novel | NM、Novel |
| 模式 ID | novel、video、your_mode | novel123、N1 |
| 分页参数 | npage、vpage | p、p1 |
| CSS 前缀 | .hm-novel、.lf-novel | .novel、.item |
| 钩子前缀 | novel_homepage_before | before |
附录 C:从零到一的完整流程
步骤 1:创建目录
mkdir -p content/homepage_modes/podcast
步骤 2:创建主文件
touch content/homepage_modes/podcast/podcast.php
步骤 3:写代码
<?php
if (!defined('LITELOG_ROOT')) exit('error!');
if (!class_exists('HomepageMode')) return;
class HomepageMode_Podcast extends HomepageMode {
public $id = 'podcast';
public $name = '播客';
public $description = '显示最新播客节目';
public $theme = 'cyan';
public $priority = 60;
public $source = 'module';
public $icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
public function render($ctx = []) {
echo '<div style="background:#fff;padding:40px;text-align:center;border-radius:10px">';
echo '<h2 style="margin:0 0 8px">播客模式</h2>';
echo '<p style="color:#64748b;margin:0">敬请期待</p>';
echo '</div>';
}
}
HomepageModeRegistry::register(new HomepageMode_Podcast());
步骤 4:刷新后台设置页
打开 ?action=options → 首页设置 → 看到新卡片「播客」(青色主题)
步骤 5:选中 → 保存 → 访问首页
结语
首页模式系统让 LiteLog 首页不再局限于博客列表,可以扩展为论坛、小说书城、图库、播客……任何你能想到的内容聚合形态。
核心理念回顾:
- 单一职责:每个模式只管"首页主栏显示什么"
- 可插拔:放在指定目录即生效,删除即失效
- 零侵入:不修改核心代码,与主题、插件完全解耦
- 多来源:内置 / 模块 / 插件三种注册方式
- 依赖明确:
isAvailable()让后台自动识别
按照本规范开发模式,你的模式将:
- ✅ 与 LiteLog 内核、主题、其他插件完全兼容
- ✅ 拥有统一的视觉风格和交互体验
- ✅ 支持任意排序、任意组合
- ✅ 用户零配置即可用
参考实现:
- 内置:
include/lib/homepage_mode.php(Blog / Forum) - 模块:
content/homepage_modes/novel/novel.php
版本历史
| 版本 | 日期 | 变更 |
|---|---|---|
| 1.0 | 2026-10-08 | 首次发布 |