Vapor 框架学习记录(9)构建通用管理界面

本篇是关于将我们的上篇实现的基本 CMS模块转变为通用解决方案。 通过利用 Swift 协议的方式,我们将能够抽象出几个可用于通过管理界面管理数据库模型的基本控制器。 这种方法允许我们轻松定义新的查询列表、创建、更新和删除的控制器。

通用表格模板系统

首先,我们需要一些新的基础模版。将 Templates 文件夹移出 Form 目录并创建一个新的 LinkContext 结构体

/// FILE: Sources/App/Framework/Templates/Context/LinkContext.swift

import Vapor

public struct LinkContext {
    public let label: String
    public let path: String
    public let absolute: Bool
    public let isBlank: Bool
    public let dropLast: Int
    
    public init(label: String,
                path: String = "",
                absolute: Bool = false,
                isBlank: Bool = false,
                dropLast: Int = 0) {
        self.label = label
        self.path = path
        self.absolute = absolute
        self.isBlank = isBlank
        self.dropLast = dropLast
    }
    
    public func url(_ req: Request, _ infix: [PathComponent] = []) -> String {
        if absolute {
            return path
        }
        return "/" + (req.url.path.pathComponents.dropLast(dropLast) + (infix + path.pathComponents)).string
    }
    
}
 

LinkContext将帮助我们处理导航链接。 默认情况下,path是相对路径值,但如果将 absolute 属性设置为 true,则可以在此变量中存储绝对路径的 URLdropLast 变量指示在我们将路径变量附加到 URL 之前需要删除多少个path components

通过引入infix 参数,我们可以轻松地在简化的基本 URL 和路径后缀之间插入其他PathComponent

我们还需要一个 LinkTemplate 来呈现LinkContext

/// FILE: Sources/App/Framework/Templates/Html/LinkTemplate.swift


import Vapor
import SwiftHtml

public struct LinkTemplate: TemplateRepresentable {
    
    var context: LinkContext
    var body: Tag
    var pathInfix: String?
    
    public init(_ context: LinkContext, pathInfix: String? = nil, _ builder: ((String) -> Tag)? = nil) {
        self.context = context
        self.pathInfix = pathInfix
        self.body = builder?(context.label) ?? Text(context.label)
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        A { body }
            .href(context.url(req, pathInfix?.pathComponents ?? []))
            .target(.blank, context.isBlank)
    }
    
}

下面就是如何构建链接以及如何在实践中使用infix参数。

/// Dropping path components
/// current URL is /admin/blog/posts/[uuid]/update/
let link = LinkContext(label: "Create", path: "create", dropLast: 2) 
/// drop last 2 segments, remains: /admin/blog/posts/
/// final URL will be: /admin/blog/posts/create/

/// Using path infix and custom Tag 
struct Row {
  let id: String
  let image: String 
}
let row = Row(id: "1", image: "http://localhost/example.jpg") 
let link = LinkContext(label: "Update", path: "update")
// current URL is /admin/blog/posts/
let template = LinkTemplate(link, pathInfix: row.id) { label in
  Img(src: row.image, alt: label) 
}
// template.render(req) -> A { Img(...) }.href(url) 
// final URL will be: /admin/blog/posts/1/update/

这样我们就可以通过删除最后两个path components并将新路径附加到末尾来从当前 URL 生成具有给定路径后缀的新 URL。 在第二个示例中,我们能够轻松插入row.id并使用自定义 HTML 图像元素来显示链接

为了抽象出可用于呈现各种数据的通用表视图,我们需要的第一个组件是cell context。 作为开始,这个上下文将能够显示原始文本数据和图像。 也可以在给定的单元格上放置一个链接,为此我们可以使用LinkContext

/// FILE: Sources/App/Framework/Templates/Context/CellContext.swift

public struct CellContext {
    
    public enum `Type`: String {
        case text
        case image
    }
    
    public let value: String
    public let link: LinkContext?
    public let type: `Type`
    
    public init(_ value: String, link: LinkContext? = nil, type: `Type` = .text){
        self.type = type
        self.value = value
        self.link = link
    }
}

CellTemplaterender 方法中,我们只需切换 cell context type,如果我们有链接值,我们将在链接内显示原始文本或图像内容,否则我们只返回正确的 HTML 标记

/// FILE: Sources/App/Framework/Templates/Templates/CellTemplate.swift

import Vapor
import SwiftHtml

public struct CellTemplate: TemplateRepresentable {

    var context: CellContext
    var rowId: String
    
    public init(_ context: CellContext, rowId: String) {
        self.context = context
        self.rowId = rowId
    }
    
    @TagBuilder
    public func render(_ req: Vapor.Request) -> Tag {
        Td {
            switch context.type {
            case .text:
                if let link = context.link {
                    LinkTemplate(link, pathInfix: rowId).render(req)
                } else {
                    Text(context.value)
                }
            case .image:
                if let link = context.link {
                    LinkTemplate(link, pathInfix: rowId) { label in
                        Img(src: context.value, alt: label)
                    }.render(req)
                } else {
                    Img(src: context.value, alt: context.value)
                }
                
            }
        }.class("field")
    }
}

Table cells按行组织,每一行都有一个唯一的标识符,所以让我们通过创建一个可以包含多个cell的 RowContext 对象来模拟这种结构

/// FILE: Sources/App/Framework/Templates/Contexts/RowContext.swift

public struct RowContext {
    
    public let id: String
    public let cells: [CellContext]
    
    public init(id: String, cells: [CellContext]) {
        self.id = id
        self.cells = cells
    }
    
}

我们还应该为 table columns定义一个context对象。 通过使用key和label值,可以将自定义排序机制添加到table中。 现在,我们将使用标签值来简单地显示没有排序选项的列名

/// FILE: Sources/App/Framework/Templates/Contexts/ColumnContext.swift

public struct ColumnContext {
 
    public let key: String
    public let label: String
    
    public init(_ key: String, label: String? = nil) {
        
        self.key = key
        self.label = label ?? key.capitalized
    }
}

现在我们已经准备好根据我们刚刚创建的结构来组合我们的表视图。 我们应该添加的最后一件事是一个action数组,我们可以在其中存储 LinkContext 值并在需要时显示这些action。 这就是我们完整的 TableContext 对象的样子

/// FILE: Sources/App/Framework/Templates/Contexts/TableContext.swift

