Xiuno BBS 开发实践教程 - 网站大事记
Tillreetree 14天前

Xiuno BBS 开发实践教程 - 网站大事记

本文要求你已经阅读并理解了《Xiuno BBS 开发实践教程 - 单页》一文,以及了解PHP、HTML、MySQL等基础知识。

并额外期待你了解JavaScript Ajax请求(尤其是使用jQuery发送Ajax请求)相关知识。

注意:本文可能枯燥无味(因为涉及更多代码),但如果你愿意耐心阅读,那么你将会收获很多。

本文将会更加聚焦于代码本身,辅佐适当的注释来解释代码的细节。


需求分析

你盯着之前制作好的单页插件,心想:“嘿,要是有个地方能记录我们网站成长的每一步,岂不是更酷?”这不仅仅是给新用户一个了解网站历史的机会,也是老用户回忆往昔的温馨角落。于是,你决定开始新的冒险——创建一个“网站大事记”。

于是你就找到了核心需求:你想要将网站发展过程中的大事件记录下来,放在单独的页面里

因为“网站发展过程中的大事件”的数据结构比单页复杂一些,所以数据结构选择创建数据库表

表的名字是bbs_events_log,字段有:

字段名 数据类型 是否为空 默认值 备注
id int NOT NULL AUTO_INCREMENT 主键
title varchar(255) NOT NULL   标题
content text NOT NULL   内容
create_time int NOT NULL   发布时间

通过这样的设计,我们可以轻松地记录每一次重要的更新或活动,并以友好的方式向用户展示这些信息。接下来,我们将一步步实现这个功能。

你知道吗?在古代,人们用石碑来记录重大事件,而今天,我们则用代码和数据库。技术进步的速度,真让人惊叹!

了解新概念

Xiuno BBS的数据库函数

Xiuno BBS将PDO等数据库操作函数再次封装成db系列函数,开发者无需关心PDO等底层细节,也方便用户使用不同的数据库引擎,如MySQL、SQLite等。

函数包括:

/**
 * 执行 SQL 查询并返回单条记录。
 *
 * @param string $sql SQL 查询语句
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return array|bool 成功返回查询结果数组,失败返回 FALSE
 */
function db_sql_find_one($sql, $d = NULL);

/**
 * 执行 SQL 查询并返回多条记录。
 *
 * @param string $sql SQL 查询语句
 * @param string|null $key 指定返回数组的键名,默认为NULL
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return array|bool 成功返回查询结果数组,失败返回 FALSE
 */
function db_sql_find($sql, $key = NULL, $d = NULL);

/**
 * 执行 SQL 语句。
 *
 * @param string $sql SQL 语句
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return mixed 根据执行的 SQL 类型返回相应结果
 */
function db_exec($sql, $d = NULL);

/**
 * 计算指定表中满足条件的记录数。
 *
 * @param string $table 表名
 * @param array $cond 条件数组
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return int|FALSE 成功返回计数值,失败返回 FALSE
 */
function db_count($table, $cond = array(), $d = NULL);

/**
 * 获取指定表中的最大ID值。
 *
 * @param string $table 表名
 * @param string $field 字段名
 * @param array $cond 条件数组
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return int|FALSE 成功返回最大ID值,失败返回 FALSE
 */
function db_maxid($table, $field, $cond = array(), $d = NULL);

/**
 * 插入一条新记录到指定表。
 *
 * @param string $table 表名
 * @param array $arr 要插入的数据数组
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return int|FALSE 成功返回插入的ID或受影响的行数,失败返回 FALSE
 */
function db_insert($table, $arr, $d = NULL);

/**
 * 替换一条记录到指定表,如果存在则更新,不存在则插入。
 *
 * @param string $table 表名
 * @param array $arr 要替换的数据数组
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return int|FALSE 成功返回插入的ID或受影响的行数,失败返回 FALSE
 */
function db_replace($table, $arr, $d = NULL);

/**
 * 更新指定表中的记录。
 *
 * @param string $table 表名
 * @param array $cond 条件数组
 * @param array $update 要更新的数据数组
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return int|FALSE 成功返回受影响的行数,失败返回 FALSE
 */
function db_update($table, $cond, $update, $d = NULL);

/**
 * 删除指定表中的记录。
 * 慎用!
 * @param string $table 表名
 * @param array $cond 条件数组
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return int|FALSE 成功返回受影响的行数,失败返回 FALSE
 */
function db_delete($table, $cond, $d = NULL);

/**
 * 清空指定表中的所有数据。
 * 慎用!
 * @param string $table 表名
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return bool 成功返回 TRUE,否则返回 FALSE
 */
function db_truncate($table, $d = NULL);

/**
 * 读取指定表中的一条记录。
 *
 * @param string $table 表名
 * @param array $cond 条件数组
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return array|bool 成功返回查询结果数组,失败返回 FALSE
 */
function db_read($table, $cond, $d = NULL);

