Vapor 框架学习记录(8)内容管理系统

上一篇我们已经准备好了需要的各种表单字段,现在,我们将构建一个带有管理界面的内容管理系统。 我们将为管理页面创建一个独立的模块,它将与 Web 前端完全独立的。 CMS 将支持列表、详细信息、创建、更新和删除功能。 模型将持久保存到数据库中,我们将通过使用新的内置中间件来保护管理后台

admin 模块

我们想要的基本内容管理系统需要有最基础的增删改查功能,所以后面我们也是按这几个功能来分开实现。

在我们创建管理员模块之前,让我们稍微重构一下我们的代码。 首先,我们将从 Web index模板文件中移出 Svg 菜单图标扩展

/// FILE: Sources/App/Extensions/Svg+MenuIcon.swift
import SwiftSvg

extension Svg {
    static func menuIcon() -> Svg {
        Svg {
            Line(x1: 3, y1: 12, x2: 21, y2: 12)
            Line(x1: 3, y1: 6, x2: 21, y2: 6)
            Line(x1: 3, y1: 18, x2: 21, y2: 18)
        }
        .width(24)
        .height(24)
        .viewBox(minX: 0, minY: 0, width: 24, height: 24)
        .fill("none")
        .stroke("currentColor")
        .strokeWidth(2)
        .strokeLinecap("round")
        .strokeLinejoin("round")
    }
}

下一步,我们应该向index模版添加一个新的admin链接,因为在我们创建了管理模块之后,通过身份验证的用户,就能够从 Web 前端访问仪表板。


/// FILE: Sources/App/Modules/Web/Templates/Html/WebIndexTemplate.swift

import Vapor
import SwiftSvg
import SwiftSgml
import SwiftHtml

public struct WebIndexTemplate: TemplateRepresentable {
    
    public var context: WebIndexContext
    var body: Tag
    
    public init(_ context: WebIndexContext, @TagBuilder _ builder: () -> Tag) {
        self.context = context
        self.body = builder()
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        Html {
            Head {
                Meta()
                    .charset("utf-8")
                Meta()
                    .name(.viewport)
                    .content("width=device-width, initial-scale=1")
                Link(rel: .shortcutIcon)
                    .href("/image/favicon.ico")
                    .type("image/x-icon")
                Link(rel: .stylesheet)
                    .href("https://cdn.jsdelivr.net/gh/feathercms/feather-core@1.0.0-beta.44/feather.min.css")
                Link(rel: .stylesheet)
                    .href("/css/web.css")
                
                Title(context.title)
            }
            Body {
                Header {
                    Div {
                        A {
                            Img(src: "/img/logo.png", alt: "Logo")
                        }
                        .id("site-logo")
                        .href("/")
                        
                        Nav {
                            Input()
                                .type(.checkbox)
                                .id("primary-menu-button")
                                .name("menu-button")
                                .class("menu-button")
                            
                            Label {
                                Svg.menuIcon()
                            }.for("primary-menu-button")
                            
                            Div {
                                A("Home")
                                    .href("/")
                                    .class("selected", req.url.path == "/")
                                A("Blog")
                                    .href("/blog/")
                                    .class("selected", req.url.path == "/blog/")
                                A("About")
                                    .href("#")
                                    .onClick("javascript:about();")
                                if req.auth.has(AuthenticatedUser.self) {
                                    A("Admin")
                                        .href("/admin/")
                                    A("Sign Out")
                                        .href("/sign-out/")
                                } else {
                                    A("Sign In")
                                        .href("/sign-in/")
                                }
                            }
                            .class("menu-items")
                        }
                        .id("primary-menu")
                    }
                    .id("navigation")
                }
                
                Main {
                    body
                }
                
                Footer {
                    Section {
                        P {
                            Text("This site is powered by ")
                            A("Swift")
                                .href("https://swift.org")
                                .target(.blank)
                            Text(" & ")
                            A("Vapor")
                                .href("https://vapor.codes")
                                .target(.blank)
                            Text(".")
                        }
                        P("lqbk.space © 2020-2022")
                    }
                }
                
                Script()
                    .type(.javascript)
                    .src("/js/web.js")
                
            }
        }
        .lang("en-US")
    }
}

admin模块和web模块一样,是其他模块的主要布局框架。 它们提供基本布局模板,其他模块可以挂接到这些容器中。 例如,web 模块有一个index模板,用于 web 前端的所有页面,例如博客或登录界面。 同理,管理模块将为管理页面提供类似的index模板。

作为起点,我们需要一个context才能创建admin index模板