public struct TableContext {
    public let columns: [ColumnContext]
    public let rows: [RowContext]
    public let actions: [LinkContext]
    
    public init(columns: [ColumnContext],
                rows: [RowContext],
                actions: [LinkContext] = []) {
        self.columns = columns
        self.rows = rows
        self.actions = actions
    }
}

TableTemplate 内部,我们将利用之前定义的上下文和模板。 我们可以将列和操作都映射到 Thead 部分内的 Th 标记。 在 Tbody 块中,我们可以遍历行并为每个单元格显示一个单元格以及一个带有动作集链接模板的动作。 我们使用 path Infixs 将行标识符(模型标识符)放入操作链接中。

/// FILE: Sources/App/Framework/Templates/Html/TableTemplate.swift


import Vapor
import SwiftHtml

public struct TableTemplate: TemplateRepresentable {

    var context: TableContext
    
    public init(_ context: TableContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Vapor.Request) -> Tag {
        Table {
            Thead {
                Tr {
                    context.columns.map { column in
                        Th(column.label)
                            .id(column.key)
                            .class("field")
                    }
                    
                    context.actions.map { action in
                        Th(action.label)
                            .class("action")
                    }
                }
            }
            Tbody {
                for row in context.rows {
                    Tr {
                        row.cells.map { CellTemplate($0, rowId: row.id).render(req) }
                        context.actions.map { action in
                            Td {
                                LinkTemplate(action, pathInfix: row.id).render(req)
                            }
                            .class("action")
                        }
                    }.id(row.id)
                }
            }
        }
    }
}

我们通过使用较小的元素可以构建出复杂的模板结构, 组合是表达这样的数据模型和模板的好方式,现在我们已准备好根据表格模板呈现列表

通用模型和列表控制器

现在我们来关注控制器。 到目前为止,我们只创建了几个控制器,但这次我们将为那些使用关联数据库模型类型的控制器设计出一个特殊的协议。 我们应该注意到 AdminBlogPostController 都使用底层的 Fluent 模型来呈现列表、编辑表单和删除内容。之后会实现的 AdminBlogCategoryController 也将遵循相同的模式,因此抽出一个通用协议是有意义的。

这个新协议 ModelController 具有关联的类型值,这是协议实现将定义为类型别名的类型的通用占位符。 我们可以将其称为 DatabaseModel,它应该是 DatabaseModelInterface 类型,这样我们就可以确保只有 Fluent 模型类型可以用作数据库模型。

我们将需要两个小的辅助变量,第一个是 Name 对象,第二个是 parameterId。 当我们显示导航栏链接时将使用该名称,当我们尝试基于路径组件查找模型时,parameterId 将很有帮助。

最后,我们可以扩展 ModelController 接口,并提供两种通用方法来根据请求参数返回标识符值,并提供一个函数来通过标识符查找模型或因未找到错误而中止。 稍后我们将需要这些功能。


/// FILE: Sources/App/Framework/Controllers/ModelController.swift

import Vapor
import FluentKit

public struct Name {
    
    public let singular: String
    public let plural: String
    
    init(singular: String, plural: String? = nil) {
        self.singular = singular
        self.plural = plural ?? singular + "s"
    }
}

public protocol ModelController {
    associatedtype DatabaseModel: DatabaseModelInterface
    
    var modelName: Name { get }
    var parameterId: String { get }
    func identifier(_ req: Request) throws -> UUID
    func findBy(_ id: UUID, on: Database) async throws -> DatabaseModel
    
}

extension ModelController {
    
    func identifier(_ req: Request) throws -> UUID {
        
        guard let id = req.parameters.get(parameterId), let uuid = UUID(uuidString: id) else {
            throw Abort(.badRequest)
        }
        return uuid
    }
    
    func findBy(_ id: UUID, on db: Database) async throws -> DatabaseModel {
        guard let model = try await DatabaseModel.find(id, on: db) else {
            throw Abort(.notFound)
        }
        return model
    }
    
}

现在我们有了一个通用的模型控制器接口,我们应该稍微关注一下列表视图的模板。 这些模板将由管理模块提供,因此我们将上下文和模板文件都放在 Admin/Templates 目录下。

AdminListPageContext 对象将具有一个标题属性、一个table context值以及一个navigation和一个带有 LinkContext 项的breadcrumbs数组。 额外的navigation链接将显示在列表标题下,breadcrumbs将用于屏幕的左上角,允许用户在需要时在管理链接结构中向上移动一个或多个级别


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


public struct AdminListPageContext {
    
    public let title: String
    public let table: TableContext
    public let navigation: [LinkContext]
    public let breadcrumbs: [LinkContext]
    
    public init(title: String,
                table: TableContext,
                navigation: [LinkContext] = [],
                breadcrumbs: [LinkContext] = []) {
        self.title = title
        self.table = table
        self.navigation = navigation
        self.breadcrumbs = breadcrumbs
    }
    
}

AdminListPageTemplate 的render方法中,我们可以检查table context是否包含rows,如果为空则显示空列表消息,否则我们相应地渲染 TableTemplate模版。 我们还通过contexttitlebreadcrumbs数组传递给index模板,我们将在本节之后立即进行必要的更改。 在标题下,我们使用 LinkTemplate 结构呈现导航链接

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

import Vapor
import SwiftHtml


public struct AdminListPageTemplate: TemplateRepresentable {

    
    var context: AdminListPageContext
    
    public init(_ context: AdminListPageContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Vapor.Request) -> SwiftSgml.Tag {
        AdminIndexTemplate(.init(title: context.title, breadcrumbs: context.breadcrumbs)) {
            Div {
                H1(context.title)
                P {
                    context.navigation.map { LinkTemplate($0).render(req) }
                }
            }.class("lead")
            
            
            if context.table.rows.isEmpty {
                Div {
                    Span("🔍 ") .class("icon")
                    H2("Oh no")
                    P("This list is empty right now.")
                    A("Try again →")
                        .href(req.url.path)
                        .class("button-1")
                }.class(["lead", "container", "center"])
                
            } else {
                
                TableTemplate(context.table).render(req)
                
            }
            
        }.render(req)

    }
    
}

现在是时候在 AdminIndexTemplate 中添加对 breadcrumb menu的支持了。

import Vapor
import SwiftHtml
import SwiftSvg

public struct AdminIndexTemplate: TemplateRepresentable {
    
