美文网首页Vapor 学习笔记
Vapor 框架学习记录(8)内容管理系统

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

作者: lqbk | 来源:发表于2022-12-16 14:40 被阅读0次

    上一篇我们已经准备好了需要的各种表单字段,现在,我们将构建一个带有管理界面的内容管理系统。 我们将为管理页面创建一个独立的模块,它将与 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创建可重用的表单组件和字段。 最后,我们学习了如何从持久存储中删除记录

    相关文章

      网友评论

        本文标题:Vapor 框架学习记录(8)内容管理系统

        本文链接:https://www.haomeiwen.com/subject/rrsfqdtx.html