/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminIndexContext.swift

public struct AdminIndexContext {
    
    public let title: String
    public init(title: String) {
        self.title = title
    }
}

接着就是模版文件 AdminIndexTemplate,确保为context和Template所在的目录结构

/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminIndexTemplate.swift

import Vapor
import SwiftHtml
import SwiftSvg

public struct AdminIndexTemplate: TemplateRepresentable {
    
    public var context: AdminIndexContext
    
    var body: Tag
    
    public init(_ context: AdminIndexContext, @TagBuilder _ builder: () -> Tag) {
        self.context = context
        self.body = builder()
    }
    
    @TagBuilder
    public func render(_ req: Vapor.Request) -> SwiftSgml.Tag {
        
        Html {
            Head {
                Meta()
                    .charset("utf-8")
                Meta()
                    .name(.viewport)
                    .content("width=device-width, initial-scale=1")
                Meta()
                    .name("robots")
                    .content("noindex")
                Link(rel: .shortcutIcon)
                    .href("/images/favicon.ico")
                    .type("image/x-icon")
                Link(rel: .stylesheet)
                    .href("https://cdn.jsdelivr.net/gh/feathercms/feather-core@1.0.0-beta.44/feather.min.css")
                Link(rel: .stylesheet)
                    .href("/css/admin.css")
                
                Title(context.title)
            }
            
            Body {
                
                Div {
                    A {
                        Img(src: "/img/logo.png", alt: "Logo")
                            .title("Logo")
                            .style("width: 300px")
                    }
                    .href("/")
                    
                    
                    Nav {
                        
                        Input()
                            .type(.checkbox)
                            .id("secondary-menu-button")
                            .name("menu-button")
                            .class("menu-button")
                        
                        Label{
                            Svg.menuIcon()
                        }
                        .for("secondary-menu-button")
                                
                                
                        Div {
                            A("Sign out")
                                .href("/sign-out/")
                            
                        }.class("menu-items")
                    }
                    .id("secondary-menu")
                }
                .id("navigation")
                
                Main {
                    body
                }
                
                Script()
                    .type(.javascript)
                    .src("/js/admin.js")
            }
            
        }
        .lang("en-US")
        
    }
    
    
}

主管理模板与 Web 索引略有不同。 第一个变化是新的meta标记是robots,因为我们不想管理页面被索引。 不像其他可以被机器人访问的页面,我们将用中间件保护它们,所以它不会公开可用,但我们还是添加robots meta

我们也在此处链接 Feather CSS 框架,因为它是一个包含非常常见内容的通用共享 CSS 文件。 我们还包含了一个新的 admin.css 样式表,它将包含管理员特定的样式。 菜单结构与 web 不同,我们在最后添加了一个新的 admin.js 文件。 请在public文件夹中创建这些新文件。

我们还需要内容管理系统的主页之类的东西。 我们将把它称为仪表板,和往常一样,首先我们需要为它创建 context

/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminDashboardContext.swift


struct AdminDashboardContext {
    let icon: String
    let title: String
    let message: String
}

让我们在模板文件夹中的索引文件旁边添加一个新的 AdminDashboardTemplate

/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminDashboardTemplate.swift


import Vapor
import SwiftHtml

struct AdminDashboardTemplate: TemplateRepresentable {

    var context: AdminDashboardContext
    
    init(context: AdminDashboardContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Vapor.Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title)) {
            Div {
                Section {
                    P(context.icon)
                    H1(context.title)
                    P(context.message)
                }
            }
            .id("dashboard")
            .class("container")
        }
        .render(req)
    }
    
}

现在新建一个AdminFrontendController 来为CMS渲染仪表盘页面。


/// FILE: Sources/App/Modules/Admin/Controllers/AdminFrontendController.swift

import Vapor

struct AdminFrontendController {
    
    func dashboardView(req: Request) throws -> Response {
        let user = try req.auth.require(AuthenticatedUser.self)
        let template = AdminDashboardTemplate(context: .init(icon: "👋", title: "Dashboard", message: "Hello \(user.email),welcome to the CMS."))
        return req.templates.renderHtml(template)
    }
    
}

通过创建一个新的 AdminRouter 对象连接这个管理控制器。 如果你还记得我们已经为所有路由启用了会话身份验证器中间件,那么如果存在有效会话,用户将自动进行身份验证。

我们可以在 Authenticatable 类型上使用 redirectMiddleware 函数,它将返回一个中间件,该中间件将每个未经身份验证的流量重定向到指定路径。

/// FILE: Sources/App/Modules/Admin/AdminRouter.swift