    //...
    
    @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")
                
                Div {
                    Nav {
                        A("Admin")
                            .href("/admin/")
                        
                        context.breadcrumbs.map{ LinkTemplate($0).render(req) }
                    }
                }
                .class("breadcrumb")
                
                Main {
                    body
                }
                
                Script()
                    .type(.javascript)
                    .src("/js/admin.js")
            }
            
        }
        .lang("en-US")
        
    }
    
    
}

至此模板已经基本准备就绪,我们现在可以创建通用列表控制器,它将所有必要的东西放在一起来呈现我们的列表。 我们将使用列表前缀命名所有与列表相关的方法,这样我们就不会发生命名冲突。 这个前缀模式将应用于我们将来要创建的每一个管理控制器。

第一个list方法负责查询所有的数据库模型。 为此,我们可以使用关联的 DatabaseModel,因为它是一个通用的 Fluent 模型,查询方法在其上可用。

listView 函数是请求的处理程序方法,所以它会非常复杂,因此我们将定义几个额外的帮助程序来处理请求。

使用 listColumns 函数,开发者将能够定义将用于呈现列表标题的set of columns。 listCells 方法可用于返回每一行的cells,它有一个额外的参数,因此我们可以使用数据库模型的属性显示正确的值。

通过实现自定义 listNavigationlistBreadcrumbs方法,你可以为列表控制器设置自定义链接,同样,这些方法将是可选的,所以我们可以直接使用通用的实现返回默认链接。

listContext 方法将负责构建 AdminListPageContext 结构,listTemplate 将返回列表模板。 你还可以重写这些方法以为你的列表提供自定义视图,但在大多数情况下,你只需定义一个自定义columns和一个自定义cells的方法。

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

import Vapor

protocol AdminListController: ModelController {
    
    func list(_ req: Request) async throws -> [DatabaseModel]
    func listView(_ req: Request) async throws -> Response
    
    func listColumns() -> [ColumnContext]
    func listCells(for model: DatabaseModel) -> [CellContext]
    func listNavigation(_ req: Request) -> [LinkContext]
    func listBreadcrumbs(_ req: Request) -> [LinkContext]
    func listContext(_ req: Request, _ list: [DatabaseModel]) -> AdminListPageContext
    func listTemplate(_ req: Request, _ list: [DatabaseModel]) -> TemplateRepresentable
    
}

extension AdminListController {
    
    func list(_ req: Request) async throws -> [DatabaseModel] {
        try await DatabaseModel.query(on: req.db).all()
    }
    
    func listView(_ req: Request) async throws -> Response {
        let list = try await list(req)
        let template = listTemplate(req, list)
        return req.templates.renderHtml(template)
    }
    
    func listNavigation(_ req: Request) -> [LinkContext] {
        [
            LinkContext(label: "Create",path: "create")
        ]
    }
    
    func listBreadcrumbs(_ req: Request) -> [LinkContext] {
        [
            LinkContext(label: DatabaseModel.Module.identifier.capitalized, dropLast: 1)
        ]
    }
    
    func listContext(_ req: Request, _ list: [DatabaseModel]) -> AdminListPageContext {
        
        let rows = list.map { RowContext(id: $0.id!.uuidString, cells: listCells(for: $0)) }
        
        let table = TableContext(columns: listColumns(), rows: rows, actions: [
            LinkContext(label: "Update", path: "update"),
            LinkContext(label: "Delete", path: "delete")
        ])
        
        return .init(title: "List",
                     table: table,
                     navigation: listNavigation(req),
                     breadcrumbs: listBreadcrumbs(req))
    }
    
    func listTemplate(_ req: Request, _ list: [DatabaseModel]) -> TemplateRepresentable {
        AdminListPageTemplate(listContext(req, list))
    }
    
}

现在我们应该更新 AdminBlogPostController,我们可以简单地用下面的代码片段替换之前的 findlistView 方法,同时需要遵守 AdminListController 协议,因为它将提供我们列表视图逻辑的其余部分。

listColumns 中,我们定义了我们要显示的列,在我们的例子中,这将是一个图像和一个标题列,然后在 listCells 中,我们简单地为每一列返回一个cell。 管理列表控制器将遍历row,它会为每一个row调用此方法,使我们能够返回对应的cell

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

import Vapor
import Fluent

struct AdminBlogPostController: AdminListController {

    typealias DatabaseModel = BlogPostModel
    
    let modelName: Name = .init(singular: "post")
    let parameterId: String = "postId"
    
    func listColumns() -> [ColumnContext] {
        [
            .init("image"),
            .init("title")
        ]
    }
    
    func listCells(for model: BlogPostModel) -> [CellContext] {
        [
            .init(model.imageKey, type: .image),
            .init(model.title, link: .init(label: model.title))
        ]
    }
    
    //...

}

同样,我们可以为帖子类别创建一个新的控制器。 它比最开始版本的 listViewfind 方法要好得多,因为我们可以专注于实际的数据表示而不是底层的查询机制。

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

import Vapor
import Fluent

struct AdminBlogCategoryController: AdminListController {

    typealias DatabaseModel = BlogCategoryModel
    let modelName: Name = .init(singular: "category", plural: "categories")
    let parameterId: String = "categoryId"
    
    func listColumns() -> [ColumnContext] {
        [
            .init("title")
        ]
    }
    
    func listCells(for model: BlogCategoryModel) -> [CellContext] {
        [
            .init(model.title, link: .init(label: model.title))
        ]
    }
    
}


使用 AdminRouter 注册新创建的控制器。

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

import Vapor

struct AdminRouter: RouteCollection {

    
    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    let categoryAdminController = AdminBlogCategoryController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        let blog = routes .grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin", "blog")
        
        let categories = blog.grouped("categories")
        categories.get(use: categoryAdminController.listView)
        
        let posts = blog.grouped("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)
    }
    
    
}

最后,我们应该在 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)
                }
                
                Nav {
                    
                    H2("Blog")
                    Ul {
                        Li {
                            A("Posts")
                                .href("/admin/blog/posts/")
                        }
                        Li {
                            A("Categories")
                                .href("/admin/blog/categories/")
                        }
                    }
                }
            }
            .id("dashboard")
            .class("container")
        }
        .render(req)
    }
    
}

