本篇是关于将我们的上篇实现的基本 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,则可以在此变量中存储绝对路径的 URL。 dropLast 变量指示在我们将路径变量附加到 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
}
}
在 CellTemplate 的 render 方法中,我们只需切换 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
模版。 我们还通过context将title和breadcrumbs数组传递给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,它有一个额外的参数,因此我们可以使用数据库模型的属性显示正确的值。
通过实现自定义 listNavigation 和 listBreadcrumbs方法,你可以为列表控制器设置自定义链接,同样,这些方法将是可选的,所以我们可以直接使用通用的实现返回默认链接。
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
,我们可以简单地用下面的代码片段替换之前的 find 和 listView 方法,同时需要遵守 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))
]
}
//...
}
同样,我们可以为帖子类别创建一个新的控制器。 它比最开始版本的 listView 和 find 方法要好得多,因为我们可以专注于实际的数据表示而不是底层的查询机制。
/// 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(" ") : 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 功能。 协议扩展帮助我们消除了大量样板代码,多亏了它们,代码组织更加得清晰有层次。