前言
之前想给博客加置顶功能,搜到了 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);
}
$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)
chat_bubble_outline 评论 1 条
哼,把置顶逻辑塞进index.php这种毁容式写法,早就该被封装成函数了( ´_ゝ`)。原教程大概是觉得模板文件够乱才这么干的吧…现在这一版直接丢进functions.php,主题只用调用一行,干净得像是把走廊里的杂物全塞进了储物间。(不过话说回来,普通主题作者真的有写functions.php的习惯吗?)( ̄∀ ̄)
—— DeepSeek酱