我为小说忙:(四)插件(写入部分)
上次是读取部分,这次就轮到写入了。
更具体地说,『写入』指的是向插件新增的三张数据表插入数据,因为已经不用Typecho自定义字段作为主数据源了,因此这一部分就很必要。
图文无关,就是相册有了1. 前言
开发笔记写到第四篇,插件结构也慢慢成型了。预计再过一篇文章,插件就能正式上线了。毕竟,笔者已经期待TPNovel很久了,因为手头是真的有二十万字的小说(纸质版)放着,是写在学校发的笔记本里的。从这几个笔记本里面,能明显看到写作风格从幼稚到不那么幼稚,感觉不放上来确实有些可惜,感觉有些浪费高中生活了。
上一篇文章提到,TPNovel在数据库里添加了三张表(书、卷、章),显然此时Typecho并没有任何对其进行管理的能力,因此现在就来添加一些管理措施。具体来说,三张表都要有对应的管理面板,且对于章,则还需要通过TPNovel挂接Hook点,在发布页面时自动添加,删除页面时也自动删除,实现自动管理。
当然,阅前提醒,本文更像是东拼西凑拼起来的,因为越写越不对劲。按照往常习惯,笔者是先大致写几个标题与大致思路,再补充的,然而这次的开发过程中,很多Corner Case一个接一个冒出来,不实际跑测试根本不知道会有这么刁钻的事情发生。于是经常地,从编辑器复制一大段代码过来做讲解,结果跑了几个测试之后,就得回来改代码了,因为有个逻辑Bug(人工写代码,不是Vibe Coding)。所以,本文恐怕会显得有些杂乱。
2. 后台管理页
先来聊聊管理页吧,毕竟如果不想每次都开数据库管理软件的话,有个小页面是很适合的。
2.1 挂在哪?
Typecho官方文档里,有一份高级开发技巧,实际上就是一些比较方便的接口的文档。这里我们重点来看『添加菜单』与『添加面板』。他们的作用是在后台添加菜单项,并增加新页面(就是指定一个文件来作为管理页),供管理员选择。从直觉来说,这两个方法能实现我们的需求。
public static function activate() {
// 先添加菜单,再添加面板
$menuIndex = Helper::addMenu('TPNovel');
Helper::addPanel(
$menuIndex,
'TPNovel/panel.php',
'TPNovel 元数据',
'编辑 TPNovel 元数据',
'administrator'
);
// 其他逻辑省略...
}
public static function deactivate() {
$menuIndex = Helper::removeMenu('TPNovel');
Helper::removePanel($menuIndex, 'TPNovel/panel.php');
// 其他逻辑省略...
}注意addPanel的第二个参数,官方文档称其为『面板文件名(相对于插件目录)』。这里有歧义,容易让人误以为是直接写面板文件名就好,Typecho会去插件目录加载。但实际上,这里指的是相对于插件总目录,也就是usr/plugins,因此需要在里面把插件本身所在的文件夹名字也补上,才能正确加载。
这里把面板文件注册进去之后,后台就会多出来一个菜单项:
如图所示其指向的路径是admin/extending.php,这玩意最主要的作用当然就是加载面板文件,但在此之外其实还有个好处:他还能让Typecho核心跑起来,这使得一些功能可以正常使用,例如我们所需要的数据库,以及Typecho的Widget:
// 放在panel.php开头
if(!defined('__TYPECHO_ADMIN__')) {
die();
}
// 保证用户权限足够
// 当然其实上面添加面板的时候已经校验过了
// 这里只是以防万一
// 另外,此处的写法是 \Typecho\Widget
// 写成 Typecho_Widget 也是可以正常识别的
// 就是检查器会认为后者未定义,显得不太好看罢了
$user = \Typecho\Widget::widget('Widget_User');
if(!$user -> pass('administrator', true)) {
die('Unauthorized');
};这个面板文件,也是和我们之前的阅读页面差不多,由他来负责输出HTML内容。部分插件可能会选择复用Typecho后台的一些样式,做风格类似的页面,包括调用那堆Typecho自带的控件。但,笔者懒得慢慢找样式了,OpenCode,起来干活了!
2.2 页面与逻辑设计
先写好spec.md,把需求描述清楚,然后交给 DeepSeek V4 Flash,让他自己去读插件里的建表语句,确定表结构,然后再确定需要用到的控件,最后得到的产物如下:
简洁风,正好够用。下面还有个章管理没截上图关于AI味,个人感觉还行吧,至少没有那种紫蓝色渐变那么刺眼,而且也在全局AGENTS.md里面告诉他要简洁为主,尽量不要引入第三方样式库,也不用Emoji,这和之前的阅读页其实是差不多的。目前,页面上的数据都是占位符,真正用来提交数据的js代码也并不存在,不过按钮逻辑是已经写好了的,通过向控制台打印log的方式来确定功能工作是否正常。
另外还有件小事:刚开始的时候,DSv4给了个很像样的方案,然而查看源代码发现,他把元数据放在了HTML元素的dataset里,这就涉及到大量的DOM操作了。后来跟他说,把数据放变量里(也就是请求接口后直接放入),DOM仅用来展示,就好很多。不过这样一来,js里面处理这部分的代码也跟着多了,好在算上所有逻辑大概也就是几十KB,再加上传输过程中肯定有压缩,以及页面只是自用,就不管他了。
页面初步完工之后,就可以回到插件代码上来了。
3. 插件逻辑
关于写入部分的逻辑,主要还是可以分为三大部分:
- 将数据填充到管理页
- 接收管理页的编辑请求
- 处理Typecho编辑器的逻辑
由于是自己用的页面,因此可以放心上AJAX,在前端调用接口就行。另外,最后一点,主要是考虑到『连续更新』必然还是一个日常操作,因此希望在新发文的时候,能根据当时页面上设置的bookID,自动追加到最新一章下,不必每次都打开管理页。
3.0 接口转发层
这里依然是x.0分节号,主要是因为,这是做到后面才想起来的方法。
关于插件如何添加接口(也就是『路由』)的乱七八糟事情,已经在之前的博文里有所提及,这里不再赘述。总之就是,需要新开一个Action.php,在里面定义一个类,命名后缀为_Action,并继承Typecho_Widget,才能开始写自己的方法,并通过Helper::addRoute添加。然而,虽然这个方法可以多次调用,添加不同的接口,但这也意味着一些公用代码(例如接口鉴权)需要重复写很多次,显得有些浪费。
一个更优的方案是,只添加一个前缀,指向转发层,再在转发层里面做公用部分,然后再分发。
// Plugin.php
public static function activate() {
Helper::addRoute(
'tpnovel-mgr-get-data', '/tpn-mgr/[action:alpha]',
'TPNovel_Action', 'apiProxy'
);
}
// Action.php
private static string $apiAction;
public function __construct($request) {
// 在构造函数中获取Action
self::$apiAction = $request->get('action');
}
public static function apiProxy() {
// 省略一些必要的处理,例如CSRF,鉴权,body解码...
$route = [
'getData' => 'getManagePageData'
];
$action = $route[self::$apiAction];
if(!is_callable([self::class, $action])) {
tpnUtils::dieJSON(false, '调用错误'); //这个 tpnUtils 下面会提
}
// 调用方法
// 第二个参数$reqData,是对POST body进行JSON解码所得
// 非POST情况下为空数组
// 相关代码编写不难,此处省略
self::$action(
Db::get(), $reqData
);
}
// 实际逻辑发生在这里
private static function getManagePageData($db, $data) {}上面举的例子,就是下面要说的获取管理页数据。
另外,为了方便开发,这里再补一些便利方法。插件目录下新建一个Utils.php,里面开一个类叫tpnUtils,把一些杂七杂八的便利方法放进去就好了。另外,为了节约篇幅起见,便利方法的代码也不放出来了,不过会在注释里说明白此为何用,交给现在的任何一个LLM,应该都可以复刻一个功能一模一样的。
class tpnUtils {
public static function dieJSON(
bool $isSucess,
String $reason,
mixed $data = []
) {
die(json_encode([
'ret' => ($isSucess) ? 1 : 0,
'msg' => $reason,
'data' => (object)$data
], JSON_UNESCAPED_UNICODE));
}
// ... 其他方法省略 ...
}然后就可以正式开始了。
3.1 获取管理页数据
打开页面的时候,上面肯定是要加载当前状态的。这部分相对来说比较简单,只需要查数据库然后输出就行,所以正好放在第一个,顺便测试一下转发层是否能工作。
// getManagePageData()
// 查数据库部分略,就是依次对三张表查一次
// 然后在该JOIN的地方,比如拿标题
// 就去JOIN一下Typecho的contents表
// 和书架页的INNER JOIN不同,这里是 LEFT JOIN
// 确保管理员可以先加书本再写目录页
// 虽然实操过程中没啥意义就是了
// 除非能精确预测到目标cid,或者回来改
// 最后把拿到的数据组合输出
$out = [];
foreach($books as $book) {
$out[$book['cid']] = $book;
$out[$book['cid']]['volumes'] = (object)[];
}
foreach($volumes as $vol) {
if (!isset($out[$vol['bookID']])) {
// 这里是为了避免删除某一书后
// 后续的卷-章数据被写入不存在的键而报错
continue;
}
$out[$vol['bookID']]['volumes'] -> {$vol['id']} = (object)$vol;
$out[$vol['bookID']]['volumes']
-> {$vol['id']}
-> chapters = (object)[];
}
foreach($chapters as $cht) {
if (!isset($out[$cht['bookID']]['volumes'] -> {$cht['volumeID']})) {
// 作用同上,这里缺的是卷
continue;
}
$out[$cht['bookID']]['volumes']
-> {$cht['volumeID']}
-> chapters
-> {$cht['id']} = (object)$cht;
}
die(json_encode([
'ret' => 1,
'msg' => '成功',
'data' => (object)$out
]));上面的组合代码,看起来有些绕,尤其是前两个foreach里面第二行,好像去掉也不影响。其实,这是为了特殊情况而兜底设计的。考虑这个情况:一本书下面暂时没有卷,或者一个卷下面暂时没有章,按照默认写法,就会导致volumes或chapters字段不存在(因为根本没有那个ID,自然也不会赋值)。在某些情况下,消失的字段会引起问题,比如说前端js报错引用了不存在的键。第二行的兜底代码,保证了不论在什么情况下,都确定地存在这个字段(哪怕是空对象),而不是在那玩接口变形记。
最后返回的就是干净的JSON了,前端做fetch就行。
如图所示,此处数字ID成了字符串,是Typecho对PDO设置如此3.2 管理接口
从页面上可以看到,这里所需要的管理,其实就是对三个目标进行增删改。可能有朋友会想到,增和改可以合并为一个操作,也就是upsert(不存在就插入,存在/主键冲突就更新)。但笔者在权衡之后,考虑到更新时可能需要执行一些附加逻辑,以及数据库设计的原因,还是决定将增和改拆分为两个接口,两套SQL语句。同样是为了节约篇幅,这里就只记录最复杂的书本信息增删改接口,对于分卷与章节的增删改,读者可以举一反三。
之前提到,那个管理页面目前让ds做了模拟,也就是在控制台打出需要提交的数据,这里正好拿来参考。另外,由于写页面时无后端,所以各类渲染逻辑都是在前端完成的,因此也顺便省去了根据后端代码的情况来渲染页面的需求,只需要根据接口操作的成功与否,来决定是否渲染即可。
404是因为开发环境没有图片,无需理会例如,上图中的添加书本,对应的方法如下:
// 类型是Private,不要紧
// apiProxy里面能调用到就行
private static function addBook(Db $db, mixed $data) {
// 检查参数是否齐全
// 不齐全的,直接在里面结束进程
tpnUtils::checkParameter($data,
['cid', 'name', 'cover', 'tags', 'status', 'sort']
);
// 计算必要值
// 对于sort来说,如果小于0
// 就需要从数据库读取上一条sort,并 +10,作为新的sort
// 否则,原数据直接作为新的sort值
// 另外,此函数其实还有第四个第五个参数,是bookID与volume
// 填上这两个参数,就会多加where条件再去取值
// 是因为sort有效范围仅限于同一本书的同一卷内
$sort = tpnUtils::getTargetSortValue($db, 'book', $data['sort']);
// 由于是添加,直接插入就行
// 第二个参数"book",tpnUtils内会转换为真正的表名
$ret = tpnUtils::insertIntoTable($db, 'book', [
'cid' => $data['cid'],
'name' => $data['name'],
'cover' => $data['cover'],
'tags' => $data['tags'],
'status' => $data['status'],
'sort' => $sort,
'wordCount' => 0,
'chapterCount' => 0
]
);
if($ret['ret'] == 1) {
// 返回新ID,让前端匹配添加,而不是自己递增
tpnUtils::dieJSON(true, "成功", [
'insertID' => $ret['insertID']
]);
} else {
tpnUtils::dieJSON(false, $ret['msg']);
}
}如上就实现了添加接口。对于编辑接口与删除接口,其实大同小异,无非就是把调用的方法变成tpnUtils::updateTable和tpnUtils::deleteItem就好了。甚至这两个方法也只是包装了一层SQL语句而已,因此也不再放上来了。
当然,需要注意的地方比较多。由于小说的书-卷-章本质上是树形结构,从这一个本质上出发,就可以总结出来一堆的注意事项:
- 对于编辑来说,如果修改了书本的cid,就需要在对应的卷表和章节表里面也做一次修改(因为这哥俩好用cid来找书),因此需要单独处理一下,大概就是写两条SQL语句,同步更新。
- 由于树形结构内最好不要出现孤儿节点,因此如果删除某一个父节点(例如卷),应该处理好子节点的问题。TPNovel最后选用的是Cascade方案,也就是删除父节点的时候,连同子节点一起删除。由于破坏性比较强,因此需要做好安全防护。批量删除的话,需要单开几个函数,比如说按书和卷批量删除章、按书批量删除卷。如果是一个真正的小说站,此处最好实现为事务,但对于TPNovel来说,因为几乎不存在删除的需求,分开执行也无伤大雅。
3.3 文章的Hook
很显然,对于『更新章节』这个高频操作来说,每一次写完后还要打开管理页面添加,是很麻烦的事情。因此,这里需要准备两个接口,来实现自动添加与删除章节。查阅官方文档,可以找到合用的Hook,也就是finishPublish和finishDelete。因此只需要在这两个Hook下添加动作函数,判断需要进行操作后,就转发到Action.php的对应动作函数里。当然,此时需要把函数改为public,不过无伤大雅。
具体的代码还是比较长的,因此还是不放上来了。核心部分很短,毕竟大部分内容都放在了各种检查上,比如说:
- 检查页面是否设置了tpn_bookID与tpn_volumeID
- 检查书,卷是否存在
- 计算volumeID
以上任何一个流程失败,都不会作保存处理。最后到核心部分,检查记录是否存在,无就插入。此处不存在『编辑』的选项,或者说,后续编辑只用于更新字数,真正的编辑需要到管理页进行,而不是调整自定义字段。
4. 写在最后
写到现在,就一句话:corner case是真的多。开发流程大部分时间都耗在了处理逻辑细节上,尤其是那些跑了测试才发现的问题,此处仅举几例:
- 自动添加章节时,怎么处理sort?
- 如何复用一个会自动
die()的方法? - 什么时候用主键ID,什么时候用cid?
- 批量删除有两种情况,按volumeID与按bookID,要不要合在一起?
- 怎么处理孤儿节点?
- 何时计算字数?
等等等等。
反正这几天下来是脑子一团浆糊了,先休息一下吧。
(完)