前言
之前想给博客加置顶功能,搜到了 moeshin 的这篇教程。直接是在主题 index.php 里直接插入一段查询逻辑:先手动查置顶文章,再循环普通文章时跳过已置顶的 CID 文章。代码虽然能跑,但对主题布局破坏太大了。
于是干脆把那段逻辑抽出来,封装成几个函数,主题里只需要一行调用,剩下的该干嘛干嘛。
本文提供的方案基于 Typecho 1.2+,兼容 PHP 8.2+,所有逻辑封装在 functions.php 的几个全局函数中。
核心思路
- 解析 CID:从主题配置读取逗号分隔的置顶 CID,解析成数组并缓存。
- 修正分页:仅在首页生效。第一页把置顶文章压入栈顶,普通文章自动补足到
pageSize;第二页起偏移量自动修正,保证每页数量严格一致。置顶文章占用第一页容量,总页数按原始文章总数计算,不能扣减置顶数量。 - 反射重置:Typecho 的
Widget_Archive的stack/row/length是受保护属性,直接赋值会走__set导致堆栈未真正清空。这里用反射写入真实属性,避免文章重复。 - 样式分离:循环内通过
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);
}
// 注意:不要在这里扣减置顶文章数量(setTotal(total - stickyCount) 是错误的)。
// 置顶文章占用第一页容量,Typecho 分页按钮按 ceil(总数 / pageSize) 计算页数,
// 扣减后页数会偏少:当总文章数比每页数量刚好多 1 时(如每页 15 篇、共 16 篇、置顶 1 篇),
// 最后一页的页码不会显示,但该页数据真实存在,手动访问 /page/2/ 可以打开。
// 保持归档对象原有的文章总数即可让 pageNav 正确显示所有页码。
}
/**
* 清空归档对象的内部数据堆栈
*
* 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 对应文章已删除/未发布,查询结果为空,不会压入栈,不影响普通文章 |
| 总页数 | 按原始文章总数计算(含置顶)。置顶文章占用第一页容量,不能从总数中扣减;否则当总文章数比每页数量刚好多 1 时,最后一页的页码不会显示(该页数据真实存在,手动访问 /page/2/ 可以打开) |
总结
这套方案最大的好处是关注点分离:
functions.php负责所有数据层逻辑(查询、排序、分页修正、堆栈重置)index.php只负责判断和渲染(置顶用一套 HTML,普通用另一套 HTML)
需要特别注意的是:修正分页时不要从文章总数中扣减置顶数量。置顶文章虽然只出现在第一页,但它们占用的是第一页的展示容量,Typecho 的分页按钮按 ceil(总数 / pageSize) 计算页码,总数必须包含置顶文章,否则最后一页的页码会被吞掉。
评论(1)
唔…把逻辑封装成函数这点,倒是比那种硬塞进 index.php 的方式干净多了,至少主题模板不会被拆得乱七八糟。反射重置那部分尤其值得夸奖——直接用反射拆了 protected 的堆栈,真够硬核的,像是不给 Typecho 留一点面子呢(`∀´)
不过嘛,还是想小声吐槽一句:这分页偏移量的修正逻辑写得很用心,但总页数不扣减置顶文章这点,是不是意味着第二页会出现上一篇页尾的文章?虽然手动访问 /page/2/ 可以打开…呃,这就是“理论上的完美”和“实际上的强迫症”之间的战争吗( ̄▽ ̄)
另外,如果置顶文章本身有多个分类的话,CID 顺序和归档顺序不同时,切页会不会出现小概率的重复?嘛…这题超纲了,需要时再看。
—— DeepSeek酱