import Vapor

struct AdminRouter: RouteCollection {

    
    let controller = AdminFrontendController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
    }
    
    
}

正如之前提到的,admin视图将只对经过身份验证的用户可用,这样我们就可以保护我们的admin路由免受未经授权的公共访问

该中间件检查 req.auth 存储中是否存在现有的 AuthenticatedUser 对象,如果存在,则 Vapor 将像往常一样调用请求处理程序,否则它将执行 HTTP 重定向到提供的路径。 你还可以通过在请求处理程序中调用 try req.auth.require(AuthenticatedUser.self) 函数来保护端点,但使用中间件更优雅一些

为了完成这个模块,我们应该在 Admin 文件夹中创建一个新的 AdminModule 结构,并使用该模块启动管理路由器实例

/// FILE: Sources/App/Modules/Admin/AdminModule.swift

import Vapor

struct AdminModule: ModuleInterface {
    
    let router = AdminRouter()
    
    func boot(_ app: Application) throws {
        try router.boot(routes: app.routes)
    }
}

现在我们回到config文件,注册这个新模块来使用。

/// FILE: Sources/App/configure.swift

public func configure(_ app: Application) throws {
    //...

    /// setup modules
    let modules: [ModuleInterface] = [
        WebModule(),
        BlogModule(),
        UserModule(),
        AdminModule()
    ]

    //...
}

运行应用程序,使用默认用户帐户登录,然后单击管理菜单。 现在我们有了 CMS 的基本框架。 这些步骤现在应该已经很熟悉了,最后我们准备好继续构建一些真正的内容管理界面。

列表

我们将为博客模块创建一个新的管理列表组件,这样我们就可以为所有现有的博客文章创建一个很好的列表。 像往常一样,我们从帖子的context开始

/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostListContext.swift

struct AdminBlogPostListContext {
    let title: String
    let list: [Blog.Post.List]
}


接着是AdminBlogPostListTemplate

/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminBlogPostListTemplate.swift

import Vapor
import SwiftHtml

struct AdminBlogPostListTemplate: TemplateRepresentable {

    var context: AdminBlogPostListContext
    
    init(context: AdminBlogPostListContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Vapor.Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title)) {
            
            Div {
                Section {
                    H1(context.title)
                }
                .class("lead")
                
                Table {
                    Thead {
                        Tr {
                            Th("Image")
                            Th("Title")
                            Th("Preview")
                        }
                    }
                    Tbody {
                        for item in context.list {
                            Tr {
                                Td {
                                    Img(src: item.image, alt: item.title, workDicIfNeed: "assets")
                                }
                                Td(item.title)
                                Td {
                                    A("Preview")
                                        .href("/" + item.slug + "/")
                                }
                            }
                        }
                    }
                }
            }
            .id("list")
        }
        .render(req)
    }
    
}


在这个模板中,我们简单地使用context列表数组来呈现基于博客文章列表对象的表格。 我们可以简单地显示帖子的图像和标题以及转到帖子页面的预览 URL。 我们可以使用内置的 SwiftHtml 标签来呈现我们的 HTML 表格。

接下来,在我们继续使用控制器之前,我们应该清理一些自创建 BlogPostModel 类型以来未触及的代码。由于我们不想直接使用数据库模型,因为它可能包含敏感数据,所以我们需要一个映射函数的转换model的地方。 创建一个 BlogPostApiController 并将映射列表函数放在那里是个好主意,它可以将博客文章模型转换为公共 Blog.Post.List

/// FILE: Sources/App/Modules/Blog/Controllers/BlogPostApiController.swift

import Vapor

struct BlogPostApiController {
    
    func mapList(_ model: BlogPostModel) -> Blog.Post.List {
        .init(id: model.id!,
              title: model.title,
              slug: model.slug,
              image: model.imageKey,
              excerpt: model.excerpt,
              date: model.date)
    }
}

现在我们可以创建一个新的控制器来负责呈现帖子相关的管理视图。 让我们创建一个带有 listView 函数的新 AdminBlogPostController 并查询所有可用的实体并使用新的 API controller映射它们,最后我们渲染模板

/// FILE: Sources/App/Modules/Admin/Controllers/AdminBlogPostController.swift

import Vapor

struct AdminBlogPostController {
    
    func listView(_ req: Request) async throws -> Response {
        let posts = try await BlogPostModel.query(on: req.db).all()
        let api = BlogPostApiController()
        let list = posts.map { api.mapList($0) }
        let template = AdminBlogPostListTemplate(context: .init(title: "Posts", list: list))
        return req.templates.renderHtml(template)
    }
}

