使用axum构建博客系统 - 文章管理

发布时间 2023-10-21 20:26:06作者: CrossPython

本章我们将实现博客的文章管理功能。

数据库结构

CREATE TABLE topics (
  id BIGSERIAL PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  category_id INT NOT NULL,
  summary VARCHAR(255) NOT NULL,
  markdown VARCHAR NOT NULL,
  html VARCHAR NOT NULL,
  hit INT NOT NULL DEFAULT 0,
  dateline TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  is_del BOOLEAN NOT NULL DEFAULT FALSE,
  FOREIGN KEY (category_id) REFERENCES categories (id)
);
字段说明
id 主键。唯一标识,自增。
title 文章标题
category_id 外键。分类ID
summary 摘要。纯文本
markdown 内容的markdown格式。
html 内容的HTML格式。
hit 文章浏览次数
dateline 文章添加时间
is_del 是否删除

提示:PostgreSQL中,没有限定长度的VARCHAR等同于TEXT,并且效率远高于其它数据库的TEXT,同时可以指定默认值。

数据库视图

CREATE VIEW v_topic_cat_list AS
  SELECT t.id, title, summary, hit, dateline,category_id,t.is_del,
         c.name AS category_name
    FROM
      topics AS t
      INNER JOIN categories AS c
          ON t.category_id=c.id
                   WHERE c.is_del = false
;

该视图主要用于列表显示文章,为了显示分类名称,关联了分类表。

数据模型

// src/model.rs
#[derive(PostgresMapper, Serialize)]
#[pg_mapper(table="v_topic_cat_list")]
pub struct TopicList {
    pub id:i64,
    pub title: String,
    pub category_id:i32,
    pub summary:String,
    pub hit:i32,
    pub dateline:time::SystemTime,
    pub is_del:bool,
    pub category_name:String,
}
impl TopicList {
    pub fn dateline(&self) ->String {
        let ts = self.dateline.clone().duration_since(time::UNIX_EPOCH).unwrap_or(time::Duration::from_secs(0)).as_secs() as i64;
        Local.timestamp(ts, 0).format("%Y/%m/%d %H:%M:%S").to_string()
    }
}

#[derive(PostgresMapper, Serialize)]
#[pg_mapper(table="topics")]
pub struct TopicID {
    pub id:i64,
}

#[derive(PostgresMapper, Serialize)]
#[pg_mapper(table="topics")]
pub struct TopicEditData {
    pub id:i64,
    pub title: String,
    pub category_id: i32,
    pub summary: String,
    pub markdown: String,
}
  • TopicList:对应数据库视图 v_topic_cat_list
  • TopicList::dateline():将dateline字段格式化为年/月/日 时:分:秒的字符串
  • TopicID:文章的ID
  • TopicEditData:用于修改的文章数据

依赖

为了处理文章发表的时间,引入新的依赖:

# Cargo.toml
[dependencies]
# ...
chrono = "0.4"

数据库操作

以下代码均位于 src/db/topic.rs 文件。

增加文章 create()

pub async fn create(client: &Client, frm: &form::CreateTopic) -> Result<TopicID> {
    let html = md2html(&frm.markdown);
    let dateline = time::SystemTime::now();
    super::insert(client, "INSERT INTO topics (title,category_id, summary, markdown, html, hit, dateline, is_del) VALUES ($1, $2, $3, $4, $5, 0, $6, false) RETURNING id", &[&frm.title, &frm.category_id, &frm.summary, &frm.markdown, &html,  &dateline ], "添加文章失败").await
}

form::CreateTopic的定义见下文的“表单类”部分。

本函数将表单提交的Markdown转换成HTML,然后分别保存到数据库中。

分页获取文章列表list()

pub async fn list(client: &Client, page: u32) -> Result<Paginate<Vec<TopicList>>> {
    let sql=format!("SELECT id,title,category_id,summary,hit,dateline,is_del,category_name FROM v_topic_cat_list WHERE is_del=false ORDER BY id DESC LIMIT {} OFFSET {}", DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE as u32 * page);
    let count_sql = "SELECT COUNT(*) FROM v_topic_cat_list WHERE is_del=false";
    super::pagination(client, &sql, count_sql, &[], page).await
}

注意,该函数并没有从原始的topics数据表中获取数据,而是从视图v_topic_cat_list中获取。

修改文章update()

pub async fn update(client: &Client, frm: &EditTopic, id: i64) -> Result<bool> {
    let html = md2html(&frm.markdown);
    let sql =
        "UPDATE topics SET title=$1,category_id=$2,summary=$3,markdown=$4,html=$5 WHERE id=$6";
    let n = super::execute(
        client,
        sql,
        &[
            &frm.title,
            &frm.category_id,
            &frm.summary,
            &frm.markdown,
            &html,
            &id,
        ],
    )
    .await?;
    Ok(n > 0)
}

查找用于修改的文章数据find2edit()

pub async fn find2edit(client: &Client, id: i64) -> Result<TopicEditData> {
    super::query_row(
        client,
        "SELECT id,title,category_id,summary,markdown FROM topics WHERE id=$1 LIMIT 1",
        &[&id],
    )
    .await
}

删除或还原文章del_or_restore()

pub async fn del_or_restore(client: &Client, id: i64, is_del: bool) -> Result<bool> {
    let n = super::del_or_restore(client, "topics", &id, is_del).await?;
    Ok(n > 0)
}

Markdown转HTML md2html()

fn md2html(markdown: &str) -> String {
    md::to_html(markdown)
}

该函数调用的md::to_html()位于md模块,该模块的说明见下文。

分页 Paginate