/**
 * 查找指定表中的记录,可分页。
 *
 * @param string $table 表名
 * @param array $cond 条件数组
 * @param array $orderby 排序数组
 * @param int $page 页码
 * @param int $pagesize 每页显示数量
 * @param string $key 返回数组的键名
 * @param array $col 要查询的字段列表
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return array|bool 成功返回查询结果数组,失败返回 FALSE
 */
function db_find($table, $cond = array(), $orderby = array(), $page = 1, $pagesize = 10, $key = '', $col = array(), $d = NULL);

/**
 * 查找指定表中的一条记录。
 *
 * @param string $table 表名
 * @param array $cond 条件数组
 * @param array $orderby 排序数组
 * @param array $col 要查询的字段列表
 * @param object|null $d 数据库连接对象,默认为NULL,将使用全局默认连接
 * @return array|bool 成功返回查询结果数组,失败返回 FALSE
 */
function db_find_one($table, $cond = array(), $orderby = array(), $col = array(), $d = NULL);

这将是你操作数据库的基石。

cond 条件数组详解

在使用上述提供的数据库操作函数时,条件数组 $cond 用于构建 SQL 查询的 WHERE 子句。

条件数组的键对应数据库中的字段名称,而值则可以采用多种形式来表达查询条件:

  • 简单等值匹配
    • 当值不是数组时,表示对字段进行等值匹配。例如,['id' => 123] 将生成 WHERE id = 123
  • 多值匹配
    • 当值是一个包含多个元素的数组时,表示字段的值需要匹配这些元素中的任何一个。例如,['id' => array(1, 2, 3)] 将生成 WHERE (id=1 OR id=2 OR id=3)
  • 范围匹配
    • 当值是一个关联数组,其中键为比较运算符(如 ><= 等),值为比较值时,表示字段的值需要满足特定的范围或比较条件。例如,array('age' => array('>' => 18, '<=' => 30)) 将生成 WHERE age > 18 AND age <= 30
    • 支持小于、大于、小于等于、大于等于等比较运算符。
  • 模糊匹配
    • 当值是一个关联数组,且键为 LIKE (全大写) 时,表示字段的值需要部分匹配给定的字符串。例如,['name' => array('LIKE' => 'John')] 将生成 WHERE name LIKE '%John%'。你不需要特意写百分号,它会自动添加。

orderby 排序数组详解

排序数组 $orderby 用于构建 SQL 查询的 ORDER BY 子句,以确定查询结果的排序方式。数组的键对应数据库中的字段名称,而值决定了排序的方向:

  • 如果值为 1,则表示该字段按升序排列ASC。例如,['name' => 1] 将对用 ORDER BY name ASC
  • 如果值不是 1(通常是0或-1),则表示该字段按降序排列DESC。例如,['date' => -1] 将对用 ORDER BY date DESC
    • 务必注意这一点,如果不是1,则表示降序排列。
    • 因为这个确实是有误导性,因为只用数字是看不出来排序方向的。如果你愿意的话,请自行定义两个常量,比如const ASC = 1; const DESC = 0;来消除误会。
      • 再次强调:如果不是1,则表示降序排列。

排序数组可以包含多个字段,以实现多级排序。例如,['name' => 1, 'date' => -1] 将首先按name字段升序排序,如果name字段值相同,则按date字段降序排序。

  • 排序的顺序将按照数组中的顺序进行。

key参数详解(用于db_find、db_sql_find)

假设 bbs_thread 表中有以下数据(简化例子):

tid subject content username create_time
80 第一主题 这是第一个帖子的内容 用户A 2023-01-01
81 第二主题 这是第二个帖子的内容 用户B 2023-01-02
82 第三主题 这是第三个帖子的内容 用户C 2023-01-03

当指定了$key参数时,返回数组的键名则为$key参数指定的字段内容。例如,db_find('bbs_thread', array(), array(), 1, 20, 'tid')返回的数组的键将会是tid的内容。在foreach循环等场合可能有用。

返回的数组将是:

array(
    80 => array(
        'tid' => 80,
        'subject' => '第一主题',
        'content' => '这是第一个帖子的内容',
        'username' => '用户A',
        'create_time' => '2023-01-01'
    ),
    81 => array(
        'tid' => 81,
        'subject' => '第二主题',
        'content' => '这是第二个帖子的内容',
        'username' => '用户B',
        'create_time' => '2023-01-02'
    ),
    82 => array(
        'tid' => 82,
        'subject' => '第三主题',
        'content' => '这是第三个帖子的内容',
        'username' => '用户C',
        'create_time' => '2023-01-03'
    )
)

col参数详解(用于db_find、db_find_one)

用于指定要查询的字段列表,默认为空数组,表示查询所有字段。例如:db_find('bbs_thread', array(), array(), 1, 20, '', array('tid','subject'))将只会返回tid和subject字段的内容。适合“我不想要那么多数据”的场景。

返回的数组将是:

array(
    0 => array(
        'tid' => 80,
        'subject' => '第一主题'
    ),
    1 => array(
        'tid' => 81,
        'subject' => '第二主题'
    ),
    2 => array(
        'tid' => 82,
        'subject' => '第三主题'
    )
)

我应该获取所有字段的内容还是只获取一部分字段的内容?

虽然这是一个相对主观的决策,但Xiuno BBS的立场为我们提供了一些有价值的见解。

Xiuno BBS的观点:

在Xiuno BBS的架构中,由于缓存层的存在,即使使用了 SELECT *,也不会对性能造成太大的影响,因为缓存能够减少数据库的直接访问次数

另外我们坚持用 SELECT * 而不写长条字段,也是有原因的,因为我们可以在中间加入缓存。比如用户数据,我们按条去,按条缓存,在开启 memcached, yac 后,中间的这些 SQL 都消失了。

权衡利弊:

使用 SELECT * 的优点

  • 不需要列出所有所需的字段名,减少了代码维护的工作量。
  • 当数据库表结构发生变化时(如增加新字段),不需要修改查询代码
  • 缓存机制完善的情况下,可以减少数据库的直接访问。

使用 SELECT * 的缺点

  • 在某些情况下,可能会暴露敏感信息,如密码等。
    • 所以xiuno bbs选择在获取之后进行处理,如user_format函数会删掉密码字段。
  • 在没有缓存或故意不使用缓存的情况下,查询大量数据会影响性能。
    • 一些开发者往往忽视缓存机制,导致查询大量数据时对性能的影响。我就不在此点出是谁了。

指定字段查询的优点

  • 只查询需要的字段,减少了数据传输和处理的开销。
  • 避免暴露不必要的字段信息,如密码等。
  • 在数据库层面减少了数据的读取和处理量。
    • 但是缓存机制的存在,已经可以减少数据的读取和处理量了。

指定字段查询的缺点

  • 当数据库表结构发生变化时,需要更新查询代码。
    • 但万一你忘了呢?如果你不自己看数据库里有哪些列,那你可能会不知道新的结构是什么。用SELECT *可以让你一直知道最新的结构。
  • 如果遗漏了某些必要的字段,可能会导致程序出错。
    • 例如你的插件写死了“获取某些字段”,但未来数据库表结构发生变化,你没有将新的字段加入查询代码中,就相当于你遮住了自己的眼睛。

你的服务器硬盘

  • 如果你使用的是SSD,那么指定字段查询所节约的开销并不明显。
  • 如果你使用的是机械硬盘,那么缓存机制会填补这部分性能损失。
    • 你可能会认为PHP这种“过时的语言”是导致网站性能低的原因之一,但我不这么认为。哪怕你用PHP 7,只要开启OPCache,也能有不错的性能;而使用PHP 8再开启JIT,性能会进一步提升。
    • 因此,无论你是使用PHP 7还是PHP 8,只要合理配置并充分利用其优化特性,都可以获得出色的性能表现。
    • 此外,结合良好的数据库设计和缓存策略,你完全不必担心PHP会成为瓶颈。

综上所述

你应该放心地使用 SELECT *

jQuery Ajax请求

为了让你能更方便地与服务器进行数据交互,Xiuno BBS提供了jQuery Ajax请求的封装函数。当然,jQuery自身的Ajax相关函数保持原样,只是Xiuno BBS再次封装了一遍。

为什么要用Ajax?

  • 页面不刷新,用户体验更好。
    • 不是所有的操作都必须刷新页面的。
  • 减轻服务器压力,提高性能。
    • 因为Xiuno BBS的设计哲学之一就是“将一些任务交给客户端来做,而不是让服务器来处理”。
  • 解耦代码,提高可维护性。
    • 虽然Xiuno BBS依旧是很传统的那种PHP+HTML的架构,但Xiuno BBS是支持API输出的,所以用Ajax是非常明智的选择。

标准jQuery Ajax请求

Get

$.get()方法用于发送GET请求,获取数据。基本语法如下:

$.get('your-url-here', { key: 'value' }, function(response) {
  console.log(response);
}, 'json');
  • 第一个参数是请求的URL。
  • 第二个参数是发送到服务器的数据。
  • 第三个参数是请求成功时的回调函数。
  • 第四个参数是预期的返回数据类型。
Post

$.post()方法用于发送POST请求,通常用于提交表单数据。基本语法如下:

$.post('your-url-here', { key: 'value' }, function(response) {
  console.log(response);
}, 'json');

参数与$.get()相同,但请求类型为POST。

Xiuno BBS的Ajax请求

XGet

与标准的 $.get() 方法不同,$.xget() 在处理非 JSON 响应时能够返回错误回调,并且支持重试机制。