BlogFrontendController内部,我们还可以用新的 API 方法替换旧的地图逻辑,这样我们的代码库中就不会有那么多重复的代码

import Vapor
import Fluent

struct BlogFrontendController {
        
    func blogView(req: Request) async throws -> Response {
        
        let posts = try await BlogPostModel
            .query(on: req.db)
            .sort(\.$date, .descending)
            .all()
        
        let api = BlogPostApiController()
        let list = posts.map{ api.mapList($0)}
        
        let ctx = BlogPostsContext(icon: "🔥 ", title: "Blog", message: "Hot news and stories about everything.", posts: list)
        
        return req.templates.renderHtml(BlogPostsTemplate(ctx))
    }

    //...  

}

在路由器中,我们需要再次使用 redirectMiddleware 方法,因为我们不想让访客访问博客文章列表管理页面。 我们还可以在路由上使用 grouped 方法,通过路径组件数组对路由进行分组。

/// FILE: Sources/App/Modules/Admin/AdminRouter.swift

import Vapor

struct AdminRouter: RouteCollection {

    
    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/"))
            .grouped("admin", "blog")
            .get("posts", use: blogPostController.listView)
    }
    
    
}

现在,在admin dashboard template中,我们将添加一个新链接来访问博客文章

/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminDashboardTemplate.swift


import Vapor
import SwiftHtml

struct AdminDashboardTemplate: TemplateRepresentable {

 
    var context: AdminDashboardContext
    
    init(context: AdminDashboardContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Vapor.Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title)) {
            Div {
                Section {
                    P(context.icon)
                    H1(context.title)
                    P(context.message)
                }
                
                Nav {
                    
                    H2("Blog")
                    Ul {
                        Li {
                            A("Posts")
                                .href("/admin/blog/posts/")
                        }
                    }
                }
            }
            .id("dashboard")
            .class("container")
        }
        .render(req)
    }
    
}


我们将插入一些额外的 CSS 来使我们的图像在表格视图中变小一点。 将以下代码片段粘贴到 admin.css 文件中。

/* FILE: Public/css/admin.css */

tr {
    grid-template-columns: 4rem 1fr 4rem;
    column-gap: 1rem;
}

td img {
    display: block;
}

th {
    text-align: left;
}

这就是你可以将新组件集成到管理界面的方式。 运行应用程序并检查新创建的列表。 它应该会向您显示所有可用的博客文章

详情

帖子的详细视图会与之前的流程非常相似,不过我们在构建此功能时还将学习一些新东西。 首先,我们从详情context开始

/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostDetailContext.swift

struct AdminBlogPostDetailContext {
    let title: String
    let detail: Blog.Post.Detail
}

我们将使用相应模板中的 **Dl、Dt、Dd **元素来构建我们的详细视图

/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminBlogPostDetailTemplate.swift

import Vapor
import SwiftHtml

struct AdminBlogPostDetailTemplate: TemplateRepresentable {
    
    var context: AdminBlogPostDetailContext
    
    var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .long
        formatter.timeStyle = .short
        return formatter
    }()
    
    init(context: AdminBlogPostDetailContext) {
        self.context = context
    }
    
    func render(_ req: Vapor.Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title)) {
            Div {
                Section {
                    H1(context.title)
                }
                .class("lead")
                
                Dl {
                    Dt("Image")
                    Dd {
                        Img(src: context.detail.image, alt: context.detail.title, workDicIfNeed: "assets")
                    }
                    
                    Dt("Title")
                    Dd(context.detail.title)
                    
                    Dt("Excerpt")
                    Dd(context.detail.excerpt)
                    
                    Dt("Date")
                    Dd(dateFormatter.string(from: context.detail.date))
                    
                    Dt("Content")
                    Dd(context.detail.content)
                }
            }
            .id("detail")
            .class("container")
        }
        .render(req)
        
    }
    
    
}

我们还应该使用新的 mapDetail 函数扩展 BlogPostApiController,这将使我们能够将获取的模型映射到详细信息对象中。 稍后我们将使用这些类型的 API 控制器通过 API 层返回 JSON 响应

/// FILE: Sources/App/Modules/Blog/Controllers/BlogPostApiController.swift

import Vapor

struct BlogPostApiController {
    
    func mapList(_ model: BlogPostModel) -> Blog.Post.List {
        .init(id: model.id!,
              title: model.title,
              slug: model.slug,
              image: model.imageKey,
              excerpt: model.excerpt,
              date: model.date)
    }
    