如果你构建并运行该应用程序,你应该会看到两个列表都运行良好。 当然,你可以为这些列表添加更多的操作和定制,拥有这种结构的好处在于你可以共享核心组件,并且为其他数据库模型构建新列表非常容易。

通用详情页控制器

在实现了列表页通用化改造后,我们也应该想出一个更好的解决方案来显示我们的数据库模型的详情页。 为此,我们将遵循与列表视图完全相同的设计方案。

我们从一个通用的detail context对象开始,它与我们的 CellContext 非常相似,但这次我们不需要到其他页面的额外链接。

/// FILE: Sources/App/Framework/Templates/Contexts/DetailContext.swift

public struct DetailContext {
    
    public enum `Type`: String {
        case text
        case image
    }
    
    public let key: String
    public let label: String
    public let value: String
    public let type: `Type`
    
    public init(key: String, value: String, label: String? = nil, type: `Type` = .text) {
        self.key = key
        self.label = label ?? key.capitalized
        self.value = value
        self.type = type
    }
}

DetailTemplate 内部,我们可以使用定义列表元素(DT、DL)来呈现基于 DetailContext 类型的上下文。 我们还检查该值是否为空,并为空值显示一个不间断的空格字符。

/// FILE: Sources/App/Framework/Templates/Html/DetailTemplate.swift

import Vapor
import SwiftHtml

public struct DetailTemplate: TemplateRepresentable {

    var context: DetailContext
    
    public init(context: DetailContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Vapor.Request) -> SwiftSgml.Tag {
        Dt(context.label)
        switch context.type {
        case .text:
            context.value.isEmpty ? Dd("&nbsp;") : Dd(context.value.replacingOccurrences(of: "\n", with: "<br>"))
        case .image:
            Dd {
                Img(src: context.value, alt: context.label)
            }
        }
    }
}

现在我们可以转到管理模块,我们应该为详细信息页面模板提供一个上下文对象。 当然,我们会有一个标题、一组导航链接和breadcrumbs,但除了这些属性之外,我们将创建一个包含 DetailContext 的字段数组,还有一个额外的 LinkContext 数组,我们可以此页面的底部使用它来显示操作, 如果我们想在不久的将来添加删除操作,这会很方便。

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

public struct AdminDetailPageContext {
    
    public let title: String
    public let fields: [DetailContext]
    public let navigation: [LinkContext]
    public let breadcrumbs: [LinkContext]
    public let actions: [LinkContext]
    
    public init(title: String,
                fields: [DetailContext],
                navigation: [LinkContext] = [],
                breadcrumbs: [LinkContext] = [],
                actions: [LinkContext] = []) {
        self.title = title
        self.fields = fields
        self.navigation = navigation
        self.breadcrumbs = breadcrumbs
        self.actions = actions
    }
    
}

AdminDetailPageTemplate 会很简单,因为我们已经有了一个用于大多数底层context值的模板,我们可以map所有内容并在map block中呈现正确的模板。

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

import Vapor
import SwiftHtml

struct AdminDetailPageTemplate: TemplateRepresentable {
    
    var context: AdminDetailPageContext
    
    init(context: AdminDetailPageContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title, breadcrumbs: context.breadcrumbs)) {
            Div {
                
                Div {
                    H1(context.title)
                    context.navigation.map { LinkTemplate($0).render(req) }
                }
                .class("lead")
                
                Dl {
                    context.fields.map { DetailTemplate(context: $0).render(req) }
                }
                
                Section {
                    context.actions.map { LinkTemplate($0).render(req) }
                }
                
            }
            .class("container")
        }
        .render(req)
    }
    
}

现在我们开始实现通用的 AdminDetailController 控制器,我们将遵循与列表控制器完全相同的设计原则。 这次你可以通过实现 detailFields 方法来定义显示的字段。

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

import Vapor

protocol AdminDetailController: ModelController {
    
    func detailView(_ req: Request) async throws -> Response
    func detailTemplate(_ req: Request, _ model: DatabaseModel) -> TemplateRepresentable
    func detailFields(for model: DatabaseModel) -> [DetailContext]
    func detailContext(_ req: Request, _ model: DatabaseModel) -> AdminDetailPageContext
    func detailBreadcrumbs(_ req: Request, _ model: DatabaseModel) -> [LinkContext]
    func detailNavigation(_ req: Request, _ model: DatabaseModel) -> [LinkContext]
    
}

extension AdminDetailController {
    func detailView(_ req: Request) async throws -> Response {
        let model = try await findBy(identifier(req), on: req.db)
        return req.templates.renderHtml(detailTemplate(req, model))
    }
    
    func detailTemplate(_ req: Request, _ model: DatabaseModel) -> TemplateRepresentable {
        AdminDetailPageTemplate(context: detailContext(req, model))
    }
    
    func detailContext(_ req: Request, _ model: DatabaseModel) -> AdminDetailPageContext {
        .init(title: "Details",
              fields: detailFields(for: model),
              navigation: detailNavigation(req, model),
              breadcrumbs: detailBreadcrumbs(req, model),
              actions: [LinkContext(label: "Delete",path: "/delete/?redirect=" + req.url.path.pathComponents.dropLast().string + "&cancel=" + req.url.path)
                       ])
    }
    
    func detailBreadcrumbs(_ req: Request, _ model: DatabaseModel) -> [LinkContext] {
        [
            LinkContext(label: DatabaseModel.Module.identifier.capitalized, dropLast: 2),
            LinkContext(label: modelName.plural.capitalized, dropLast: 1)
        ]
    }
    
    func detailNavigation(_ req: Request, _ model: DatabaseModel) -> [LinkContext] {
        [
            LinkContext(label: "Update", path: "update")
        ]
    }
    
}

detailContext 中,我们添加了一个新的link action,稍后我们可以使用它来删除模型。 删除路由将支持重定向和取消,这将允许我们在成功删除事件后执行重定向或返回到发起删除请求的原始页面。

完成 AdminDetailController 协议后,我们可以更新AdminBlogCategoryController

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

import Vapor
import Fluent

struct AdminBlogCategoryController: AdminListController, AdminDetailController {


    typealias DatabaseModel = BlogCategoryModel
    let modelName: Name = .init(singular: "category", plural: "categories")
    let parameterId: String = "categoryId"
    
    func listColumns() -> [ColumnContext] {
        [
            .init("title")
        ]
    }
    