参数:

  • url (string):请求的 URL。
  • callback (function|callable):请求完成后的回调函数。回调函数接收两个参数:codemessage
    • code (number):0 表示成功,非 0 表示失败。
      • 通常1是用户出错,-1是服务器出错。
      • 但很奇葩的是,Xiuno BBS允许在code字段里写string类型的数据,用于指示具体哪个表单控件产生错误。你不要这样写啊。
      • 这要求服务器必须以这个格式返回数据:{code:0, message:'具体内容'}
    • message (any):服务器返回的数据。可能是string或object。
  • retry (number):重试次数,默认为 0(不重试)。

用法:

$.xget('your-url-here', function(code, message) {
    if (code === 0) {
        alert('成功');
    } else {
        alert('错误:' + message);
    }
}, 3); // 最多重试3次
XPost

与标准的 $.post() 方法不同,$.xpost() 在处理非 JSON 响应时能够返回错误回调,并且支持进度回调函数。

参数:

  • url (string):请求的 URL。
  • postdata (object|string):发送到服务器的数据。
  • callback (function|callable):请求完成后的回调函数。回调函数接收两个参数:codemessage
    • 这要求服务器必须以这个格式返回数据:{code:0, message:'具体内容'}
  • progress_callback (function):进度回调函数,参数为一个数值(0-100),表示请求的进度。
    • 对于上传文件来说很有用。

用法:

$.xpost('your-url-here', "key=value", function(code, message) {
    // 请求完成后的回调
    if (code === 0) {
        alert('成功');
    } else {
        alert('出现错误');
        console.warn('错误:' + message);
    }
}, function(progress) {
    // 进度回调
    console.log('上传进度:' + progress.toFixed(2) + '%');
    // 在这里你就可以改变进度条读数了
    document.querySelector('progress#upload_progress').value = progress.toFixed(0);
});

开始制作插件

自述文件 conf.json

创建文件夹my_events_log,在文件夹内创建conf.json文件。

内容如下:

{
    "name":"网站大事记", 
    "brief":"教程插件", 
    "version":"1.0.0", 
    "bbs_version":"4.0.4",
    "installed":0, 
    "enable":0, 
    "hooks_rank":[], 
    "overwrites_rank":[], 
    "dependencies":[] 
}

安装插件文件 install.php

在安装插件的时候,我们需要往数据库里创建表,才能让插件正常工作。

在文件夹my_events_log内创建install.php文件。

<?php
// 防止直接访问
!defined('DEBUG') AND exit('Forbidden');
// 数据库表前缀
$tablepre = $db->tablepre;

$sql = "CREATE TABLE IF NOT EXISTS {$tablepre}events_log (
    id INT NOT NULL AUTO_INCREMENT,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    create_time INT NOT NULL,
    PRIMARY KEY (id)
);";
// 执行创建表的SQL
$r = db_exec($sql);

卸载插件文件 unstall.php

卸载的时候可以将之前创建的表删掉。

在文件夹my_events_log内创建unstall.php文件。

<?php
// 防止直接访问
!defined('DEBUG') AND exit('Forbidden');
// 数据库表前缀
$tablepre = $db->tablepre;

$sql = "DROP TABLE IF EXISTS {$tablepre}events_log;";
// 执行
$r = db_exec($sql);

业务函数

my_events_log/文件夹内创建model文件夹,然后进入model文件夹,创建文件events_log.func.php,内容如下:

<?php
// 【R】根据条件查询网站大事记列表。
function events_log_find($cond = [], $sort = ['id'=>0], $page = 1, $pagesize = 20) {
    $r = db_find('events_log', $cond, $sort,  $page, $pagesize);
    return $r;
}
// 【R】根据ID读取网站大事记内容。
function events_log_read($id) {
    $r = db_find_one('events_log', array('id'=>$id));
    return $r;
}
// 【C】根据ID创建网站大事记内容。
function events_log_create($title, $content) {
    $arr = array(
        'title'=>$title,
        'content'=>$content,
        'create_time'=>time(),
    );
    $r = db_insert('events_log', $arr);
    return $r;
}
// 【U】根据ID更新网站大事记内容。
function events_log_update($id,$title,$content) {
    $arr = array(
        'id' => $id,
        'title'=>$title,
        'content'=>$content,
        'create_time'=>time(),
    );
    $r = db_replace('events_log', $arr );
    return $r;
}
// 【D】根据ID删除网站大事记内容。
function events_log_delete($id) {
    $r = db_delete('events_log', array('id'=>$id));
    return $r;
}

这就是最基础的CRUD(Create、Read、Update和Delete)操作。

然后将业务函数注册到Xiuno BBS中

刚才的文件需要注册到Xiuno BBS中,才能被Xiuno BBS调用。

my_events_log/文件夹内创建hook文件夹,然后进入hook文件夹,创建文件model_inc_file.php,内容如下:

APP_PATH.'plugin/my_events_log/model/events_log.func.php',

我们可以观察Xiuno BBS目录/model.inc.php,在这个文件中首先是$include_model_files,注册了大量文件,这些文件都是Xiuno BBS内置的“Model层”业务代码。而最后一个model文件后面有个注释// hook model_inc_file.php,我们将自己的model文件路径放在这个hook中,就能让Xiuno BBS加载我们的model文件。