    func mapDetail(_ model: BlogPostModel) -> Blog.Post.Detail {
        .init(id: model.id!,
              title: model.title,
              slug: model.slug,
              image: model.imageKey,
              excerpt: model.excerpt,
              date: model.date,
              category: .init(id: model.category.id!,
                              title: model.category.title),
              content: model.content)
    }
}

AdminBlogPostController 中,我们必须以某种方式找到当前的博客文章模型。 由于我们在注册路由处理程序时将在路径中使用 postId 参数,因此我们可以通过调用 req.parameters.get() 方法以字符串形式返回 id 值。

将字符串转换为 UUID 对象并使用它来查询我们的数据库模型真的很容易。

detailView 方法现在非常简单,我们只需找到模型,将模型转换为适当的详情对象并使用context渲染模板。

/// FILE: Sources/App/Modules/Admin/Controllers/AdminBlogPostController.swift

import Vapor
import Fluent

struct AdminBlogPostController {
    
    func find(_ req: Request) async throws -> BlogPostModel {
        guard let id = req.parameters.get("postId"),
              let uuid = UUID(uuidString: id),
              let post = try await BlogPostModel.query(on: req.db).filter(\.$id == uuid).with(\.$category).first() else {
            throw Abort(.notFound)
        }
        return post
    }
    
    func listView(_ req: Request) async throws -> Response {
        let posts = try await BlogPostModel.query(on: req.db).all()
        let api = BlogPostApiController()
        let list = posts.map { api.mapList($0) }
        let template = AdminBlogPostListTemplate(context: .init(title: "Posts", list: list))
        return req.templates.renderHtml(template)
    }
    
    func detailView(_ req: Request) async throws -> Response {
        let post = try await find(req)
        let detail = BlogPostApiController().mapDetail(post)
        let template = AdminBlogPostDetailTemplate(context: .init(title: "Post details", detail: detail))
        return req.templates.renderHtml(template)
    }
}

我们可以在博客前端控制器中再次重构一件事。 在 postView 函数中获取模型后,我们可以使用相同的 API 对象来映射博客文章的详细信息

import Vapor
import Fluent

struct BlogFrontendController {
        
    func blogView(req: Request) async throws -> Response {
        
        let posts = try await BlogPostModel
            .query(on: req.db)
            .sort(\.$date, .descending)
            .all()
        
        let api = BlogPostApiController()
        let list = posts.map{ api.mapList($0)}
        
        let ctx = BlogPostsContext(icon: "🔥 ", title: "Blog", message: "Hot news and stories about everything.", posts: list)
        
        return req.templates.renderHtml(BlogPostsTemplate(ctx))
    }
    
    func postView(req: Request) async throws -> Response {
        let slug = req.url.path.trimmingCharacters(in: .init(charactersIn: "/"))
        guard let post = try await BlogPostModel
                .query(on: req.db)
                .filter(\.$slug == slug)
                .with(\.$category)
                .first() else {
            return req.redirect(to: "/")
        }
        
        let api = BlogPostApiController()
        let ctx = BlogPostContext(post: api.mapDetail(post))
        return req.templates.renderHtml(BlogPostTemplate(ctx))
    }
    
    
}

现在是时候注册我们的路由处理程序了。 我们可以将 posts 端点存储在一个变量中,这样以后我们就可以重用它,而不必重新对所有内容进行分组

当注册一个路由参数时,你应该在它前面加上一个“:”,这样 Vapor 就会知道它不是一个静态路径组件,而是一个动态路由参数。 你可以稍后通过引用其名称来查询此路由参数

/// FILE: Sources/App/Modules/Admin/AdminRouter.swift


import Vapor

struct AdminRouter: RouteCollection {

    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        let posts =  routes.grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin","blog","posts")
        
        posts.get(use: blogPostController.listView)
        posts.get(":postId", use: blogPostController.detailView)
    }
   
}

最后我们返回管理列表模版,向标题字段添加一个超链接,这样当用户单击它时,它将打开帖子详细信息页面

/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminBlogPostListTemplate.swift

import Vapor
import SwiftHtml

struct AdminBlogPostListTemplate: TemplateRepresentable {

    var context: AdminBlogPostListContext
    
