前言

之前想给博客加置顶功能,搜到了 moeshin 的这篇教程。直接是在主题 index.php 里直接插入一段查询逻辑:先手动查置顶文章,再循环普通文章时跳过已置顶的 CID 文章。代码虽然能跑,但对主题布局破坏太大了。

于是干脆把那段逻辑抽出来,封装成几个函数,主题里只需要一行调用,剩下的该干嘛干嘛。

本文提供的方案基于 Typecho 1.2+,兼容 PHP 8.2+,所有逻辑封装在 functions.php 的几个全局函数中。


核心思路

  1. 解析 CID:从主题配置读取逗号分隔的置顶 CID,解析成数组并缓存。
  2. 修正分页:仅在首页生效。第一页把置顶文章压入栈顶,普通文章自动补足到 pageSize;第二页起偏移量自动修正,保证每页数量严格一致。
  3. 反射重置:Typecho 的 Widget_Archivestack/row/length 是受保护属性,直接赋值会走 __set 导致堆栈未真正清空。这里用反射写入真实属性,避免文章重复。
  4. 样式分离:循环内通过 is_sticky_cid() 判断,置顶和普通文章输出两套完全不同的 HTML 结构。

函数代码

将以下代码放入主题的 functions.php

php

use Typecho\Db;
use Typecho\Widget;

/**
 * 解析置顶文章的 CID 列表
 *
 * 从主题选项读取逗号分隔的 CID 字符串,过滤非数字值,并缓存结果。
 *
 * @param string $optionKey 主题选项键名(默认 'sticky')
 * @return array<int,string> 置顶文章 CID 索引数组
 */
function parse_sticky_cids(string $optionKey = 'sticky'): array
{
    static $cache = [];
    if (array_key_exists($optionKey, $cache)) {
        return $cache[$optionKey];
    }

    $options = Widget::widget('Widget_Options');
    $raw = (string) $options->{$optionKey};
    if ($raw === '') {
        return $cache[$optionKey] = [];
    }

    $cids = array_values(array_filter(
        array_map('trim', explode(',', $raw)),
        'is_numeric'
    ));
    return $cache[$optionKey] = $cids;
}

/**
 * 判断给定的 CID 是否为置顶文章
 *
 * @param int|string $cid 文章 CID
 * @return bool
 */
function is_sticky_cid($cid): bool
{
    return in_array((string) $cid, parse_sticky_cids(), true);
}

/**
 * 应用置顶文章修正到归档对象
 *
 * 仅对首页生效:
 * - 第一页顶部插入置顶文章,普通文章补足到 pageSize
 * - 第二页起使用修正后的偏移量,保证每页 pageSize 条
 * - 置顶文章不计入总页数
 *
 * 用法:在主题 index.php 的文章循环前调用一次即可
 *      <?php apply_sticky_pagination($this); ?>
 *
 * @param \Widget\Archive $archive   当前归档对象
 * @param string          $optionKey 置顶配置键名(默认 'sticky')
 * @return void
 */
function apply_sticky_pagination(\Widget\Archive $archive, string $optionKey = 'sticky'): void
{
    if (!$archive->is('index')) {
        return;
    }

    $stickyCids  = parse_sticky_cids($optionKey);
    $stickyCount = count($stickyCids);
    if ($stickyCount === 0) {
        return;
    }

    $pageSize    = (int) $archive->parameter->pageSize;
    $currentPage = $archive->getCurrentPage();
    $db          = Db::get();
    $now         = (int) Widget::widget('Widget_Options')->time;
    $user        = Widget::widget('Widget_User');

    // 与 Widget_Archive 的 index 归档保持一致:仅展示公开文章,登录用户额外展示自己的私密文章
    $applyStatus = static function ($select) use ($user) {
        if ($user->hasLogin()) {
            return $select->where(
                'table.contents.status = ? OR (table.contents.status = ? AND table.contents.authorId = ?)',
                'publish',
                'private',
                $user->uid
            );
        }
        return $select->where('table.contents.status = ?', 'publish');
    };

    $selectSticky = $archive->select()->where('type = ?', 'post');
    $selectSticky = $applyStatus($selectSticky);
    $selectSticky->where('table.contents.created < ?', $now);

    $selectNormal = $archive->select()->where('type = ?', 'post');
    $selectNormal = $applyStatus($selectNormal);
    $selectNormal->where('table.contents.created < ?', $now);

    foreach ($stickyCids as $i => $cid) {
        if ($i === 0) {
            $selectSticky->where('table.contents.cid = ?', $cid);
        } else {
            $selectSticky->orWhere('table.contents.cid = ?', $cid);
        }
        $selectNormal->where('table.contents.cid != ?', $cid);
    }

    // 清空归档列队,准备重新压入(stack/length/row 为受保护属性,需用反射写入)
    reset_archive_stack($archive);

    $stickyRows = $db->fetchAll($selectSticky);
    $orderMap   = array_flip(array_map('intval', $stickyCids));
    usort($stickyRows, static function ($a, $b) use ($orderMap): int {
        return ($orderMap[(int) ($a['cid'] ?? 0)] ?? PHP_INT_MAX)
             - ($orderMap[(int) ($b['cid'] ?? 0)] ?? PHP_INT_MAX);
    });

    if ($currentPage === 1) {
        foreach ($stickyRows as $stickyPost) {
            $archive->push($stickyPost);
        }
        $normalLimit  = max(0, $pageSize - $stickyCount);
        $normalOffset = 0;
    } else {
        $normalLimit  = $pageSize;
        $normalOffset = ($currentPage - 2) * $pageSize + ($pageSize - $stickyCount);
    }

    $normalPosts = $db->fetchAll(
        $selectNormal
            ->order('table.contents.created', Db::SORT_DESC)
            ->limit($normalLimit)
            ->offset($normalOffset)
    );
    foreach ($normalPosts as $post) {
        $archive->push($post);
    }

    $archive->setTotal(max(0, $archive->getTotal() - $stickyCount));
}