然后继续看$include_model_files的后面,有一个foreach循环,用于注册Xiuno BBS内置的Model层文件。简化版本为:

foreach ($include_model_files as $model_files) {
    include _include($model_files);
}

这个应该很好理解,就是Xiuno BBS加载所有注册的model文件。这样,我们就能在xiuno bbs的任何地方调用我们的model文件中的函数。

你甚至可以用$include_model_files数组来判断某插件是否已经安装,因为有些插件会注册自己的model文件到这里。因为$include_model_files是个全局变量,所以可以随处用foreach等手段来遍历。

// 判断某个插件是否已经安装的Flag变量
$is_some_plugin_installed = false;

//开始遍历
foreach ($include_model_files as $filename) {
    if(str_contains($filename, 'some_plugin')) {
        $is_some_plugin_installed = true; //我们要找的插件已经安装
        break; //找到后就立刻跳出循环
    } else {
        continue; //继续遍历;用continue加速循环
    }
}

if (!function_exists('str_contains')) {
    // 因为是php 8函数所以需要写一个polyfill
    function str_contains($haystack, $needle) {
        return $needle !== '' && mb_strpos($haystack, $needle) !== false; //意思是$haystack中是否包含$needle
    }
}

创建路由并编写业务逻辑

进入my_events_log/hook文件夹创建文件index_route_case_end.php,内容如下:

case 'events_log': 
    include APP_PATH.'plugin/my_events_log/route/events_log.php'; 
    break;

接着,我们需要创建处理页面请求的具体逻辑。

my_events_log文件夹里创建route文件夹。进入route文件夹,然后创建名为events_log.php的文件。此文件将包含处理展示、添加、编辑和删除大事记条目的逻辑。

<?php
// 获取操作类型
// 这样会对应events_log-{action}.htm的请求
$action = param(1, 'list');

switch ($action) {
    case 'add':
        // 添加新事件
        if (isset($_POST['submit'])) {
            // 更新的数据
            $data = [
                'title' => strip_tags(param('title', '')), // 标题不需要HTML标签
                'content' => param('content', ''),
            ];
            // 在前端虽然用required可以保证用户输入对应框的内容,但用户不一定会用网页提交请求,所以在后端进行检查是有必要的
            if (empty($data['title'])) {
                message(2, "请输入标题!");
            }
            if (empty($data['content'])) {
                message(2, "请输入内容!");
            }

            $r = events_log_create($data["title"], $data['content']);
            if ($r) {
                message(0, "添加成功!");
            } else {
                message(1, "添加失败,请重试!");
            }
        } else {
            include APP_PATH . 'plugin/my_events_log/view/htm/add.htm';
        }
        break;

    case 'edit':
        // 编辑现有事件
        $id = param('id', 0);
        $events_log = events_log_read($id);
        if ($events_log) {
            // 当有数据时才能编辑
            if (isset($_POST['submit'])) {
                // 更新的数据
                $data = [
                    'title' => strip_tags(param('title', '')), // 标题不需要HTML标签
                    'content' => param('content', ''),
                ];

                // 在前端虽然用required可以保证用户输入对应框的内容,但用户不一定会用网页提交请求,所以在后端进行检查是有必要的
                if (empty($data['title'])) {
                    message(2, "请输入标题!");
                }

                if (empty($data['content'])) {
                    message(2, "请输入内容!");
                }

                $r = events_log_update($id, $data["title"], $data['content']);
                if ($r) {
                    message(0, "更新成功!");
                } else {
                    message(1, "更新失败,请重试!");
                }
            } else {
                $id = param('id', 0);
                $event = events_log_read($id);
                include APP_PATH . 'plugin/my_events_log/view/htm/edit.htm';
            }
        } else {
            // 否则立刻提示错误
            message(1, '事件记录不存在');
        }
        break;

    case 'delete':
        // 删除事件
        $r = events_log_delete(param('id', 0));
        if ($r) {
            message(0, "删除成功!");
        } else {
            message(1, "删除失败,可能已经删掉了");
        }
        break;

    case 'list':
        // 列出所有事件

        // 当前页码
        $page = param('page', 1);
        // 每页显示多少条
        $pagesize = 10;
        // 事件总数
        $tmp_events_result = events_log_find([], ['create_time' => 0], 1, 1000);
        if (is_array($tmp_events_result)) {
            $total = count($tmp_events_result);
        } else {
            $total = 0;
        }
        // 事件数据
        $events = events_log_find([], ['create_time' => 0], $page, $pagesize);
        // 分页HTML
        $pagination = pagination(url("events_log-list-{page}"), $total, $page, $pagesize);

        include APP_PATH . 'plugin/my_events_log/view/htm/list.htm';
        break;
    case 'view':
        // 查看事件
        $id = param('id', 0);
        $events_log = events_log_read($id);
        if ($events_log) {
            // 当有数据时才能查看
            include APP_PATH . 'plugin/my_events_log/view/htm/view.htm';
        } else {
            // 否则立刻提示错误
            message(1, '事件记录不存在');
        }
        break;
    default:
        // 当未提供任何操作时,立刻结束;我们不能惯着用户,以为“这能访问所有事件列表”
        // 因为Xiuno BBS会在论坛版块等场合默认执行类似于list的操作
        message(1, '未知的操作');
        break;
}

