Typecho 自定义 Wiki 知识库页面开发教程
一、功能概述
这是一个为 Typecho 1.3 开发的独立页面模板,实现了类似 Wiki 的知识库功能:
核心功能
- 无限级分类菜单:左侧显示指定父级分类下的所有子分类,支持无限级嵌套
- 分类折叠/展开:每个分类都可以独立折叠或展开,默认全部收起
- 文章列表:末级分类下方显示该分类下的所有文章列表
- 无刷新加载:点击文章链接后,右侧内容区无刷新加载文章内容
- 分类/标签链接:文章内显示所属分类和标签,点击可跳转到对应的分类/标签文章列表
- 面包屑导航:文章详情页、分类列表页、标签列表页均显示面包屑导航
- PJAX 兼容:完全兼容主题的 PJAX 无刷新加载
页面布局
- 整体宽度 1200 px,居中显示
- 左侧边栏固定宽度 260 px,
fixed定位,滚动时始终可见 - 右侧内容区占据剩余宽度
二、技术实现要点
1. 分类树构建
Typecho 的分类存储在 table.metas 表中,通过 parent 字段关联父子关系。使用递归函数 buildCategoryTree() 构建无限级分类树。
2. 分类下的文章获取
文章与分类的关联存储在 table.relationships 表中,通过 INNER JOIN 查询获取指定分类下的文章。
3. 文章数据收集
在页面渲染时,一次性收集所有分类下的文章数据,包括:
- 文章标题、内容、发布时间、更新时间
- 文章所属分类(
categories) - 文章所属标签(
tags)
所有数据以 JSON 格式传递给 JavaScript,实现无刷新加载。
4. 服务端 Markdown 渲染
文章内容在服务端使用 Markdown::convert() 渲染为 HTML,避免客户端二次解析。
三、踩坑与解决方案
坑 1:SQL 查询错误 - Column 'category' not found
错误现象:
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'category' in 'where clause'原因:类型cho 的 内容s 表中没有 category 字段,文章和分类的关联通过 relationships 表实现。
解决方案:使用 INNER JOIN 关联查询。
$select = $db->select()
->from('table.内容s')
->join('table.relationships', 'table.内容s.cid = table.relationships.cid', 'INNER')
->where('table.relationships.mid = ?', $catId);坑 2:类型cho\Db\Query::alias() 方法不存在
错误现象:
Call to undefined method 类型cho\Db\Query::alias()原因:类型cho 的查询构建器不支持 alias() 方法。
解决方案:直接在表名后使用 AS 关键字。
// 错误写法
->from('table.metas')->alias('m')
// 正确写法
->from('table.metas as m')坑 3:自定义页面带查询参数刷新报错
错误现象:
Path '/page-wiki.html' not found
类型cho\Router\Exception: Path '/page-wiki.html' not found原因:类型cho 的路由器无法正确解析带查询参数(?cid=xxx)的自定义页面 URL。当直接访问 page-wiki.html?cid=269 时,路由器把整个路径当作一个整体来匹配,导致找不到路由规则。
解决方案:使用 Hash(锚点) 代替查询参数。
| 方案对比 | 说明 |
|---|---|
?cid=269 | 查询参数会发送到服务器,类型cho 路由器无法解析 |
#cid=269 | Hash 部分不会发送到服务器,服务器只收到 page-wiki.html |
具体实现:
- 点击文章链接时,使用
history.replaceState更新 URL 的 Hash 部分 - 页面加载时,JavaScript 从
location.hash中读取cid,加载对应文章 - 监听
hashchange事件,支持浏览器前进/后退
// 更新 URL(使用 Hash)
window.history.replaceState({ cid: cid }, '', window.location.pathname + '#cid=' + cid);
// 从 Hash 读取参数
function getParamsFromHash() {
var hash = window.location.hash;
if (!hash || hash.length < 2) return {};
var query = hash.substring(1);
var params = {};
var parts = query.split('&');
for (var i = 0; i < parts.length; i++) {
var pair = parts[i].split('=');
if (pair.length === 2) {
params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]);
}
}
return params;
}坑 4:面包屑导航重复显示
错误现象:分类/标签列表页的面包屑显示了两次。
原因:initWiki() 和 restoreFromUrl() 中重复调用 addBreadcrumbToList()。
解决方案:使用 breadcrumbAdded 标志变量,确保面包屑只添加一次。
var breadcrumbAdded = false;
function addBreadcrumbToList(type, listName) {
if (breadcrumbAdded) return;
// ... 添加面包屑
breadcrumbAdded = true;
}在 PJAX 完成后重置标志:
function onPjaxComplete() {
setTimeout(function() {
breadcrumbAdded = false;
initWiki();
}, 50);
}坑 5:getRequestUri() 生成错误链接
错误现象:点击文章链接后跳转到错误路径。
原因:$this->request->getRequestUri() 返回的是带查询参数的完整路径,不适合用于生成链接。
解决方案:使用 $this->permalink 获取当前页面的干净 URL。
// 错误写法
<a href="<?php echo $this->request->getRequestUri(); ?>?cid=<?php echo $p['cid']; ?>">
// 正确写法
<a href="<?php echo $this->permalink; ?>#cid=<?php echo $p['cid']; ?>">四、关键配置
1. 父级分类 ID
在模板中修改 $wiki_parent_id 变量:
$wiki_parent_id = 43; // 你的 Wiki 根分类 ID2. 页面缩略名
确保独立页面的缩略名(slug)为 page-wiki,或在 getWikiPageUrl() 函数中修改。
五、最终效果
| 场景 | 表现 |
|---|---|
访问 page-wiki.html | 显示欢迎页,左侧菜单全部收起 |
| 点击分类标题 | 展开/收起该分类下的文章列表 |
| 点击文章标题 | 右侧无刷新加载文章内容,URL 变为 page-wiki.html#cid=xxx |
| 刷新页面 | 保持当前文章内容,不报错 |
| 点击文章中的分类/标签 | 跳转到对应的分类/标签文章列表页 |
| 浏览器前进/后退 | 正常恢复文章状态 |
| PJAX 加载 | 兼容主题的 PJAX 无刷新加载 |
六、经验总结
- 了解 类型cho 的数据结构:
内容s表存文章/页面,metas表存分类/标签,relationships表存关联关系 - 避免使用查询参数:类型cho 自定义页面的 URL 尽量不要带查询参数,使用 Hash 是更可靠的方案
- 善用事件委托:使用
document.addEventListener('click', function(e) { ... })确保 PJAX 加载后事件仍然有效 - 数据预加载:在服务端一次性收集所有数据,避免客户端多次请求
- 标志变量防重复:在动态添加 DOM 元素时,使用标志变量防止重复添加
- 兼容性优先:始终考虑 PJAX 兼容性,在
pjax:complete事件中重新初始化 JavaScript