    init(context: AdminBlogPostListContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Vapor.Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title)) {
            
            Div {
                Section {
                    H1(context.title)
                }
                .class("lead")
                
                Table {
                    Thead {
                        Tr {
                            Th("Image")
                            Th("Title")
                            Th("Preview")
                        }
                    }
                    Tbody {
                        for item in context.list {
                            Tr {
                                Td {
                                    Img(src: item.image, alt: item.title, workDicIfNeed: "assets")
                                }
                                Td {
                                    A(item.title)
                                        .href("/admin/blog/posts/" + item.id.uuidString + "/")
                                }
                                Td {
                                    A("Preview")
                                        .href("/" + item.slug + "/")
                                }
                            }
                        }
                    }
                }
            }
            .id("list")
        }
        .render(req)
    }
    
}

这就是我们呈现帖子详细信息的方式,现在如果你构建并运行应用程序,你应该能够导航到详细信息页面并查看有关博客帖子的更多信息

新建内容

下一步是创建新博客文章的功能。 为此,我们将使用我们的抽象表单组件和表单字段构建一个编辑表单

BlogPostEditForm 是一个类对象 ,init 方法使用 BlogPostModel 实例,我们将其存储为unowned pointers, 我们可以通过 model.$id.value 属性包装器检查 Fluent 模型是否已经持久化,因此我们通过这个来设置正确的url

因为这次我们使用引用类型,所以我们必须小心使用强引用,所以这就是为什么我们将本地引用的对象作为block的unowned pointers传递。 这有点不方便,但我们稍后也会修复它

/// FILE: Sources/App/Modules/Amdin/Forms/BlogPostEditForm.swift


import Vapor

final class BlogPostEditForm: AbstractForm {
    
    unowned var model: BlogPostModel
    
    var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .long
        formatter.timeStyle = .short
        return formatter
    }()
    
    
    public init(model: BlogPostModel) {
        var url = "/admin/blog/posts/"
        if let id = model.$id.value {
            url = url + id.uuidString + "/update/"
        } else {
            url = url + "create/"
        }
        
        self.model = model
        super.init(action: .init(method: .post, url: url, enctype: .multipart))
        self.fields = createFields()
    }
    
    @FormComponentBuilder
    func createFields() -> [FormComponent] {
        ImageField("image", path: "blog/post")
            .read { [unowned self] in
                $1.output.context.previewUrl = model.imageKey
                ($1 as! ImageField).imageKey = model.imageKey
            }
            .write { [unowned self] in
                model.imageKey = ($1 as! ImageField).imageKey ?? ""
            }
        
        InputField("slug")
            .config {
                $0.output.context.label.required = true
            }
            .validators {
                FormFieldValidator.required($1)
            }
            .read { [unowned self] in
                $1.output.context.value = model.slug
            }
            .write { [unowned self] in
                model.slug = $1.input
            }
        
        InputField("title")
            .config {
                $0.output.context.label.required = true
            }
            .validators {
                FormFieldValidator.required($1)
            }
            .read { [unowned self] in
                $1.output.context.value = model.title
            }
            .write { [unowned self] in
                model.title = $1.input
            }
        
        InputField("date")
            .config {
                $0.output.context.label.required = true
                $0.output.context.value = dateFormatter.string(from: Date())
            }
            .validators {
                FormFieldValidator.required($1)
            }
            .read { [unowned self] in $1.output.context.value = dateFormatter.string(from: model.date) }
            .write { [unowned self] in
                model.date = dateFormatter.date(from: $1.input) ?? Date()
                
            }
        
        TextareaField("excerpt")
            .read { [unowned self] in
                $1.output.context.value = model.excerpt
            }
            .write { [unowned self] in
                model.excerpt = $1.input
            }
        
        TextareaField("content")
            .read { [unowned self] in $1.output.context.value = model.content }
            .write { [unowned self] in model.content = $1.input }
        
        SelectField("category")
            .load { req, field in
                let categories = try await BlogCategoryModel.query(on: req.db).all()
                field.output.context.options = categories.map { OptionContext(key: $0.id!.uuidString, label: $0.title) }
            }
            .read { [unowned self] req, field in
                field.output.context.value = model.$category.id.uuidString
            }
            .write { [unowned self] req, field in
                if let uuid = UUID(uuidString: field.input), let category = try await BlogCategoryModel.find(uuid, on: req.db) {
                    model.$category.id = category.id!
                }
            }
    }
}

select category 字段比较特殊,在 load 方法中我们从数据库中获取可用的类别,并根据结果设置选项值。 写入函数会将选定的类别 ID 字符串转换为 UUID 类型,我们检查是否存在具有该标识符的现有类别

下一步是为我们的编辑表单创建一个模板文件。 我们将为创建和更新操作重用此编辑表单。 让我们为视图创建一个 AdminBlogPostEditContext