创建前台页面

为了显示和管理大事记条目,我们需要创建一些页面。在my_events_log文件夹中创建view文件夹。进入view文件夹,在里面创建htm子文件夹,然后在htm文件夹里,创建以下文件:

  • add.htm——创建
  • edit.htm——编辑
  • list.htm——查看(列表)
  • view.htm——查看(详情)

add.htm

<?php include _include(APP_PATH.'view/htm/header.inc.htm');?>

<div class="container">
    <h2 class="page-header">添加新事件</h2>
    <a href="<?= url('events_log-list') ?>" class="btn btn-default">返回列表</a>
    <form method="post" action="<?= url('events_log-add') ?>">
        <div class="form-group">
            <label>标题</label>
            <input type="text" name="title" class="form-control" required>
        </div>
        <div class="form-group">
            <label>内容</label>
            <textarea name="content" class="form-control" rows="6" required></textarea>
        </div>
        <button type="submit" name="submit" class="btn btn-primary">提交</button>
    </form>
</div>

<?php include _include(APP_PATH.'view/htm/footer.inc.htm');?>

edit.htm

其实和add.htm差不多,只是每个input都预先填充了内容。

<?php include _include(APP_PATH.'view/htm/header.inc.htm');?>

<div class="container">
    <h2 class="page-header">编辑事件 <?= $event['id'] ?></h2>
        <a href="<?= url('events_log-list') ?>" class="btn btn-default">返回列表</a>
    <form method="post" action="<?= url('events_log-edit') ?>">
        <input type="hidden" name="id" value="<?= $event['id'] ?>">
        <div class="form-group">
            <label>标题</label>
            <input type="text" name="title" class="form-control" value="<?= $event['title'] ?>" required>
        </div>
        <div class="form-group">
            <label>内容</label>
            <textarea name="content" class="form-control" rows="6" required><?= $event['content'] ?></textarea>
        </div>
        <button type="submit" name="submit" class="btn btn-primary">更新</button>
    </form>
</div>

<?php include _include(APP_PATH.'view/htm/footer.inc.htm');?>

list.htm

<?php include _include(APP_PATH.'view/htm/header.inc.htm');?>

<div class="container">
    <h2 class="page-header">网站大事记列表</h2>
    <!-- 事件列表 -->
    <section class="timeline">
        <?php /* 只有管理员可用的操作 */ if($uid && intval($gid) === 1): ?>
        <!-- 添加新事件按钮 -->
        <article class="content card">
            <div class="card-body">
                <a href="<?= url('events_log-add') ?>" class="btn btn-success">+ 添加事件</a>
            </div>
        </article>
        <?php endif; ?>

        <?php /* 如果有事件的话 */ if($events): ?>
        <?php /* 遍历每个事件 */ foreach($events as $event): ?>
        <!-- 单个事件 -->
        <article class="content card">
            <div class="card-body">
                <!-- 标题 -->
                <h3 class="card-title"><?= $event['title'] ?></h3>
                <!-- 时间 -->
                <p><?= date('Y-m-d H:i', $event['create_time']) ?></p>
                <!-- 查看详情按钮 -->
                <a href="<?= url('events_log-view', ['id' => $event['id']]) ?>" class="btn btn-link">查看详情</a>
            </div>
            <?php /* 只有管理员可用的操作 */ if($uid && intval($gid) === 1): ?>
            <div class="card-footer">
                <a href="<?= url('events_log-edit', ['id' => $event['id']]) ?>" class="btn btn-xs btn-warning">编辑</a>
                <a href="<?= url('events_log-delete', ['id' => $event['id']]) ?>" class="btn btn-xs btn-danger" onclick="return confirm('确定删除?')">删除</a>
            </div>
            <?php endif; ?>
        </article>
        <?php endforeach; ?>
        <?php endif; ?>
    </section>

    <!-- 分页导航 -->
    <div class="text-center">
        <?= $pagination ?>
    </div>
</div>

<!-- 提前引入时间线样式,我们之后会提到的,相信我 -->
<link rel="stylesheet" href="./plugin/my_events_log/view/css/timeline.css">
<?php include _include(APP_PATH.'view/htm/footer.inc.htm');?>

view.htm

<?php include _include(APP_PATH.'view/htm/header.inc.htm');?>

<div class="container">
    <div class="panel card-default">
        <div class="card-body">
            <h3 class="card-title"><?= $events_log['title'] ?></h3>
            <small class="text-muted">发布时间:<?= date('Y-m-d H:i', $events_log['create_time']) ?></small>
        </div>
        <div class="card-body">
        <?= $events_log['content'] ?>
        </div>
        <div class="card-footer">
            <a href="<?= url('events_log-list') ?>" class="btn btn-default">返回列表</a>
        </div>
    </div>