    func listCells(for model: BlogCategoryModel) -> [CellContext] {
        [
            .init(model.title, link: .init(label: model.title))
        ]
    }
    
    func detailFields(for model: BlogCategoryModel) -> [DetailContext] {
        [
            .init(key: "title", value: model.title)
        ]
    }
    
}

现在我们可以注册类别详情的路由了

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

import Vapor

struct AdminRouter: RouteCollection {

    
    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    let categoryAdminController = AdminBlogCategoryController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        let blog = routes .grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin", "blog")
        
        let categories = blog.grouped("categories")
        categories.get(use: categoryAdminController.listView)
        let categoryId = categories.grouped(":categoryId")
        categoryId.get(use: categoryAdminController.detailView)
        
        let posts = blog.grouped("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)
    }
    
}

不要忘记更新AdminBlogPostController并替换掉 detailView 方法。

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

import Vapor
import Fluent

struct AdminBlogPostController: AdminListController, AdminDetailController {

    
    typealias DatabaseModel = BlogPostModel
    
    let modelName: Name = .init(singular: "post")
    let parameterId: String = "postId"
    
    func listColumns() -> [ColumnContext] {
        [
            .init("image"),
            .init("title")
        ]
    }
    
    func listCells(for model: BlogPostModel) -> [CellContext] {
        [
            .init(model.imageKey, type: .image),
            .init(model.title, link: .init(label: model.title))
        ]
    }
    
    func detailFields(for model: BlogPostModel) -> [DetailContext] {
        [
            .init(key: "image", value: model.imageKey, type: .image),
            .init(key: "title", value: model.title)
        ]
    }

    //...
}

我们已经准备好了详细页,因为我们已经在cell上放置了链接上下文,我们可以简单地从列表导航到详细信息页面。

通用编辑控制器

在前面的章节里,我们学习了如何构建 AbstractForm 子类来显示自定义表单,不过我们之前留下了一些未解决的问题,就是事件处理程序块中常量 [unowned self]片段。

让我们开始解决这个问题,但在我们这样做之前,我们将通过在 AbstractForm 类上引入 getContext 函数来稍微分解 render 方法。 这将允许我们在需要时检索 FormContext 结构

/// FILE: Sources/App/Framework/Form/AbstractForm.swift

import Vapor

open class AbstractForm: FormComponent {

     //...

    open func render(req: Request) -> TemplateRepresentable {
        FormTemplate(getContext(req))
    }
    
    func getContext(_ req: Request) -> FormContext {
        .init(action: action,
              fields: fields.map { $0.render(req: req)},
              error: error,
              submit: submit)
    }
}

那么我们如何解决unowned问题呢? 如果我们不使用类,而是使用结构,那么我们可以消除这个问题。 由于结构不是引用类型,我们不需要在event handlers中使用unowned指针。 为此,我们将引入一个新的 ModelEditorInterface 协议,让我们可以使用抽象表单实例编辑数据库模型。 该协议也将符合 FormComponent 协议,但它只是将事件方法转发给基础表单。

/// FILE: Sources/App/Framework/ModelEditorInterface.swift

import Vapor


public protocol ModelEditorInterface: FormComponent {
    associatedtype Model: DatabaseModelInterface
    var model: Model { get }
    var form: AbstractForm { get }
    init(model: Model, form: AbstractForm)
    @FormComponentBuilder
    var formFields: [FormComponent] { get }
}

public extension ModelEditorInterface {
    
    func load(req: Request) async throws {
        try await form.load(req: req)
    }
    
    func process(req: Request) async throws {
        try await form.process(req: req)
    }
    
    func validate(req: Request) async throws -> Bool {
        try await form.validate(req: req)
    }
    
    func write(req: Request) async throws {
        try await form.write(req: req)
    }
    
    func save(req: Request) async throws {
        try await form.save(req: req)
    }
    
    func read(req: Request) async throws {
        try await form.read(req: req)
    }
    
    func render(req: Request) -> TemplateRepresentable {
        form.render(req: req)
    }
}

从现在开始,如果涉及到编辑数据库模型,我们应该始终使用模型编辑器。 当不需要关联的数据库实体时,表单子类仍可用于其他类型的 Web 表单。 例如,UserLoginForm 就是一个很好的例子。

我们将需要一个AdminEditorPageContext,它与详细信息上下文非常相似,但这次我们可以简单地使用表单上下文,而不是使用字段数组。

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

public struct AdminEditorPageContext {
    
    public let title: String
    public let form: FormContext
    public let navigation: [LinkContext]
    public let breadcrumbs: [LinkContext]
    public let actions: [LinkContext]

    public init(title: String,
                form: FormContext,
                navigation: [LinkContext] = [],
                breadcrumbs: [LinkContext] = [],
                actions: [LinkContext] = []) {
        self.title = title
        self.form = form
        self.navigation = navigation
        self.breadcrumbs = breadcrumbs
        self.actions = actions
    }
    
}

AdminEditorPageTemplate 也应该看起来很熟悉,但这次我们使用FormContext呈现 FormTemplate

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

import Vapor
import SwiftHtml

struct AdminEditorPageTemplate: TemplateRepresentable {
    
    var context: AdminEditorPageContext
    
    init(_ context: AdminEditorPageContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title, breadcrumbs: context.breadcrumbs)) {
            Div {
                Div {
                    H1(context.title)
                    context.navigation.map { LinkTemplate($0).render(req) }
                    
                }
                .class("lead")
                
                FormTemplate(context.form).render(req)
                
                Section {
                    context.actions.map { LinkTemplate($0).render(req) }
                }
            }
            .class("container")
        }
        .render(req)
    }
}

我们还需要一个通用的创建控制器,我们可以利用 ModelEditorInterface 协议并使用它来初始化和处理输入表单,并根据提交的表单值更新底层模型。 我们再次为所有内容添加前缀以避免命名冲突。

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

import Vapor

protocol AdminCreateController: ModelController {
    associatedtype CreateModelEditor: ModelEditorInterface
    func createTemplate(_ req: Request, _ editor: CreateModelEditor) -> TemplateRepresentable
    func createView(_ req: Request) async throws -> Response
    func createAction(_ req: Request) async throws -> Response
    func createContext(_ req: Request, _ editor: CreateModelEditor) -> AdminEditorPageContext
    func createBreadcrumbs(_ req: Request) -> [LinkContext]
}