/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostEditContext.swift

struct AdminBlogPostEditContext {
    let title: String
    let form: TemplateRepresentable
}

BlogPostAdminEditTemplate 将非常简单,我们只需按BlogPostAdminEditContext的模版呈现编辑表单。

/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminBlogPostEditTemplate.swift

import Vapor
import SwiftHtml

struct AdminBlogPostEditTemplate: TemplateRepresentable {

    var context: AdminBlogPostEditContext
    
    init(_ context: AdminBlogPostEditContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Vapor.Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title)) {
            Div {
                Section {
                    H1(context.title)
                }
                .class("lead")
                
                context.form.render(req)
            }
            .id("edit")
            .class("container")
        }
        .render(req)
    }
    
}

回到AdminBlogPostController,我们能够使用BlogPostEditForm来创建新的博客文章

createView 中,我们只初始化一个空模型和一个使用该模型的表单。 我们只是调用load函数,以便表单可以加载categorys,这就是我们准备呈现的内容

createAction 方法会有点复杂,首先我们需要一个新模型和一个表单, 之后我们调用load方法,然后我们处理输入字段。 我们还需要验证输入,如果出现问题,我们可以呈现包含错误的编辑表单。 否则我们继续工作流并调用 write 方法,这将确保我们的模型填充了经过验证的输入

最后我们调用 model.create(on:) 方法,这会将实体保存到数据库中,我们还在表单上调用save函数,因此如果有额外的保存操作也会执行。 作为最后一步,我们将用户重定向到详细信息页面

/// FILE: Sources/App/Modules/Blog/Controllers/AdminBlogPostController.swift

import Vapor
import Fluent

struct AdminBlogPostController {

     //...    

    private func renderEditForm(_ req: Request, _ title: String, _ form: BlogPostEditForm) -> Response {
        let template = AdminBlogPostEditTemplate(.init(title: title, form:
        form.render(req: req)))
        return req.templates.renderHtml(template)
    }
    
    
    func createView(_ req: Request) async throws -> Response {
        let model = BlogPostModel()
        let form = BlogPostEditForm(model: model)
        try await form.load(req: req)
        return renderEditForm(req, "Create post", form)
    }
    
    func createAction(_ req: Request) async throws -> Response {
        let model = BlogPostModel()
        let form = BlogPostEditForm(model: model)
        try await form.load(req: req)
        try await form.process(req: req)
        
        let isValid = try await form.validate(req: req)
        
        guard isValid else {
            return renderEditForm(req, "Create post", form)
        }
        
        try await form.write(req: req)
        try await model.create(on: req.db)
        try await form.save(req: req)
        return req.redirect(to: "/admin/blog/posts/\(model.id!.uuidString)/")
    }
}

当然,我们必须注册两个新的create路由才能使控制器生效

/// FILE: Sources/App/Modules/Admin/AdminRouter.swift

import Vapor

struct AdminRouter: RouteCollection {

    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        let posts =  routes.grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin","blog","posts")
        
        posts.get(use: blogPostController.listView)
        posts.get(":postId", use: blogPostController.detailView)
        
        posts.get("create", use: blogPostController.createView)
        posts.post("create", use: blogPostController.createAction)
    }
    
    
}

现在你可以通过输入 /admin/blog/posts/create/ URL 来尝试我们刚刚创建的内容

更新内容

在前面,我们预留了更新内容的url分支,现在我们可以通过向AdminBlogPostController添加一些非常简单的小改动来复用 BlogPostEditForm 来支持这两个功能


/// FILE: Sources/App/Modules/Blog/Controllers/AdminBlogPostController.swift

import Vapor
import Fluent

struct AdminBlogPostController {
    
    //...
    
    func updateView(_ req: Request) async throws -> Response {
        let model = try await find(req)
        let form = BlogPostEditForm(model: model)
        try await form.load(req: req)
        try await form.read(req: req)
        return renderEditForm(req, "Update post", form)
    }
    
    func updateAction(_ req: Request) async throws -> Response {
        let model = try await find(req)
        let form = BlogPostEditForm(model: model)
        try await form.load(req: req)
        try await form.process(req: req)
        let isValid = try await form.validate(req: req)
        guard isValid else {
            return renderEditForm(req, "Update post", form)
        }
        try await form.write(req: req)
        try await model.update(on: req.db)
        try await form.save(req: req)
        return req.redirect(to: "/admin/blog/posts/\(model.id!.uuidString)/update/")
    }
}

我们将使用 URL 参数来查找帖子,幸运的是我们之前实现了查找功能。 查找模型到后,我们加载表单并且把模型的数值展示在表单中

