我为小说忙:(四)插件(写入部分)

上次是读取部分,这次就轮到写入了。

更具体地说,『写入』指的是向插件新增的三张数据表插入数据,因为已经不用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设置如此如图所示,此处数字ID成了字符串,是Typecho对PDO设置如此

3.2 管理接口

从页面上可以看到,这里所需要的管理,其实就是对三个目标进行增删改。可能有朋友会想到,增和改可以合并为一个操作,也就是upsert(不存在就插入,存在/主键冲突就更新)。但笔者在权衡之后,考虑到更新时可能需要执行一些附加逻辑,以及数据库设计的原因,还是决定将增和改拆分为两个接口,两套SQL语句。同样是为了节约篇幅,这里就只记录最复杂的书本信息增删改接口,对于分卷与章节的增删改,读者可以举一反三。

之前提到,那个管理页面目前让ds做了模拟,也就是在控制台打出需要提交的数据,这里正好拿来参考。另外,由于写页面时无后端,所以各类渲染逻辑都是在前端完成的,因此也顺便省去了根据后端代码的情况来渲染页面的需求,只需要根据接口操作的成功与否,来决定是否渲染即可。

404是因为开发环境没有图片,无需理会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::updateTabletpnUtils::deleteItem就好了。甚至这两个方法也只是包装了一层SQL语句而已,因此也不再放上来了。

当然,需要注意的地方比较多。由于小说的书-卷-章本质上是树形结构,从这一个本质上出发,就可以总结出来一堆的注意事项:

  • 对于编辑来说,如果修改了书本的cid,就需要在对应的卷表和章节表里面也做一次修改(因为这哥俩好用cid来找书),因此需要单独处理一下,大概就是写两条SQL语句,同步更新。
  • 由于树形结构内最好不要出现孤儿节点,因此如果删除某一个父节点(例如卷),应该处理好子节点的问题。TPNovel最后选用的是Cascade方案,也就是删除父节点的时候,连同子节点一起删除。由于破坏性比较强,因此需要做好安全防护。批量删除的话,需要单开几个函数,比如说按书和卷批量删除章、按书批量删除卷。如果是一个真正的小说站,此处最好实现为事务,但对于TPNovel来说,因为几乎不存在删除的需求,分开执行也无伤大雅。

3.3 文章的Hook

很显然,对于『更新章节』这个高频操作来说,每一次写完后还要打开管理页面添加,是很麻烦的事情。因此,这里需要准备两个接口,来实现自动添加与删除章节。查阅官方文档,可以找到合用的Hook,也就是finishPublishfinishDelete。因此只需要在这两个Hook下添加动作函数,判断需要进行操作后,就转发到Action.php的对应动作函数里。当然,此时需要把函数改为public,不过无伤大雅。

具体的代码还是比较长的,因此还是不放上来了。核心部分很短,毕竟大部分内容都放在了各种检查上,比如说:

  • 检查页面是否设置了tpn_bookID与tpn_volumeID
  • 检查书,卷是否存在
  • 计算volumeID

以上任何一个流程失败,都不会作保存处理。最后到核心部分,检查记录是否存在,无就插入。此处不存在『编辑』的选项,或者说,后续编辑只用于更新字数,真正的编辑需要到管理页进行,而不是调整自定义字段。

4. 写在最后

写到现在,就一句话:corner case是真的多。开发流程大部分时间都耗在了处理逻辑细节上,尤其是那些跑了测试才发现的问题,此处仅举几例:

  • 自动添加章节时,怎么处理sort?
  • 如何复用一个会自动die()的方法?
  • 什么时候用主键ID,什么时候用cid?
  • 批量删除有两种情况,按volumeID与按bookID,要不要合在一起?
  • 怎么处理孤儿节点?
  • 何时计算字数?

等等等等。

反正这几天下来是脑子一团浆糊了,先休息一下吧。

(完)


木头箱子脆脆,但是这样正好

如无特殊声明,本站内容遵循 CC BY-NC-SA 4.0 协议

转载请注明出处并保留作者信息,谢谢!

本站由 搬瓦工VPS 强力驱动

none
最后修改于:2026年07月28日 00:10

添加新评论

提醒:『评论回复邮件提醒』功能正在测试中
评论后,如果站长有回复,会有邮件通知