主站
论坛首页/ LiteLog /帖子

LiteLog 首页模式(Homepage Mode)开发文档

作者:zhangwei 管理员 · 10 天前 · 22 浏览 · 1 回复
zhangwei
楼主
管理员
1 楼

LiteLog 首页模式(Homepage Mode)开发文档

版本:1.0 适用范围:LiteLog 内核 ≥ 1.0 更新日期:2026-10-08

目录

  1. 系统概述
  2. 核心概念
  3. 目录结构
  4. HomepageMode 基类 API
  5. 快速开始:第一个模式
  6. 主题色规范
  7. 图标规范
  8. 依赖检测
  9. 分页与 URL 参数规范
  10. 样式规范
  11. 完整示例
  12. 高级话题
  13. 常见问题

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 可覆写的属性

属性类型必填说明
$idstring✅唯一标识,仅允许 [a-z0-9_],建议与目录名一致
$namestring✅后台显示名称(简短,2-4 字)
$descriptionstring✅可用时的说明文案
$themestring✅主题色,见第 6 节
$iconstring✅SVG 字符串(不含 width/height)
$priorityint⬜排序权重,默认 100,越小越靠前
$sourcestring⬜来源标识:builtin / module / plugin:插件名

4.2 必须实现的方法

abstract public function render($ctx = []);
参数类型说明
$ctxarray上下文数组(见下)

$ctx 结构:

键类型说明
logsarray文章列表(仅博客模式有意义)
pageHtmlstring分页 HTML(仅博客模式有意义)
sortarray\null
tagarray\null
authorarray\null
recordYearint\null
recordMonthint\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 效果

  1. 刷新后台「系统设置」页面
  2. 首页主栏内容出现新的「公告」卡片(橙色主题)
  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"

推荐图标来源:

示例:

// ✅ 正确
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 参数命名约定

模式分页参数其他参数
blogpage—
forumfpage—
novelnpagencat(分类)、norder(排序)
你的模式xxxpagexxxcat、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:模式注册了但后台不显示?

排查步骤:

  1. 目录/文件名是否一致?(novel/novel.php)
  2. 文件是否 require 时执行了 HomepageModeRegistry::register()?
  3. 类是否继承 HomepageMode?
  4. $id 是否为空?是否只包含 [a-z0-9_]?
  5. HomepageModeRegistry::init() 是否被调用?(admin/views/options.php 会调用)
  6. 是否有 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 参数冲突怎么办?

统一规则:每个模式使用独立前缀。

模式分页参数
blogpage
forumfpage
novelnpage
你的模式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-modemode1、w
类名HomepageMode_NovelNM、Novel
模式 IDnovel、video、your_modenovel123、N1
分页参数npage、vpagep、p1
CSS 前缀.hm-novel、.lf-novel.novel、.item
钩子前缀novel_homepage_beforebefore

附录 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 首页不再局限于博客列表,可以扩展为论坛、小说书城、图库、播客……任何你能想到的内容聚合形态。

核心理念回顾:

  1. 单一职责:每个模式只管"首页主栏显示什么"
  2. 可插拔:放在指定目录即生效,删除即失效
  3. 零侵入:不修改核心代码,与主题、插件完全解耦
  4. 多来源:内置 / 模块 / 插件三种注册方式
  5. 依赖明确:isAvailable() 让后台自动识别

按照本规范开发模式,你的模式将:

  • ✅ 与 LiteLog 内核、主题、其他插件完全兼容
  • ✅ 拥有统一的视觉风格和交互体验
  • ✅ 支持任意排序、任意组合
  • ✅ 用户零配置即可用

参考实现:

  • 内置:include/lib/homepage_mode.php(Blog / Forum)
  • 模块:content/homepage_modes/novel/novel.php

版本历史

版本日期变更
1.02026-10-08首次发布
风
风之子
用户
普通用户
2 楼 · 9 天前
太牛了,这是我见过的最牛的建站系统了。
登录 或 后台登录 后参与讨论