updateAction中间流程会跟之前的createAction流程很像,最大的区别是在写入操作完成后调用Model的update方法去更新对应的数据库数据

最后还是为这两个方法注册在路由中。

/// FILE: Sources/App/Modules/Admin/AdminRouter.swift

import Vapor

struct AdminRouter: RouteCollection {

    
    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        let posts =  routes.grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin","blog","posts")
        
        posts.get(use: blogPostController.listView)
        
        let postId = posts.grouped(":postId")
        
        postId.get(use: blogPostController.detailView)
        
        posts.get("create", use: blogPostController.createView)
        posts.post("create", use: blogPostController.createAction)
        
        postId.get("update", use: blogPostController.updateView)
        postId.post("update", use: blogPostController.updateAction)
    }
    
    
}


我们可以通过** :postId** 参数对帖子进行分组,并在注册详细信息和更新处理程序时将其用作基本路由。 现在就随意尝试这个新的编辑功能

删除内容

我们将在本篇中实现的最后一件功能是基本的删除功能。 在我们实际从数据库中删除记录之前,我们将使用一个带有删除表单的简单模板来显示确认界面

AdminBlogPostDeleteContext 将具有名称和类型属性,这样我们就可以告诉用户有关实体的更多信息。

/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostDeleteContext.swift

struct AdminBlogPostDeleteContext {
    let title: String
    let name: String
    let type: String
}


基于AdminBlogPostDeleteContext,我们可以通过配置一个带有删除 URL 发布操作的简单表单来呈现我们的模板。 它只会包含一个提交按钮和一个取消删除操作的链接

/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostDeleteTemplate.swift


import Vapor
import SwiftHtml

struct AdminBlogPostDeleteTemplate: TemplateRepresentable {

    var context: AdminBlogPostDeleteContext
    
    init(context: AdminBlogPostDeleteContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Vapor.Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title)) {
            Div {
                Span("🗑 ")
                    .class("icon")
                H1(context.title)
                P("You are about to permanently delete the<br>`\(context.name)`\(context.type).")
                
                Form {
                    Input()
                        .type(.submit)
                        .class(["button", "destructive"])
                        .style("display: inline")
                        .value("Delete")
                    
                    A("Cancel")
                        .href("/admin/blog/posts/")
                        .class(["button", "cancel"])

                }
                .method(.post)
                .id("delete-form")
            }
            .class(["lead", "container", "center"])
        }
        .render(req)
    }

}

接着回到AdminBlogPostController, 我们添加上删除页面的展示方法和删除事件的处理。

/// FILE: Sources/App/Modules/Admin/Controllers/AdminBlogPostController.swift

import Vapor
import Fluent

struct AdminBlogPostController {
    //...  
    
    func deleteView(_ req: Request) async throws -> Response {
        let model = try await find(req)
        
        let template = AdminBlogPostDeleteTemplate(context: .init(title: "Delete post",name: model.title, type: "post"))
        return req.templates.renderHtml(template)
    }
    
    func deleteAction(_ req: Request) async throws -> Response {
        let model = try await find(req)
        try await req.fs.delete(key: model.imageKey)
        try await model.delete(on: req.db)
        return req.redirect(to: "/admin/blog/posts/")
    }

}


最后还是需要把新加的两个方法注册在路由

/// FILE: Sources/App/Modules/Admin/AdminRouter.swift

import Vapor

struct AdminRouter: RouteCollection {

    
    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        let posts =  routes.grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin","blog","posts")
        
        posts.get(use: blogPostController.listView)
        
        let postId = posts.grouped(":postId")
        
        postId.get(use: blogPostController.detailView)
        
        posts.get("create", use: blogPostController.createView)
        posts.post("create", use: blogPostController.createAction)
        
        postId.get("update", use: blogPostController.updateView)
        postId.post("update", use: blogPostController.updateAction)
        
        postId.get("delete", use: blogPostController.deleteView)
        postId.post("delete", use: blogPostController.deleteAction)
    }
    
    
}

就是这样,如果你访问详细信息页面并将delete到路由的末尾,你就会看到一个能够删除博客文章的确认页面

总结

本篇是关于使用 Vapor 构建一个支持基于 Web 的 CRUD 的内容管理系统。 如你所见,管理模块围绕这些功能提供了一个很好的框架。 我们还学习了如何为create和update endpoints创建可重用的表单组件和字段。 最后,我们学习了如何从持久存储中删除记录

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339

推荐阅读更多精彩内容