Vapor 框架学习记录(8)内容管理系统
上一篇我们已经准备好了需要的各种表单字段,现在,我们将构建一个带有管理界面的内容管理系统。 我们将为管理页面创建一个独立的模块,它将与 Web 前端完全独立的。 CMS 将支持列表、详细信息、创建、更新和删除功能。 模型将持久保存到数据库中,我们将通过使用新的内置中间件来保护管理后台
admin 模块
我们想要的基本内容管理系统需要有最基础的增删改查功能,所以后面我们也是按这几个功能来分开实现。
在我们创建管理员模块之前,让我们稍微重构一下我们的代码。 首先,我们将从 Web index模板文件中移出 Svg 菜单图标扩展
/// FILE: Sources/App/Extensions/Svg+MenuIcon.swift
import SwiftSvg
extension Svg {
static func menuIcon() -> Svg {
Svg {
Line(x1: 3, y1: 12, x2: 21, y2: 12)
Line(x1: 3, y1: 6, x2: 21, y2: 6)
Line(x1: 3, y1: 18, x2: 21, y2: 18)
}
.width(24)
.height(24)
.viewBox(minX: 0, minY: 0, width: 24, height: 24)
.fill("none")
.stroke("currentColor")
.strokeWidth(2)
.strokeLinecap("round")
.strokeLinejoin("round")
}
}
下一步,我们应该向index模版添加一个新的admin链接,因为在我们创建了管理模块之后,通过身份验证的用户,就能够从 Web 前端访问仪表板。
/// FILE: Sources/App/Modules/Web/Templates/Html/WebIndexTemplate.swift
import Vapor
import SwiftSvg
import SwiftSgml
import SwiftHtml
public struct WebIndexTemplate: TemplateRepresentable {
public var context: WebIndexContext
var body: Tag
public init(_ context: WebIndexContext, @TagBuilder _ builder: () -> Tag) {
self.context = context
self.body = builder()
}
@TagBuilder
public func render(_ req: Request) -> Tag {
Html {
Head {
Meta()
.charset("utf-8")
Meta()
.name(.viewport)
.content("width=device-width, initial-scale=1")
Link(rel: .shortcutIcon)
.href("/image/favicon.ico")
.type("image/x-icon")
Link(rel: .stylesheet)
.href("https://cdn.jsdelivr.net/gh/feathercms/feather-core@1.0.0-beta.44/feather.min.css")
Link(rel: .stylesheet)
.href("/css/web.css")
Title(context.title)
}
Body {
Header {
Div {
A {
Img(src: "/img/logo.png", alt: "Logo")
}
.id("site-logo")
.href("/")
Nav {
Input()
.type(.checkbox)
.id("primary-menu-button")
.name("menu-button")
.class("menu-button")
Label {
Svg.menuIcon()
}.for("primary-menu-button")
Div {
A("Home")
.href("/")
.class("selected", req.url.path == "/")
A("Blog")
.href("/blog/")
.class("selected", req.url.path == "/blog/")
A("About")
.href("#")
.onClick("javascript:about();")
if req.auth.has(AuthenticatedUser.self) {
A("Admin")
.href("/admin/")
A("Sign Out")
.href("/sign-out/")
} else {
A("Sign In")
.href("/sign-in/")
}
}
.class("menu-items")
}
.id("primary-menu")
}
.id("navigation")
}
Main {
body
}
Footer {
Section {
P {
Text("This site is powered by ")
A("Swift")
.href("https://swift.org")
.target(.blank)
Text(" & ")
A("Vapor")
.href("https://vapor.codes")
.target(.blank)
Text(".")
}
P("lqbk.space © 2020-2022")
}
}
Script()
.type(.javascript)
.src("/js/web.js")
}
}
.lang("en-US")
}
}
admin模块和web模块一样,是其他模块的主要布局框架。 它们提供基本布局模板,其他模块可以挂接到这些容器中。 例如,web 模块有一个index模板,用于 web 前端的所有页面,例如博客或登录界面。 同理,管理模块将为管理页面提供类似的index模板。
作为起点,我们需要一个context才能创建admin index模板
/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminIndexContext.swift
public struct AdminIndexContext {
public let title: String
public init(title: String) {
self.title = title
}
}
接着就是模版文件 AdminIndexTemplate
,确保为context和Template所在的目录结构
/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminIndexTemplate.swift
import Vapor
import SwiftHtml
import SwiftSvg
public struct AdminIndexTemplate: TemplateRepresentable {
public var context: AdminIndexContext
var body: Tag
public init(_ context: AdminIndexContext, @TagBuilder _ builder: () -> Tag) {
self.context = context
self.body = builder()
}
@TagBuilder
public func render(_ req: Vapor.Request) -> SwiftSgml.Tag {
Html {
Head {
Meta()
.charset("utf-8")
Meta()
.name(.viewport)
.content("width=device-width, initial-scale=1")
Meta()
.name("robots")
.content("noindex")
Link(rel: .shortcutIcon)
.href("/images/favicon.ico")
.type("image/x-icon")
Link(rel: .stylesheet)
.href("https://cdn.jsdelivr.net/gh/feathercms/feather-core@1.0.0-beta.44/feather.min.css")
Link(rel: .stylesheet)
.href("/css/admin.css")
Title(context.title)
}
Body {
Div {
A {
Img(src: "/img/logo.png", alt: "Logo")
.title("Logo")
.style("width: 300px")
}
.href("/")
Nav {
Input()
.type(.checkbox)
.id("secondary-menu-button")
.name("menu-button")
.class("menu-button")
Label{
Svg.menuIcon()
}
.for("secondary-menu-button")
Div {
A("Sign out")
.href("/sign-out/")
}.class("menu-items")
}
.id("secondary-menu")
}
.id("navigation")
Main {
body
}
Script()
.type(.javascript)
.src("/js/admin.js")
}
}
.lang("en-US")
}
}
主管理模板与 Web 索引略有不同。 第一个变化是新的meta标记是robots,因为我们不想管理页面被索引。 不像其他可以被机器人访问的页面,我们将用中间件保护它们,所以它不会公开可用,但我们还是添加robots meta
我们也在此处链接 Feather CSS 框架,因为它是一个包含非常常见内容的通用共享 CSS 文件。 我们还包含了一个新的 admin.css 样式表,它将包含管理员特定的样式。 菜单结构与 web 不同,我们在最后添加了一个新的 admin.js 文件。 请在public文件夹中创建这些新文件。
我们还需要内容管理系统的主页之类的东西。 我们将把它称为仪表板,和往常一样,首先我们需要为它创建 context
/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminDashboardContext.swift
struct AdminDashboardContext {
let icon: String
let title: String
let message: String
}
让我们在模板文件夹中的索引文件旁边添加一个新的 AdminDashboardTemplate
/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminDashboardTemplate.swift
import Vapor
import SwiftHtml
struct AdminDashboardTemplate: TemplateRepresentable {
var context: AdminDashboardContext
init(context: AdminDashboardContext) {
self.context = context
}
@TagBuilder
func render(_ req: Vapor.Request) -> Tag {
AdminIndexTemplate(.init(title: context.title)) {
Div {
Section {
P(context.icon)
H1(context.title)
P(context.message)
}
}
.id("dashboard")
.class("container")
}
.render(req)
}
}
现在新建一个AdminFrontendController
来为CMS渲染仪表盘页面。
/// FILE: Sources/App/Modules/Admin/Controllers/AdminFrontendController.swift
import Vapor
struct AdminFrontendController {
func dashboardView(req: Request) throws -> Response {
let user = try req.auth.require(AuthenticatedUser.self)
let template = AdminDashboardTemplate(context: .init(icon: "👋", title: "Dashboard", message: "Hello \(user.email),welcome to the CMS."))
return req.templates.renderHtml(template)
}
}
通过创建一个新的 AdminRouter 对象连接这个管理控制器。 如果你还记得我们已经为所有路由启用了会话身份验证器中间件,那么如果存在有效会话,用户将自动进行身份验证。
我们可以在 Authenticatable 类型上使用 redirectMiddleware 函数,它将返回一个中间件,该中间件将每个未经身份验证的流量重定向到指定路径。
/// FILE: Sources/App/Modules/Admin/AdminRouter.swift
import Vapor
struct AdminRouter: RouteCollection {
let controller = AdminFrontendController()
func boot(routes: Vapor.RoutesBuilder) throws {
routes
.grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
.get("admin", use: controller.dashboardView)
}
}
正如之前提到的,admin视图将只对经过身份验证的用户可用,这样我们就可以保护我们的admin路由免受未经授权的公共访问
该中间件检查 req.auth 存储中是否存在现有的 AuthenticatedUser 对象,如果存在,则 Vapor 将像往常一样调用请求处理程序,否则它将执行 HTTP 重定向到提供的路径。 你还可以通过在请求处理程序中调用 try req.auth.require(AuthenticatedUser.self) 函数来保护端点,但使用中间件更优雅一些
为了完成这个模块,我们应该在 Admin 文件夹中创建一个新的 AdminModule 结构,并使用该模块启动管理路由器实例
/// FILE: Sources/App/Modules/Admin/AdminModule.swift
import Vapor
struct AdminModule: ModuleInterface {
let router = AdminRouter()
func boot(_ app: Application) throws {
try router.boot(routes: app.routes)
}
}
现在我们回到config文件,注册这个新模块来使用。
/// FILE: Sources/App/configure.swift
public func configure(_ app: Application) throws {
//...
/// setup modules
let modules: [ModuleInterface] = [
WebModule(),
BlogModule(),
UserModule(),
AdminModule()
]
//...
}
运行应用程序,使用默认用户帐户登录,然后单击管理菜单。 现在我们有了 CMS 的基本框架。 这些步骤现在应该已经很熟悉了,最后我们准备好继续构建一些真正的内容管理界面。
列表
我们将为博客模块创建一个新的管理列表组件,这样我们就可以为所有现有的博客文章创建一个很好的列表。 像往常一样,我们从帖子的context开始
/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostListContext.swift
struct AdminBlogPostListContext {
let title: String
let list: [Blog.Post.List]
}
接着是AdminBlogPostListTemplate
/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminBlogPostListTemplate.swift
import Vapor
import SwiftHtml
struct AdminBlogPostListTemplate: TemplateRepresentable {
var context: AdminBlogPostListContext
init(context: AdminBlogPostListContext) {
self.context = context
}
@TagBuilder
func render(_ req: Vapor.Request) -> Tag {
AdminIndexTemplate(.init(title: context.title)) {
Div {
Section {
H1(context.title)
}
.class("lead")
Table {
Thead {
Tr {
Th("Image")
Th("Title")
Th("Preview")
}
}
Tbody {
for item in context.list {
Tr {
Td {
Img(src: item.image, alt: item.title, workDicIfNeed: "assets")
}
Td(item.title)
Td {
A("Preview")
.href("/" + item.slug + "/")
}
}
}
}
}
}
.id("list")
}
.render(req)
}
}
在这个模板中,我们简单地使用context列表数组来呈现基于博客文章列表对象的表格。 我们可以简单地显示帖子的图像和标题以及转到帖子页面的预览 URL。 我们可以使用内置的 SwiftHtml 标签来呈现我们的 HTML 表格。
接下来,在我们继续使用控制器之前,我们应该清理一些自创建 BlogPostModel
类型以来未触及的代码。由于我们不想直接使用数据库模型,因为它可能包含敏感数据,所以我们需要一个映射函数的转换model的地方。 创建一个 BlogPostApiController
并将映射列表函数放在那里是个好主意,它可以将博客文章模型转换为公共 Blog.Post.List
/// FILE: Sources/App/Modules/Blog/Controllers/BlogPostApiController.swift
import Vapor
struct BlogPostApiController {
func mapList(_ model: BlogPostModel) -> Blog.Post.List {
.init(id: model.id!,
title: model.title,
slug: model.slug,
image: model.imageKey,
excerpt: model.excerpt,
date: model.date)
}
}
现在我们可以创建一个新的控制器来负责呈现帖子相关的管理视图。 让我们创建一个带有 listView 函数的新 AdminBlogPostController
并查询所有可用的实体并使用新的 API controller映射它们,最后我们渲染模板
/// FILE: Sources/App/Modules/Admin/Controllers/AdminBlogPostController.swift
import Vapor
struct AdminBlogPostController {
func listView(_ req: Request) async throws -> Response {
let posts = try await BlogPostModel.query(on: req.db).all()
let api = BlogPostApiController()
let list = posts.map { api.mapList($0) }
let template = AdminBlogPostListTemplate(context: .init(title: "Posts", list: list))
return req.templates.renderHtml(template)
}
}
在 BlogFrontendController
内部,我们还可以用新的 API 方法替换旧的地图逻辑,这样我们的代码库中就不会有那么多重复的代码
import Vapor
import Fluent
struct BlogFrontendController {
func blogView(req: Request) async throws -> Response {
let posts = try await BlogPostModel
.query(on: req.db)
.sort(\.$date, .descending)
.all()
let api = BlogPostApiController()
let list = posts.map{ api.mapList($0)}
let ctx = BlogPostsContext(icon: "🔥 ", title: "Blog", message: "Hot news and stories about everything.", posts: list)
return req.templates.renderHtml(BlogPostsTemplate(ctx))
}
//...
}
在路由器中,我们需要再次使用 redirectMiddleware 方法,因为我们不想让访客访问博客文章列表管理页面。 我们还可以在路由上使用 grouped 方法,通过路径组件数组对路由进行分组。
/// FILE: Sources/App/Modules/Admin/AdminRouter.swift
import Vapor
struct AdminRouter: RouteCollection {
let controller = AdminFrontendController()
let blogPostController = AdminBlogPostController()
func boot(routes: Vapor.RoutesBuilder) throws {
routes
.grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
.get("admin", use: controller.dashboardView)
routes
.grouped(AuthenticatedUser.redirectMiddleware(path: "/"))
.grouped("admin", "blog")
.get("posts", use: blogPostController.listView)
}
}
现在,在admin dashboard template中,我们将添加一个新链接来访问博客文章
/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminDashboardTemplate.swift
import Vapor
import SwiftHtml
struct AdminDashboardTemplate: TemplateRepresentable {
var context: AdminDashboardContext
init(context: AdminDashboardContext) {
self.context = context
}
@TagBuilder
func render(_ req: Vapor.Request) -> Tag {
AdminIndexTemplate(.init(title: context.title)) {
Div {
Section {
P(context.icon)
H1(context.title)
P(context.message)
}
Nav {
H2("Blog")
Ul {
Li {
A("Posts")
.href("/admin/blog/posts/")
}
}
}
}
.id("dashboard")
.class("container")
}
.render(req)
}
}
我们将插入一些额外的 CSS 来使我们的图像在表格视图中变小一点。 将以下代码片段粘贴到 admin.css 文件中。
/* FILE: Public/css/admin.css */
tr {
grid-template-columns: 4rem 1fr 4rem;
column-gap: 1rem;
}
td img {
display: block;
}
th {
text-align: left;
}
这就是你可以将新组件集成到管理界面的方式。 运行应用程序并检查新创建的列表。 它应该会向您显示所有可用的博客文章
详情
帖子的详细视图会与之前的流程非常相似,不过我们在构建此功能时还将学习一些新东西。 首先,我们从详情context开始
/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostDetailContext.swift
struct AdminBlogPostDetailContext {
let title: String
let detail: Blog.Post.Detail
}
我们将使用相应模板中的 **Dl、Dt、Dd **元素来构建我们的详细视图
/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminBlogPostDetailTemplate.swift
import Vapor
import SwiftHtml
struct AdminBlogPostDetailTemplate: TemplateRepresentable {
var context: AdminBlogPostDetailContext
var dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .short
return formatter
}()
init(context: AdminBlogPostDetailContext) {
self.context = context
}
func render(_ req: Vapor.Request) -> Tag {
AdminIndexTemplate(.init(title: context.title)) {
Div {
Section {
H1(context.title)
}
.class("lead")
Dl {
Dt("Image")
Dd {
Img(src: context.detail.image, alt: context.detail.title, workDicIfNeed: "assets")
}
Dt("Title")
Dd(context.detail.title)
Dt("Excerpt")
Dd(context.detail.excerpt)
Dt("Date")
Dd(dateFormatter.string(from: context.detail.date))
Dt("Content")
Dd(context.detail.content)
}
}
.id("detail")
.class("container")
}
.render(req)
}
}
我们还应该使用新的 mapDetail 函数扩展 BlogPostApiController
,这将使我们能够将获取的模型映射到详细信息对象中。 稍后我们将使用这些类型的 API 控制器通过 API 层返回 JSON 响应
/// FILE: Sources/App/Modules/Blog/Controllers/BlogPostApiController.swift
import Vapor
struct BlogPostApiController {
func mapList(_ model: BlogPostModel) -> Blog.Post.List {
.init(id: model.id!,
title: model.title,
slug: model.slug,
image: model.imageKey,
excerpt: model.excerpt,
date: model.date)
}
func mapDetail(_ model: BlogPostModel) -> Blog.Post.Detail {
.init(id: model.id!,
title: model.title,
slug: model.slug,
image: model.imageKey,
excerpt: model.excerpt,
date: model.date,
category: .init(id: model.category.id!,
title: model.category.title),
content: model.content)
}
}
在 AdminBlogPostController
中,我们必须以某种方式找到当前的博客文章模型。 由于我们在注册路由处理程序时将在路径中使用 postId 参数,因此我们可以通过调用 req.parameters.get() 方法以字符串形式返回 id 值。
将字符串转换为 UUID 对象并使用它来查询我们的数据库模型真的很容易。
detailView 方法现在非常简单,我们只需找到模型,将模型转换为适当的详情对象并使用context渲染模板。
/// FILE: Sources/App/Modules/Admin/Controllers/AdminBlogPostController.swift
import Vapor
import Fluent
struct AdminBlogPostController {
func find(_ req: Request) async throws -> BlogPostModel {
guard let id = req.parameters.get("postId"),
let uuid = UUID(uuidString: id),
let post = try await BlogPostModel.query(on: req.db).filter(\.$id == uuid).with(\.$category).first() else {
throw Abort(.notFound)
}
return post
}
func listView(_ req: Request) async throws -> Response {
let posts = try await BlogPostModel.query(on: req.db).all()
let api = BlogPostApiController()
let list = posts.map { api.mapList($0) }
let template = AdminBlogPostListTemplate(context: .init(title: "Posts", list: list))
return req.templates.renderHtml(template)
}
func detailView(_ req: Request) async throws -> Response {
let post = try await find(req)
let detail = BlogPostApiController().mapDetail(post)
let template = AdminBlogPostDetailTemplate(context: .init(title: "Post details", detail: detail))
return req.templates.renderHtml(template)
}
}
我们可以在博客前端控制器中再次重构一件事。 在 postView 函数中获取模型后,我们可以使用相同的 API 对象来映射博客文章的详细信息
import Vapor
import Fluent
struct BlogFrontendController {
func blogView(req: Request) async throws -> Response {
let posts = try await BlogPostModel
.query(on: req.db)
.sort(\.$date, .descending)
.all()
let api = BlogPostApiController()
let list = posts.map{ api.mapList($0)}
let ctx = BlogPostsContext(icon: "🔥 ", title: "Blog", message: "Hot news and stories about everything.", posts: list)
return req.templates.renderHtml(BlogPostsTemplate(ctx))
}
func postView(req: Request) async throws -> Response {
let slug = req.url.path.trimmingCharacters(in: .init(charactersIn: "/"))
guard let post = try await BlogPostModel
.query(on: req.db)
.filter(\.$slug == slug)
.with(\.$category)
.first() else {
return req.redirect(to: "/")
}
let api = BlogPostApiController()
let ctx = BlogPostContext(post: api.mapDetail(post))
return req.templates.renderHtml(BlogPostTemplate(ctx))
}
}
现在是时候注册我们的路由处理程序了。 我们可以将 posts 端点存储在一个变量中,这样以后我们就可以重用它,而不必重新对所有内容进行分组
当注册一个路由参数时,你应该在它前面加上一个“:”,这样 Vapor 就会知道它不是一个静态路径组件,而是一个动态路由参数。 你可以稍后通过引用其名称来查询此路由参数
/// FILE: Sources/App/Modules/Admin/AdminRouter.swift
import Vapor
struct AdminRouter: RouteCollection {
let controller = AdminFrontendController()
let blogPostController = AdminBlogPostController()
func boot(routes: Vapor.RoutesBuilder) throws {
routes
.grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
.get("admin", use: controller.dashboardView)
let posts = routes.grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin","blog","posts")
posts.get(use: blogPostController.listView)
posts.get(":postId", use: blogPostController.detailView)
}
}
最后我们返回管理列表模版,向标题字段添加一个超链接,这样当用户单击它时,它将打开帖子详细信息页面
/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminBlogPostListTemplate.swift
import Vapor
import SwiftHtml
struct AdminBlogPostListTemplate: TemplateRepresentable {
var context: AdminBlogPostListContext
init(context: AdminBlogPostListContext) {
self.context = context
}
@TagBuilder
func render(_ req: Vapor.Request) -> Tag {
AdminIndexTemplate(.init(title: context.title)) {
Div {
Section {
H1(context.title)
}
.class("lead")
Table {
Thead {
Tr {
Th("Image")
Th("Title")
Th("Preview")
}
}
Tbody {
for item in context.list {
Tr {
Td {
Img(src: item.image, alt: item.title, workDicIfNeed: "assets")
}
Td {
A(item.title)
.href("/admin/blog/posts/" + item.id.uuidString + "/")
}
Td {
A("Preview")
.href("/" + item.slug + "/")
}
}
}
}
}
}
.id("list")
}
.render(req)
}
}
这就是我们呈现帖子详细信息的方式,现在如果你构建并运行应用程序,你应该能够导航到详细信息页面并查看有关博客帖子的更多信息
新建内容
下一步是创建新博客文章的功能。 为此,我们将使用我们的抽象表单组件和表单字段构建一个编辑表单
BlogPostEditForm
是一个类对象 ,init 方法使用 BlogPostModel 实例,我们将其存储为unowned pointers, 我们可以通过 model.$id.value 属性包装器检查 Fluent 模型是否已经持久化,因此我们通过这个来设置正确的url
因为这次我们使用引用类型,所以我们必须小心使用强引用,所以这就是为什么我们将本地引用的对象作为block的unowned pointers传递。 这有点不方便,但我们稍后也会修复它
/// FILE: Sources/App/Modules/Amdin/Forms/BlogPostEditForm.swift
import Vapor
final class BlogPostEditForm: AbstractForm {
unowned var model: BlogPostModel
var dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .short
return formatter
}()
public init(model: BlogPostModel) {
var url = "/admin/blog/posts/"
if let id = model.$id.value {
url = url + id.uuidString + "/update/"
} else {
url = url + "create/"
}
self.model = model
super.init(action: .init(method: .post, url: url, enctype: .multipart))
self.fields = createFields()
}
@FormComponentBuilder
func createFields() -> [FormComponent] {
ImageField("image", path: "blog/post")
.read { [unowned self] in
$1.output.context.previewUrl = model.imageKey
($1 as! ImageField).imageKey = model.imageKey
}
.write { [unowned self] in
model.imageKey = ($1 as! ImageField).imageKey ?? ""
}
InputField("slug")
.config {
$0.output.context.label.required = true
}
.validators {
FormFieldValidator.required($1)
}
.read { [unowned self] in
$1.output.context.value = model.slug
}
.write { [unowned self] in
model.slug = $1.input
}
InputField("title")
.config {
$0.output.context.label.required = true
}
.validators {
FormFieldValidator.required($1)
}
.read { [unowned self] in
$1.output.context.value = model.title
}
.write { [unowned self] in
model.title = $1.input
}
InputField("date")
.config {
$0.output.context.label.required = true
$0.output.context.value = dateFormatter.string(from: Date())
}
.validators {
FormFieldValidator.required($1)
}
.read { [unowned self] in $1.output.context.value = dateFormatter.string(from: model.date) }
.write { [unowned self] in
model.date = dateFormatter.date(from: $1.input) ?? Date()
}
TextareaField("excerpt")
.read { [unowned self] in
$1.output.context.value = model.excerpt
}
.write { [unowned self] in
model.excerpt = $1.input
}
TextareaField("content")
.read { [unowned self] in $1.output.context.value = model.content }
.write { [unowned self] in model.content = $1.input }
SelectField("category")
.load { req, field in
let categories = try await BlogCategoryModel.query(on: req.db).all()
field.output.context.options = categories.map { OptionContext(key: $0.id!.uuidString, label: $0.title) }
}
.read { [unowned self] req, field in
field.output.context.value = model.$category.id.uuidString
}
.write { [unowned self] req, field in
if let uuid = UUID(uuidString: field.input), let category = try await BlogCategoryModel.find(uuid, on: req.db) {
model.$category.id = category.id!
}
}
}
}
select category 字段比较特殊,在 load 方法中我们从数据库中获取可用的类别,并根据结果设置选项值。 写入函数会将选定的类别 ID 字符串转换为 UUID 类型,我们检查是否存在具有该标识符的现有类别
下一步是为我们的编辑表单创建一个模板文件。 我们将为创建和更新操作重用此编辑表单。 让我们为视图创建一个 AdminBlogPostEditContext
/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostEditContext.swift
struct AdminBlogPostEditContext {
let title: String
let form: TemplateRepresentable
}
BlogPostAdminEditTemplate
将非常简单,我们只需按BlogPostAdminEditContext
的模版呈现编辑表单。
/// FILE: Sources/App/Modules/Admin/Templates/Html/AdminBlogPostEditTemplate.swift
import Vapor
import SwiftHtml
struct AdminBlogPostEditTemplate: TemplateRepresentable {
var context: AdminBlogPostEditContext
init(_ context: AdminBlogPostEditContext) {
self.context = context
}
@TagBuilder
func render(_ req: Vapor.Request) -> Tag {
AdminIndexTemplate(.init(title: context.title)) {
Div {
Section {
H1(context.title)
}
.class("lead")
context.form.render(req)
}
.id("edit")
.class("container")
}
.render(req)
}
}
回到AdminBlogPostController
,我们能够使用BlogPostEditForm
来创建新的博客文章
在 createView 中,我们只初始化一个空模型和一个使用该模型的表单。 我们只是调用load函数,以便表单可以加载categorys,这就是我们准备呈现的内容
createAction 方法会有点复杂,首先我们需要一个新模型和一个表单, 之后我们调用load方法,然后我们处理输入字段。 我们还需要验证输入,如果出现问题,我们可以呈现包含错误的编辑表单。 否则我们继续工作流并调用 write 方法,这将确保我们的模型填充了经过验证的输入
最后我们调用 model.create(on:) 方法,这会将实体保存到数据库中,我们还在表单上调用save函数,因此如果有额外的保存操作也会执行。 作为最后一步,我们将用户重定向到详细信息页面
/// FILE: Sources/App/Modules/Blog/Controllers/AdminBlogPostController.swift
import Vapor
import Fluent
struct AdminBlogPostController {
//...
private func renderEditForm(_ req: Request, _ title: String, _ form: BlogPostEditForm) -> Response {
let template = AdminBlogPostEditTemplate(.init(title: title, form:
form.render(req: req)))
return req.templates.renderHtml(template)
}
func createView(_ req: Request) async throws -> Response {
let model = BlogPostModel()
let form = BlogPostEditForm(model: model)
try await form.load(req: req)
return renderEditForm(req, "Create post", form)
}
func createAction(_ req: Request) async throws -> Response {
let model = BlogPostModel()
let form = BlogPostEditForm(model: model)
try await form.load(req: req)
try await form.process(req: req)
let isValid = try await form.validate(req: req)
guard isValid else {
return renderEditForm(req, "Create post", form)
}
try await form.write(req: req)
try await model.create(on: req.db)
try await form.save(req: req)
return req.redirect(to: "/admin/blog/posts/\(model.id!.uuidString)/")
}
}
当然,我们必须注册两个新的create路由才能使控制器生效
/// FILE: Sources/App/Modules/Admin/AdminRouter.swift
import Vapor
struct AdminRouter: RouteCollection {
let controller = AdminFrontendController()
let blogPostController = AdminBlogPostController()
func boot(routes: Vapor.RoutesBuilder) throws {
routes
.grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
.get("admin", use: controller.dashboardView)
let posts = routes.grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin","blog","posts")
posts.get(use: blogPostController.listView)
posts.get(":postId", use: blogPostController.detailView)
posts.get("create", use: blogPostController.createView)
posts.post("create", use: blogPostController.createAction)
}
}
现在你可以通过输入 /admin/blog/posts/create/ URL 来尝试我们刚刚创建的内容
更新内容
在前面,我们预留了更新内容的url分支,现在我们可以通过向AdminBlogPostController
添加一些非常简单的小改动来复用 BlogPostEditForm
来支持这两个功能
/// FILE: Sources/App/Modules/Blog/Controllers/AdminBlogPostController.swift
import Vapor
import Fluent
struct AdminBlogPostController {
//...
func updateView(_ req: Request) async throws -> Response {
let model = try await find(req)
let form = BlogPostEditForm(model: model)
try await form.load(req: req)
try await form.read(req: req)
return renderEditForm(req, "Update post", form)
}
func updateAction(_ req: Request) async throws -> Response {
let model = try await find(req)
let form = BlogPostEditForm(model: model)
try await form.load(req: req)
try await form.process(req: req)
let isValid = try await form.validate(req: req)
guard isValid else {
return renderEditForm(req, "Update post", form)
}
try await form.write(req: req)
try await model.update(on: req.db)
try await form.save(req: req)
return req.redirect(to: "/admin/blog/posts/\(model.id!.uuidString)/update/")
}
}
我们将使用 URL 参数来查找帖子,幸运的是我们之前实现了查找功能。 查找模型到后,我们加载表单并且把模型的数值展示在表单中
updateAction中间流程会跟之前的createAction流程很像,最大的区别是在写入操作完成后调用Model的update方法去更新对应的数据库数据
最后还是为这两个方法注册在路由中。
/// FILE: Sources/App/Modules/Admin/AdminRouter.swift
import Vapor
struct AdminRouter: RouteCollection {
let controller = AdminFrontendController()
let blogPostController = AdminBlogPostController()
func boot(routes: Vapor.RoutesBuilder) throws {
routes
.grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
.get("admin", use: controller.dashboardView)
let posts = routes.grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin","blog","posts")
posts.get(use: blogPostController.listView)
let postId = posts.grouped(":postId")
postId.get(use: blogPostController.detailView)
posts.get("create", use: blogPostController.createView)
posts.post("create", use: blogPostController.createAction)
postId.get("update", use: blogPostController.updateView)
postId.post("update", use: blogPostController.updateAction)
}
}
我们可以通过** :postId** 参数对帖子进行分组,并在注册详细信息和更新处理程序时将其用作基本路由。 现在就随意尝试这个新的编辑功能
删除内容
我们将在本篇中实现的最后一件功能是基本的删除功能。 在我们实际从数据库中删除记录之前,我们将使用一个带有删除表单的简单模板来显示确认界面
AdminBlogPostDeleteContext
将具有名称和类型属性,这样我们就可以告诉用户有关实体的更多信息。
/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostDeleteContext.swift
struct AdminBlogPostDeleteContext {
let title: String
let name: String
let type: String
}
基于AdminBlogPostDeleteContext
,我们可以通过配置一个带有删除 URL 发布操作的简单表单来呈现我们的模板。 它只会包含一个提交按钮和一个取消删除操作的链接
/// FILE: Sources/App/Modules/Admin/Templates/Contexts/AdminBlogPostDeleteTemplate.swift
import Vapor
import SwiftHtml
struct AdminBlogPostDeleteTemplate: TemplateRepresentable {
var context: AdminBlogPostDeleteContext
init(context: AdminBlogPostDeleteContext) {
self.context = context
}
@TagBuilder
func render(_ req: Vapor.Request) -> Tag {
AdminIndexTemplate(.init(title: context.title)) {
Div {
Span("🗑 ")
.class("icon")
H1(context.title)
P("You are about to permanently delete the<br>`\(context.name)`\(context.type).")
Form {
Input()
.type(.submit)
.class(["button", "destructive"])
.style("display: inline")
.value("Delete")
A("Cancel")
.href("/admin/blog/posts/")
.class(["button", "cancel"])
}
.method(.post)
.id("delete-form")
}
.class(["lead", "container", "center"])
}
.render(req)
}
}
接着回到AdminBlogPostController
, 我们添加上删除页面的展示方法和删除事件的处理。
/// FILE: Sources/App/Modules/Admin/Controllers/AdminBlogPostController.swift
import Vapor
import Fluent
struct AdminBlogPostController {
//...
func deleteView(_ req: Request) async throws -> Response {
let model = try await find(req)
let template = AdminBlogPostDeleteTemplate(context: .init(title: "Delete post",name: model.title, type: "post"))
return req.templates.renderHtml(template)
}
func deleteAction(_ req: Request) async throws -> Response {
let model = try await find(req)
try await req.fs.delete(key: model.imageKey)
try await model.delete(on: req.db)
return req.redirect(to: "/admin/blog/posts/")
}
}
最后还是需要把新加的两个方法注册在路由
/// FILE: Sources/App/Modules/Admin/AdminRouter.swift
import Vapor
struct AdminRouter: RouteCollection {
let controller = AdminFrontendController()
let blogPostController = AdminBlogPostController()
func boot(routes: Vapor.RoutesBuilder) throws {
routes
.grouped(AuthenticatedUser.redirectMiddleware(path: "/sign-in/"))
.get("admin", use: controller.dashboardView)
let posts = routes.grouped(AuthenticatedUser.redirectMiddleware(path: "/")).grouped("admin","blog","posts")
posts.get(use: blogPostController.listView)
let postId = posts.grouped(":postId")
postId.get(use: blogPostController.detailView)
posts.get("create", use: blogPostController.createView)
posts.post("create", use: blogPostController.createAction)
postId.get("update", use: blogPostController.updateView)
postId.post("update", use: blogPostController.updateAction)
postId.get("delete", use: blogPostController.deleteView)
postId.post("delete", use: blogPostController.deleteAction)
}
}
就是这样,如果你访问详细信息页面并将delete到路由的末尾,你就会看到一个能够删除博客文章的确认页面
总结
本篇是关于使用 Vapor 构建一个支持基于 Web 的 CRUD 的内容管理系统。 如你所见,管理模块围绕这些功能提供了一个很好的框架。 我们还学习了如何为create和update endpoints创建可重用的表单组件和字段。 最后,我们学习了如何从持久存储中删除记录