</div>

<?php include _include(APP_PATH.'view/htm/footer.inc.htm');?>

在导航菜单里添加链接

我们还差一步就可以测试了!我们需要在导航菜单里添加这个新的页面,别人才能方便的找到这个页面并查阅。

进入hook文件夹,创建文件header_nav_forum_end.htm,内容如下:

<li class="nav-item">
    <a class="nav-link" href="<?= url('events_log-list') ?>">
        <i class="icon-clock-o"></i> 大事记
    </a>
</li>

亲自测试

实际上,到这一步,整个插件的功能已经完整了。

点击网站顶部导航菜单里的“大事记”,访问我们新制作的“网站大事记”页面。

然后点击“添加事件”按钮,填写如下内容:

  • 标题:网站上线
  • 内容:我们很高兴地宣布,经过数月的紧张开发与测试,我们的新网站终于正式上线了!感谢所有参与和支持我们项目的朋友们。

然后点击“提交”按钮。如果一切正常,你会看到提示消息:“添加成功!”

你也可以故意不填写某一项,如果一切正常,你会看到提示信息“请输入标题!”或“请输入内容!”

然后,再次点击网站顶部导航菜单里的“大事记”,回到大事记列表页面。现在你应该能看到新的卡片出现,标题为“网站上线”。

点击这个卡片的“查看详情”按钮,应该会看到具体的内容。

然后,再次点击网站顶部导航菜单里的“大事记”,回到大事记列表页面。点击标题为“网站上线”的卡片里的“编辑”按钮。填入以下新的内容:

  • 标题:新增评论系统
  • 内容:为了增强用户的互动体验,我们推出了一套全新的评论系统。现在,用户可以更方便地分享他们的观点和反馈。

然后点击“提交”按钮。如果一切正常,你会看到提示消息:“更新成功!”

你也可以故意不填写某一项,如果一切正常,你会看到提示信息“请输入标题!”或“请输入内容!”

然后,再次点击网站顶部导航菜单里的“大事记”,回到大事记列表页面。点击标题为“网站上线”的卡片里的“删除”按钮。确认删除后,对应的卡片应该会消失不见。

不过,我们可以更进一步

完成了上述功能测试后,你就已经验证了“网站大事记”插件的基本增删改查功能。恭喜!

界面美化

接下来,为了让用户体验更佳,我们可以对界面进行一些美化。一个美观的界面不仅能提升用户的使用体验,还能让您的网站看起来更加专业和吸引人。

my_events_log文件夹的view文件夹中创建css文件夹。进入css文件夹,然后创建文件timeline.css。内容如下(你可以直接复制):

/* 定义时间线的容器样式 */
.timeline {
    /* 设置相对定位,以便其内部元素可以使用绝对定位进行布局 */
    position: relative;
}

/* 使用伪元素:before在时间线左侧创建一条竖直的线条 */
.timeline::before {
    /* 确保内容为空,因为我们只需要背景色作为竖线 */
    content: '';
    /* 绝对定位,相对于.timeline容器 */
    position: absolute;
    /* 竖线宽度设置为6px */
    width: 6px;
    /* 背景颜色设置为灰色 */
    background-color: var(--gray);
    /* 从顶部到底部全高 */
    top: 0;
    bottom: 0;
    /* 竖线位于容器的最左侧 */
    left: 0%;
    /* 向左移动3px以精确对齐 */
    margin-left: -3px;
}

/* 定义时间线上每个事件的内容区域样式 */
.timeline .content {
    /* 左边距设置为16px,确保文本不会紧贴竖线 */
    margin-left: 16px;
}

/* 使用伪元素:after在每个事件内容区域前添加一个小圆点 */
.timeline .content::after {
    /* 确保内容为空,因为我们只需要圆形形状 */
    content: '';
    /* 绝对定位,相对于.content容器 */
    position: absolute;
    /* 圆形大小设置为25px x 25px */
    width: 25px;
    height: 25px;
    /* 将圆点放置在左边距之外 */
    left: -30px;
    /* 圆点背景颜色设置为白色 */
    background-color: var(--white);
    /* 圆点周围添加橙色边框 */
    border: 4px solid var(--orange);
    /* 圆点垂直居中对齐 */
    top: 15px;
    /* 圆角半径设置为50%,使其成为圆形 */
    border-radius: 50%;
    /* 提高堆叠顺序,确保圆点显示在竖线之上 */
    z-index: 1;
}

现在,回到大事记列表页面。你应该能看到每一个卡片的左侧会有橙色的圆点和灰色的竖线,来表示“时间线”的概念,使事件展示得更为美观和直观。

Ajax请求

为了进一步提升用户体验,我们可以使用Ajax技术来实现无刷新页面操作。