extension AdminCreateController {
    
    private func render(_ req: Request, editor: CreateModelEditor) -> Response {
        return req.templates.renderHtml(createTemplate(req, editor))
    }
    
    func createView(_ req: Request) async throws -> Response {
        let editor = CreateModelEditor(model: .init(), form: .init())
        editor.form.fields = editor.formFields
        try await editor.load(req: req)
        return render(req, editor: editor)
    }
    
    func createAction(_ req: Request) async throws -> Response {
        let model = DatabaseModel()
        let editor = CreateModelEditor(model: model as! CreateModelEditor.Model, form: .init())
        editor.form.fields = editor.formFields
        try await editor.load(req: req)
        try await editor.process(req: req)
        let isValid = try await editor.validate(req: req)
        guard isValid else {
            return render(req, editor: editor)
        }
        try await editor.write(req: req)
        try await editor.model.create(on: req.db)
        try await editor.save(req: req)
        var components = req.url.path.pathComponents.dropLast()
        components += editor.model.id!.uuidString.pathComponents
        return req.redirect(to: "/" + components.string + "/update/")
    }
    
    func createTemplate(_ req: Request, _ editor: CreateModelEditor) -> TemplateRepresentable {
        AdminEditorPageTemplate(createContext(req, editor))
    }
    
    func createContext(_ req: Request, _ editor: CreateModelEditor) -> AdminEditorPageContext {
        let context = FormContext(action: editor.form.action,
                                  fields: editor.form.fields.map { $0.render(req: req) },
                                  error: editor.form.error,
                                  submit: editor.form.submit)
        return .init(title: "Create", form: context, breadcrumbs: createBreadcrumbs(req))
    }
    
    func createBreadcrumbs(_ req: Request) -> [LinkContext] {
        [
            LinkContext(label: DatabaseModel.Module.identifier.capitalized, dropLast: 2),
            LinkContext(label: modelName.plural.capitalized, dropLast: 1)
        ]
    }
}

就像create controller一样,我们可以引入一个update controller,它将包含所有与更新相关的逻辑。 正如你在创建和更新操作后看到的那样,我们重定向到一个新的 URL,现在我们可以假设更新/详细信息路由可用,但稍后你可能希望通过引入一个新的重定向函数来分解重定向逻辑 可以在实际的控制器实现中定制。

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

import Vapor

protocol AdminUpdateController: ModelController {
    associatedtype UpdateModelEditor: ModelEditorInterface
    func updateView(_ req: Request) async throws -> Response
    func updateAction(_ req: Request) async throws -> Response
    func updateTemplate(_ req: Request, _ editor: UpdateModelEditor) async -> TemplateRepresentable
    func updateContext(_ req: Request, _ editor: UpdateModelEditor) async -> AdminEditorPageContext
    func updateBreadcrumbs(_ req: Request, _ model: DatabaseModel) -> [LinkContext]
    func updateNavigation(_ req: Request, _ model: DatabaseModel) -> [LinkContext]
}

extension AdminUpdateController {
    
    private func render(_ req: Request, editor: UpdateModelEditor) async -> Response {
        return req.templates.renderHtml(await updateTemplate(req, editor))
    }
    
    func updateView(_ req: Request) async throws -> Response {
        let model = try await findBy(identifier(req), on: req.db)
        let editor = UpdateModelEditor(model: model as! UpdateModelEditor.Model, form: .init())
        editor.form.fields = editor.formFields
        try await editor.load(req: req)
        try await editor.read(req: req)
        return await render(req, editor: editor)
    }
    
    
    func updateAction(_ req: Request) async throws -> Response {
        let model = try await findBy(identifier(req), on: req.db)
        let editor = UpdateModelEditor(model: model as! UpdateModelEditor.Model, form: .init())
        editor.form.fields = editor.formFields
        try await editor.load(req: req)
        try await editor.process(req: req)
        let isValid = try await editor.validate(req: req)
        guard isValid else {
            return await render(req, editor: editor)
        }
        try await editor.write(req: req)
        try await editor.model.update(on: req.db)
        try await editor.save(req: req)
        return req.redirect(to: req.url.path)
    }
    
    func updateTemplate(_ req: Request, _ editor: UpdateModelEditor) async -> TemplateRepresentable {
        await AdminEditorPageTemplate(updateContext(req, editor))
    }
    
    func updateContext(_ req: Request, _ editor: UpdateModelEditor) async -> AdminEditorPageContext {
        .init(title: "Update",
              form: editor.form.getContext(req),
              navigation: updateNavigation(req, editor.model as! DatabaseModel),
              breadcrumbs: updateBreadcrumbs(req, editor.model as! DatabaseModel),
              actions: [
                LinkContext(label: "Delete",
                            path: "delete/?redirect=" + req.url.path.pathComponents.dropLast(2).string + "&cancel=" + req.url.path, dropLast: 1)
              ])
    }
    
    func updateBreadcrumbs(_ req: Request, _ model: DatabaseModel) -> [LinkContext] {
        [
            LinkContext(label: DatabaseModel.Module.identifier.capitalized, dropLast: 3),
            LinkContext(label: modelName.plural.capitalized, dropLast: 2)
        ]
    }
    
    func updateNavigation(_ req: Request, _ model: DatabaseModel) -> [LinkContext] {
        [
            LinkContext(label: "Details", dropLast: 1)
        ]
   }
    
}

创建和更新控制器将负责表单呈现和提交工作流程,但缺少了表单的ui表现部分。 为此,我们创建新的表单编辑器来处理。

编辑器的主要目标是提供必要的表单字段,以显示将使用管理界面上的控制器呈现的创建或编辑表单。

幸运的是,我们已经有很多可用的表单字段类型,因此我们可以使用这些组件和构建器方法来创建我们的字段。

/// FILE: Sources/App/Modules/Blog/Editors/BlogCategoryEditor.swift

import Vapor

struct BlogCategoryEditor: ModelEditorInterface {
    
    let model: BlogCategoryModel
    let form: AbstractForm
    
    init(model: BlogCategoryModel, form: AbstractForm) {
        self.model = model
        self.form = form
    }
    
    @FormComponentBuilder
    var formFields: [FormComponent] {
        InputField("title")
            .config {
                $0.output.context.label.required = true
            }
            .validators {
                FormFieldValidator.required($1)
            }
            .read {
                $1.output.context.value = model.title
            }
            .write {
                model.title = $1.input
            }
    }
}