为方便模板的分页操作,对Paginate扩展了几个方法:

// src/db/paginate.rs

impl<T> Paginate<T> {
    // ...
    pub fn has_prev(&self) ->bool {
        self.page > 0
    }
    pub fn last_page(&self) -> i64 {
        self.total_pages-1
    }
    pub fn has_next(&self)->bool {
        (self.page as i64) <  self.last_page()
    }
    pub fn is_active(&self, page :&i64)->bool {
        (self.page as i64) == *page
    }
}
  • has_prev():是否有上一页
  • last_page():最后一页的页码
  • has_next():是否有下一页
  • is_active():判断指定的页码是否是当前激活页码

md模块

为了将Markdown转换为HTML,增加该模块:

// src/md.rs

use pulldown_cmark::{html, Options, Parser};

fn get_parser(md: &str) -> Parser {
    Parser::new_ext(md, Options::all())
}
pub fn to_html(md: &str) -> String {
    let mut out_html = String::new();
    html::push_html(&mut out_html, get_parser(md));
    out_html
}
  • get_parser() :初始化解析器
  • to_html():将Markdown转换为HTML

表单类

// src/form.rs

#[derive(Deserialize)]
pub struct CreateTopic {
    pub title: String,
    pub category_id: i32,
    pub summary: String,
    pub markdown: String,
}


pub type EditTopic = CreateTopic;
  • CreateTopic:创建文章的表单
  • EditTopic:修改文章的表单

注意,为展示更多的可能性,这里 EditTopic直接以别名的形式由CreateTopic定义。这种上一章的EditCategory是不同的。

字段说明表单元素
title 文章标题 单行文本框(input)
category_id 分类ID 下拉框(select)
summary 摘要 多行文本框(textarea)
markdown 表单输入的Markdown 多行文本框(textarea)

模板

为了节约篇幅,本章只挑几个特殊的模板进行说明。完整的模板请通过本章分支的对应目录查看。

分页模板 templates/pagination.html

<nav>
    <ul class="pagination">
        {% if list.has_prev() %}
        <li class="page-item">
            <a class="page-link" href="?page={{ page - 1 }}" aria-label="Previous">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>
        {% else %}
        <li class="page-item disabled">
            <a class="page-link"  aria-label="Previous">
                <span aria-hidden="true">&laquo;</span>
            </a>
        </li>
        {% endif %}
        {%for i in 0..list.total_pages%}
        <li class="page-item{% if list.is_active(i) %} active{%endif%}" aria-current="page">
            <a class="page-link" href="?page={{i}}">{{ i+1 }}</a>
        </li>
        {%endfor%}
        {% if list.has_next() %}
        <li class="page-item">
            <a class="page-link" href="?page={{page+1}}" aria-label="Next">
                <span aria-hidden="true">&raquo;</span>
            </a>
        </li>
        {% else %}
        <li class="page-item disabled">
            <a class="page-link"  aria-label="Next">
                <span aria-hidden="true">&raquo;</span>
            </a>
        </li>
        {% endif %}
    </ul>
</nav>

这里的 list需要Paginate<Vec<T>>类型。

文章列表模板 index.html

由于文章列表需要进行分页,所以在该模板的content块中,引入了分页模板:

{% extends "./../base.html" %}
{% block title%}所有文章{%endblock%}
{% block toolbar %} {% include "./toolbar.html" %} {%endblock%}
{% block msg %} {%include "../msg.index"%} {%endblock%}
{% block content %}
<table class="table table-striped table-hover">
    <thead>
        <tr>
            <th>ID</th>
            <th>标题</th>
            <th>分类</th>
            <th>摘要</th>
            <th>浏览次数</th>
            <th>时间</th>
            <th>操作</th>
        </tr>
    </thead>
    <tbody>
        {% for item in list.data%}
        <tr>
            <td>{{ item.id }}</td>
            <td>{{ item.title }}</td>
            <td>{{ item.category_name }}</td>
            <td>{{ item.summary }}</td>
            <td>{{ item.hit }}</td>
            <td class="dateline">{{ item.dateline() }}</td>
            <td>
                <a href="/admin/topic/edit/{{ item.id }}" class="btn btn-primary btn-sm">修改</a>
                <a href="/admin/topic/del/{{ item.id }}" class="btn btn-danger btn-sm" onclick="return confirm('确定删除「{{ item.title }}」');">删除</a>
            </td>
        </tr>
        {%endfor%}
    </tbody>
</table>
{% include "../../pagination.html" %}
{%endblock%}

视图类

// src/view/backend/topic.rs

#[derive(Template)]
#[template(path="backend/topic/add.html")]
pub struct Add {
    pub cats : Vec<Category>,
}
#[derive(Template)]
#[template(path="backend/topic/index.html")]
pub struct Index {
    pub msg:Option<String>,
    pub page: u32,
    pub list:Paginate<Vec<TopicList>>,
}

#[derive(Template)]
#[template(path="backend/topic/edit.html")]
pub struct Edit {
    pub cats : Vec<Category>,
    pub item: TopicEditData,
}
  • Add:添加文章的视图。由于需要分类列表,所以其中包含了 pub cats : Vec<Category>字段
  • Index:文章列表视图。 + msg:显示提示信息 + page:分页的页码 + list:带分页信息的文章列表
  • Edit:视图。除了分类列表之外,还包含了要修改的文章的数据item

handler

文章管理的handler定义在src/handler/backend/topic.rs,由于没有涉及新知识,请自行在源码仓库查看。

路由

文章管理的路由定义在src/handler/backend/mod.rs,由于没有涉及新知识,请自行在源码仓库查看。