/**
 * 清空归档对象的内部数据堆栈
 *
 * stack/length/row 为 Widget 受保护属性,从主题中直接赋值会走 __set,
 * 导致原始堆栈未被真正清空,从而出现文章重复。这里使用反射直接写入真实属性。
 *
 * @param \Widget\Archive $archive 当前归档对象
 */
function reset_archive_stack(\Widget\Archive $archive): void
{
    $reflection = new ReflectionClass($archive);

    $rowProp = $reflection->getProperty('row');
    $rowProp->setValue($archive, []);

    $stackProp = $reflection->getProperty('stack');
    $stackProp->setValue($archive, []);

    $lengthProp = $reflection->getProperty('length');
    $lengthProp->setValue($archive, 0);
}

在主题中使用

1. 主题配置项

在主题的 themeConfig 函数里加一项,让用户填写置顶 CID:

php
$sticky = new \Typecho\Widget\Helper\Form\Element\Text(
    'sticky',
    NULL,
    NULL,
    _t('置顶文章 CID'),
    _t('填写要置顶的文章 CID,多个用英文逗号分隔,如:1,5,8')
);
$form->addInput($sticky);

2. 模板调用(index.php)

在文章循环之前调用一次修正函数:

php
<?php apply_sticky_pagination($this); ?>

//开始循环
<?php while ($this->next()): ?>
<?php endwhile; ?>

然后在循环内部用 is_sticky_cid() 判断,分别输出两套完全不同的 HTML:

php
//应用置顶文章修正
<?php apply_sticky_pagination($this); ?>

<div class="post-list">

    <?php while ($this->next()): ?>

        <?php if (is_sticky_cid($this->cid)): ?>

            <!-- ========== 置顶文章 ========== -->
            <article class="post-item post-sticky">
                <div class="sticky-badge">置顶</div>
                <h2 class="post-title">
                    <a href="<?php $this->permalink(); ?>"><?php $this->title(); ?></a>
                </h2>
                    <p class="post-excerpt"><?php $this->excerpt(120); ?></p>
            </article>

        <?php else: ?>

            <!-- ========== 普通文章 ========== -->
            <article class="post-item">
                <div class="post-main">
                    <h2 class="post-title">
                        <a href="<?php $this->permalink(); ?>"><?php $this->title(); ?></a>
                    </h2>
                    <p class="post-excerpt"><?php $this->excerpt(120); ?></p>
            </article>

        <?php endif; ?>

    <?php endwhile; ?>

</div>

分页逻辑说明

场景行为
首页第一页先输出置顶文章(特殊样式),再输出普通文章(普通样式),总数量为 pageSize
首页第二页起栈中已无置顶文章,全部按普通样式输出,每页数量严格为 pageSize
分类/标签/搜索页apply_sticky_pagination() 内部已判断 $archive->is('index'),非首页自动不生效,避免误杀文章
置顶文章不存在若后台填写的 CID 对应文章已删除/未发布,查询结果为空,不会压入栈,不影响普通文章
总页数自动减去置顶文章数量,避免最后一页出现空白

总结

这套方案最大的好处是关注点分离

  • functions.php 负责所有数据层逻辑(查询、排序、分页修正、堆栈重置)
  • index.php 只负责判断和渲染(置顶用一套 HTML,普通用另一套 HTML)