对于 BlogCategoryModel,我们只需编辑一个标题值,为此 InputField 是最佳的选择。 验证也非常简单,我们在这里唯一要做的就是在控制器调用适当的编辑器方法时读取和写入输入和输出值。

AdminBlogCategoryController 中,我们只需要实现创建和更新协议,这是通过两个新的类型别名定义实现的,这就是我们告诉系统它应该使用的编辑器的确切类型的方式。

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

import Vapor
import Fluent

struct AdminBlogCategoryController: AdminListController, AdminDetailController, AdminCreateController, AdminUpdateController {


    typealias DatabaseModel = BlogCategoryModel
    typealias CreateModelEditor = BlogCategoryEditor
    typealias UpdateModelEditor = BlogCategoryEditor
    
    let modelName: Name = .init(singular: "category", plural: "categories")
    let parameterId: String = "categoryId"
    
    func listColumns() -> [ColumnContext] {
        [
            .init("title")
        ]
    }
    
    func listCells(for model: BlogCategoryModel) -> [CellContext] {
        [
            .init(model.title, link: .init(label: model.title))
        ]
    }
    
    func detailFields(for model: BlogCategoryModel) -> [DetailContext] {
        [
            .init(key: "title", value: model.title)
        ]
    }
    
}

可以看一下AdminBlogCategoryController,里面的实现非常简单,得益于通过默认协议扩展实现了很多逻辑。


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

import Vapor

struct AdminRouter: RouteCollection {

    
    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    let categoryAdminController = AdminBlogCategoryController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        let blog = routes .grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin", "blog")
        
        let categories = blog.grouped("categories")
        categories.get(use: categoryAdminController.listView)
        categories.get("create", use: categoryAdminController.createView)
        categories.post("create", use: categoryAdminController.createAction)
        let categoryId = categories.grouped(":categoryId")
        categoryId.get(use: categoryAdminController.detailView)
        categoryId.get("update", use: categoryAdminController.updateView)
        categoryId.post("update", use: categoryAdminController.updateAction)
        
        let posts = blog.grouped("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)
    }
    
}

现在我们应该能够查看、创建和编辑博客categories。

最后,我们可以更新blog post controller,为了首先实现这一点,我们应该删除原始的博客文章表单,我们应该添加一个编辑器。

/// FILE: Sources/App/Modules/Blog/Editors/BlogPostEditor.swift

import Vapor


struct BlogPostEditor: ModelEditorInterface {
    let model: BlogPostModel
    let form: AbstractForm
    
    var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .long
        formatter.timeStyle = .short
        return formatter
    }()
    
    init(model: BlogPostModel, form: AbstractForm) {
        self.model = model
        self.form = form
    }
    
    @FormComponentBuilder
    var formFields: [FormComponent] {
        ImageField("image", path: "blog/post")
            .read {
                $1.output.context.previewUrl = model.imageKey
                ($1 as! ImageField).imageKey = model.imageKey
            }
            .write {
                model.imageKey = ($1 as! ImageField).imageKey ?? ""
            }
        
        InputField("slug")
            .config {
                $0.output.context.label.required = true
            }
            .validators {
                FormFieldValidator.required($1)
            }
            .read {
                $1.output.context.value = model.slug
            }
            .write {
                model.slug = $1.input
            }
        
        InputField("title")
            .config {
                $0.output.context.label.required = true
            }
            .validators {
                FormFieldValidator.required($1)
            }
            .read {
                $1.output.context.value = model.title
            }
            .write {
                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 { $1.output.context.value = dateFormatter.string(from: model.date) }
            .write {
                model.date = dateFormatter.date(from: $1.input) ?? Date()
                
            }
        
        TextareaField("excerpt")
            .read {
                $1.output.context.value = model.excerpt
            }
            .write {
                model.excerpt = $1.input
            }
        
        TextareaField("content")
            .read {  $1.output.context.value = model.content }
            .write {  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 {  req, field in
                field.output.context.value = model.$category.id.uuidString
            }
            .write {  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!
                }
            }
    }
    
}

修改 AdminBlogPostController 并从中删除创建和更新方法。

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

import Vapor
import Fluent

struct AdminBlogPostController: AdminListController, AdminDetailController, AdminCreateController, AdminUpdateController {

    typealias DatabaseModel = BlogPostModel
    typealias CreateModelEditor = BlogPostEditor
    typealias UpdateModelEditor = BlogPostEditor
    
    let modelName: Name = .init(singular: "post")
    let parameterId: String = "postId"
    
    func listColumns() -> [ColumnContext] {
        [
            .init("image"),
            .init("title")
        ]
    }
    
    func listCells(for model: BlogPostModel) -> [CellContext] {
        [
            .init(model.imageKey, type: .image),
            .init(model.title, link: .init(label: model.title))
        ]
    }
    
    func detailFields(for model: BlogPostModel) -> [DetailContext] {
        [
            .init(key: "image", value: model.imageKey, type: .image),
            .init(key: "title", value: model.title)
        ]
    }
    
    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 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/")
    }

}

通用删除控制器

我们可以从一个新的页面context对象重新开始,这次我们将引入一个name和一个type属性。 name将包含实体的上下文值,type将用于显示用户要删除的对象的实际类型。

我们将显示一个2次确认表单,为此我们将定义一个简单的删除表单,当用户使用 post 方法提交该表单时,我们将执行删除操作。 这就是为什么我们在删除页面上下文中有一个 FormContext

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

public struct AdminDeletePageContext {
    
    public let title: String
    public let name: String
    public let type: String
    public let form: FormContext
    public let navigation: [LinkContext]
    public let breadcrumbs: [LinkContext]
    
    public init(title: String,
                name: String,
                type: String,
                form: FormContext,
                navigation: [LinkContext] = [],
                breadcrumbs: [LinkContext] = []) {
        self.title = title
        self.name = name
        self.type = type
        self.form = form
        self.navigation = navigation
        self.breadcrumbs = breadcrumbs
    }
    
}

下面会解释基于模板的名称和类型值的原因。 例如,如果你想删除名称为“Lorem ipsum”的“帖子”类型,将显示以下消息:“你即将永久删除 Lorem ipsum 帖子。”

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

import Vapor
import SwiftHtml