这样用户在添加或删除事件时,无需重新加载整个页面,从而提供更加流畅的交互体验。

打开文件my_events_log/view/htm/list.htm,然后作出修改。新的文件内容如下。

<?php include _include(APP_PATH.'view/htm/header.inc.htm');?>

<div class="container">
    <h2 class="page-header">网站大事记列表</h2>
    <!-- 事件列表 -->
    <section class="timeline">
        <?php /* 只有管理员可用的操作 */ if($uid && intval($gid) === 1): ?>
        <!-- 添加新事件按钮 -->
        <article class="content card">
            <div class="card-body">
                <!-- 这次使用了模态框来提升用户体验 -->
                <button type="button" class="btn btn-success" data-toggle="modal" data-target="#addEventModal">+ 添加事件</button>
            </div>
        </article>
        <!-- 添加新事件表单模态框 -->
        <div class="modal fade" id="addEventModal" tabindex="-1" role="dialog" aria-labelledby="addEventModalLabel" aria-hidden="true">
            <div class="modal-dialog" role="document">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title" id="addEventModalLabel">添加新事件</h5>
                        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                            <span aria-hidden="true">&times;</span>
                        </button>
                    </div>
                    <div class="modal-body">
                        <!-- 你会发现这个表单和add.htm的如出一辙 -->
                        <form id="add_event_form" method="post" action="<?= url('events_log-add') ?>">
                            <input type="hidden" name="submit" value="1">
                            <div class="form-group">
                                <label for="title">标题</label>
                                <input type="text" id="title" name="title" class="form-control" required>
                            </div>
                            <div class="form-group">
                                <label for="content">内容</label>
                                <textarea id="content" name="content" class="form-control" rows="6" required></textarea>
                            </div>
                            <button type="submit" class="btn btn-primary">提交</button>
                            <button type="reset" class="btn btn-secondary" data-dismiss="modal">取消</button>
                        </form>
                    </div>
                </div>
            </div>
        </div>
        <?php endif; ?>

        <?php /* 如果有事件的话 */ if($events): ?>
        <?php /* 遍历每个事件 */ foreach($events as $event): ?>
        <!-- 单个事件 -->
        <article class="content card" data-id="<?= $event['id'] ?>">
            <div class="card-body">
                <!-- 标题 -->
                <h3 class="card-title"><?= $event['title'] ?></h3>
                <!-- 时间 -->
                <p><?= date('Y-m-d H:i', $event['create_time']) ?></p>
                <!-- 查看详情按钮 -->
                <a href="<?= url('events_log-view', ['id' => $event['id']]) ?>" class="btn btn-link">查看详情</a>
            </div>
            <?php /* 只有管理员可用的操作 */ if($uid && intval($gid) === 1): ?>
            <div class="card-footer">
                <a href="<?= url('events_log-edit', ['id' => $event['id']]) ?>" class="btn btn-xs btn-warning">编辑</a>
                <button type="button" class="btn btn-xs btn-danger" onclick="processDelete(<?= $event['id'] ?>)">删除</button>
            </div>
            <?php endif; ?>
        </article>
        <?php endforeach; ?>
        <?php endif; ?>
    </section>

    <!-- 分页导航 -->
    <div class="text-center">
        <?= $pagination ?>
    </div>
</div>

<!-- 引入提前写好的时间线样式 -->
<link rel="stylesheet" href="./plugin/my_events_log/view/css/timeline.css">
<?php include _include(APP_PATH.'view/htm/footer.inc.htm');?>
<script>
    // 将表单固定成jQuery实例
    let add_event_form = $('#add_event_form');
    // 提交事件
    add_event_form.on('submit', function () {
        event.preventDefault(); 
        add_event_form.reset();
        var postdata = add_event_form.serialize();
        $.xpost(add_event_form.attr('action'), postdata, function (code, message) {
            if (code == 0) {
                window.location.reload();
            } else {
                $.alert(message);
            }
        });
    });

    // 删除事件
    function processDelete(id) {
        if (confirm('确定删除?')) {
            $.xpost(xn.url("events_log-delete"), "id=" + id, function (code, message) {
                if (code == 0) {
                    document.querySelector('[data-id="' + id + '"]').remove();
                } else {
                    $.alert(message);
                }
            });
        }
    }
</script>

现在,点击“添加事件”按钮后,会显示一个弹窗(模态框),填写好内容后,点击“提交”按钮,如果一切正常,页面将会刷新,并显示刚刚提交的内容。

恭喜你!

你已经完成了为Xiuno BBS开发一款功能更复杂一些的插件!你应该出门为你自己买一杯奶茶犒劳自己了。

我知道本文可能确实不那么容易理解,但还是感谢你能坚持看完。

最新回复 (2)
全部楼主
  • 叫兽
    13天前 2
    1
    楼主,你写得实在是太好了。我惟一能做的,就只有把这个帖子顶上去这件事了。 
  • 流口水的鱼
    8天前 3
    0
    不错的帖子!
返回