public struct AdminDeletePageTemplate: TemplateRepresentable {
    
    var context: AdminDeletePageContext
    
    public init(_ context: AdminDeletePageContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        AdminIndexTemplate(.init(title: context.title, breadcrumbs: context.breadcrumbs)) {
            Div {
                Span("🗑 ")
                    .class("icon")
                H1(context.title)
                P("You are about to permanently delete the<br>`\(context.name)` \(context.type).")
                
                FormTemplate(context.form).render(req)
                
                A("Cancel")
                    .href((try? req.query.get(String.self, at: "cancel")) ?? "#")
                    .class(["button", "cancel"])
                
            }
            .class(["lead", "container", "center"])
        }
        .render(req)
    }
    
}

我们可以检查是否有取消查询参数,并在用户单击取消按钮时将其用作返回原始页面的链接。 在表单控制器内部,我们将对重定向查询参数执行相同的操作,并使用它在删除后转到最终位置。

与之前介绍的管理控制器相比,AdminDeleteController 控制器相对简单。 唯一需要的方法是 deleteInfo,开发者可以在其中提供上下文值作为将要删除的实体的名称。

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

import Vapor


final class DeleteForm: AbstractForm {
    
    init() {
        super.init()
        self.action.method = .post
        self.submit = "Delete"
    }
    
}

protocol AdminDeleteController: ModelController {
    
    func deleteView(_ req: Request) async throws -> Response
    func deleteAction(_ req: Request) async throws -> Response
    func deleteTemplate(_ req: Request, _ model: DatabaseModel, _ form: DeleteForm) -> TemplateRepresentable
    func deleteInfo(_ model: DatabaseModel) -> String
    func deleteContext(_ req: Request, _ model: DatabaseModel, _ form: DeleteForm) -> AdminDeletePageContext
    
}

extension AdminDeleteController {
    
    func deleteView(_ req: Request) async throws -> Response {
        let model = try await findBy(identifier(req), on: req.db)
        let form = DeleteForm()
        return req.templates.renderHtml(deleteTemplate(req, model, form))
    }
    
    func deleteAction(_ req: Request) async throws -> Response {
        let model = try await findBy(identifier(req), on: req.db)
        try await model.delete(on: req.db)
        var url = req.url.path
        if let redirect = try? req.query.get(String.self, at: "redirect") {
            url = redirect
        }
        return req.redirect(to: url)
    }
    
    func deleteTemplate(_ req: Request, _ model: DatabaseModel, _ form: DeleteForm) -> TemplateRepresentable {
        AdminDeletePageTemplate(deleteContext(req, model, form))
    }
    
    func deleteContext(_ req: Request, _ model: DatabaseModel, _ form: DeleteForm) -> AdminDeletePageContext {
        .init(title: "Delete",
              name: deleteInfo(model),
              type: "model",
              form: form.getContext(req))
    }
}

首先,我们可以通过更新 AdminBlogCategoryController 文件来尝试一下

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

import Vapor
import Fluent

struct AdminBlogCategoryController: AdminListController, AdminDetailController, AdminCreateController, AdminUpdateController, AdminDeleteController {


    typealias DatabaseModel = BlogCategoryModel
    typealias CreateModelEditor = BlogCategoryEditor
    typealias UpdateModelEditor = BlogCategoryEditor
    
    let modelName: Name = .init(singular: "category", plural: "categories")
    let parameterId: String = "categoryId"
    
    func listColumns() -> [ColumnContext] {
        [
            .init("title")
        ]
    }
    
    func listCells(for model: BlogCategoryModel) -> [CellContext] {
        [
            .init(model.title, link: .init(label: model.title))
        ]
    }
    
    func detailFields(for model: BlogCategoryModel) -> [DetailContext] {
        [
            .init(key: "title", value: model.title)
        ]
    }
    
    func deleteInfo(_ model: DatabaseModel) -> String {
        model.title
   }
    
}

在我们注册删除路由之前,让我们先快速修改 AdminBlogPostController

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

import Vapor
import Fluent

struct AdminBlogPostController: AdminListController, AdminDetailController, AdminCreateController, AdminUpdateController, AdminDeleteController {

    typealias DatabaseModel = BlogPostModel
    typealias CreateModelEditor = BlogPostEditor
    typealias UpdateModelEditor = BlogPostEditor
    
    let modelName: Name = .init(singular: "post")
    let parameterId: String = "postId"
    
    func listColumns() -> [ColumnContext] {
        [
            .init("image"),
            .init("title")
        ]
    }
    
    func listCells(for model: BlogPostModel) -> [CellContext] {
        [
            .init(model.imageKey, type: .image),
            .init(model.title, link: .init(label: model.title))
        ]
    }
    
    func detailFields(for model: BlogPostModel) -> [DetailContext] {
        [
            .init(key: "image", value: model.imageKey, type: .image),
            .init(key: "title", value: model.title)
        ]
    }
    
    func deleteInfo(_ model: DatabaseModel) -> String {
        model.title
    }
}

最后,把删除路由添加进模块当中。

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

import Vapor

struct AdminRouter: RouteCollection {

    
    let controller = AdminFrontendController()
    
    let blogPostController = AdminBlogPostController()
    
    let categoryAdminController = AdminBlogCategoryController()
    
    func boot(routes: Vapor.RoutesBuilder) throws {
        routes
            .grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
            .get("admin", use: controller.dashboardView)
        
        let blog = routes .grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin", "blog")
        
        let categories = blog.grouped("categories")
        categories.get(use: categoryAdminController.listView)
        categories.get("create", use: categoryAdminController.createView)
        categories.post("create", use: categoryAdminController.createAction)
        let categoryId = categories.grouped(":categoryId")
        categoryId.get(use: categoryAdminController.detailView)
        categoryId.get("update", use: categoryAdminController.updateView)
        categoryId.post("update", use: categoryAdminController.updateAction)
        categoryId.get("delete", use: categoryAdminController.deleteView)
        categoryId.post("delete", use: categoryAdminController.deleteAction)
        
        let posts = blog.grouped("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)
    }
    
}

总结

在本篇中,我们主要完成了博客模块的基础CMS。 我们创建了一个可供其他人使用的通用 CRUD 功能。 协议扩展帮助我们消除了大量样板代码,多亏了它们,代码组织更加得清晰有层次。

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

推荐阅读更多精彩内容