diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index a1e71d1..9464e17 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -150,8 +150,8 @@ ## J. 站点单页 -- [ ] 公开:`/page/:slug` 列表入口(nav/footer) -- [ ] 管理员 CRUD;发布开关;排序;nav/footer 展示开关 +- [x] 公开:`/page/:slug` 列表入口(nav/footer)— SSR +- [x] 管理员 CRUD;发布开关;排序;nav/footer 展示开关 — SSR `/admin/pages` --- diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index fa2a17a..22bdc4e 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -37,7 +37,7 @@ | `/links` | 友链 | 已迁 | | `/messages` | 私信/通知会话列表 | 已迁 | | `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 | -| `/page/:slug` | 站点单页 | 未注册 | +| `/page/:slug` | 站点单页 | 已迁 | | `*` | 404 / pending | 已迁 | ### 1.3 后台(Admin SSR,表单 + CSRF,不挂管理 JSON `/api`) @@ -50,9 +50,10 @@ | `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 | | `/admin/settings` | 品牌 + 基础限流 + 敏感词 + SMTP | 已迁 | | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | +| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 | -未迁(原 SPA):reports / users / badges / media / pages / 完整 Limits 等。 +未迁(原 SPA):reports / users / badges / media / 完整 Limits 等。 --- @@ -184,7 +185,7 @@ |----|----------| | Dashboard | 看计数与待办;点进对应列表 | | Boards | 拖拽或数字排序;图标/色板选择;增删改 | -| Pages | 列表发布开关;进编辑器写正文;nav/footer 勾选 | +| Pages | 列表发布开关;编辑正文(HTML textarea);nav/footer 勾选 — SSR `/admin/pages` | | Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(完整侧栏组件编辑 / 复检按钮未迁) | | Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 | | Comments | 审核;修订查看;回收站 | diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index 678136e..73687b4 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -96,4 +96,6 @@ Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_ 友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、nav/footer/aside 入口开关)。 -Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`。 \ No newline at end of file +站点单页:公开 `/page/:slug`;nav/footer 按 `show_in_nav` / `show_in_footer` 注入;Admin `/admin/pages`(CRUD、发布、排序、展示开关)。 + +Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`。 \ No newline at end of file diff --git a/modules/webrender/render.go b/modules/webrender/render.go index 2616dbd..f4b3385 100644 --- a/modules/webrender/render.go +++ b/modules/webrender/render.go @@ -73,6 +73,7 @@ var parseGlobs = []string{ "messages/*.tmpl", "links/*.tmpl", "boards/*.tmpl", + "page/*.tmpl", } // Load 解析全部模板(进程内一次) diff --git a/public/assets/site.css b/public/assets/site.css index bcf4f0e..1f6f5f1 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -826,3 +826,16 @@ points-only[data-locked="false"] { overflow: hidden; } +.j13-page { + max-width: 800px; + margin: 0 auto; + padding: 1rem 1rem 2.5rem; +} +.j13-page__article h1 { + margin: 0 0 1rem; + font-size: 1.5rem; +} +.j13-page__body { + line-height: 1.65; +} + diff --git a/routers/setup.go b/routers/setup.go index e5165dc..861c2e6 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -76,6 +76,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { } }() + sitePageSvc := services.NewSitePageService(filter) + h := &api.Handlers{ Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc, Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc, @@ -84,7 +86,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, OIDC: oidcSvc, Gitea: giteaSvc, Points: services.NewPointsService(), Badge: services.NewBadgeService(), - SitePage: services.NewSitePageService(filter), + SitePage: sitePageSvc, FriendLinkApply: friendLinkApplySvc, } authMW := auth.NewAuthMiddleware(authSvc) @@ -103,6 +105,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Points: services.NewPointsService(), FriendLink: friendLinkApplySvc, Mail: mailSvc, + SitePage: sitePageSvc, }, authMW) r.GET("/media/thumb/*filepath", h.ServeImageThumb) diff --git a/routers/web/admin_pages.go b/routers/web/admin_pages.go new file mode 100644 index 0000000..3f05dac --- /dev/null +++ b/routers/web/admin_pages.go @@ -0,0 +1,198 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +type adminPageRow struct { + ID uint + Title string + Slug string + Published bool + SortOrder int + ShowInNav bool + ShowInFooter bool +} + +type adminPageForm struct { + ID uint + Title string + Slug string + Content string + Published bool + SortOrder int + ShowInNav bool + ShowInFooter bool + IsEdit bool +} + +type adminPagesData struct { + AdminChrome + Pages []adminPageRow + Form adminPageForm +} + +// AdminPagesGet 站点单页列表 +func (d Deps) AdminPagesGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderAdminPages(ctx, "", adminPageForm{}) +} + +func (d Deps) renderAdminPages(ctx *webctx.Context, errMsg string, form adminPageForm) { + chrome := d.adminChrome(ctx, "站点单页", "pages") + chrome.Error = errMsg + data := adminPagesData{AdminChrome: chrome, Form: form} + if d.SitePage != nil { + list, _ := d.SitePage.ListAll() + data.Pages = make([]adminPageRow, 0, len(list)) + for _, p := range list { + data.Pages = append(data.Pages, adminPageRow{ + ID: p.ID, Title: p.Title, Slug: p.Slug, Published: p.Published, + SortOrder: p.SortOrder, ShowInNav: p.ShowInNav, ShowInFooter: p.ShowInFooter, + }) + } + } + ctx.HTML(http.StatusOK, "admin/pages", data) +} + +// AdminPageCreate 新建单页 +func (d Deps) AdminPageCreate(c *gin.Context) { + ctx := d.ctx(c) + form := adminPageFormFrom(c) + if !ctx.CheckCSRF() { + d.renderAdminPages(ctx, "无效请求,请重试", form) + return + } + if d.SitePage == nil { + d.renderAdminPages(ctx, "单页服务未就绪", form) + return + } + if _, err := d.SitePage.Create(adminPageInputFrom(form)); err != nil { + d.renderAdminPages(ctx, err.Error(), form) + return + } + ctx.SetFlash("单页已创建") + ctx.Redirect("/admin/pages") +} + +// AdminPageEditGet 编辑表单 +func (d Deps) AdminPageEditGet(c *gin.Context) { + ctx := d.ctx(c) + if d.SitePage == nil { + d.renderAdminPages(ctx, "单页服务未就绪", adminPageForm{}) + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + page, err := d.SitePage.GetByID(uint(id)) + if err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/pages") + return + } + d.renderAdminPages(ctx, "", adminPageForm{ + ID: page.ID, Title: page.Title, Slug: page.Slug, Content: page.Content, + Published: page.Published, SortOrder: page.SortOrder, + ShowInNav: page.ShowInNav, ShowInFooter: page.ShowInFooter, IsEdit: true, + }) +} + +// AdminPageUpdate 更新单页 +func (d Deps) AdminPageUpdate(c *gin.Context) { + ctx := d.ctx(c) + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + form := adminPageFormFrom(c) + form.ID = uint(id) + form.IsEdit = true + if !ctx.CheckCSRF() { + d.renderAdminPages(ctx, "无效请求,请重试", form) + return + } + if d.SitePage == nil { + d.renderAdminPages(ctx, "单页服务未就绪", form) + return + } + if err := d.SitePage.Update(uint(id), adminPageInputFrom(form)); err != nil { + d.renderAdminPages(ctx, err.Error(), form) + return + } + ctx.SetFlash("单页已保存") + ctx.Redirect("/admin/pages") +} + +// AdminPageDelete 删除 +func (d Deps) AdminPageDelete(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/pages") + return + } + if d.SitePage == nil { + ctx.SetFlash("单页服务未就绪") + ctx.Redirect("/admin/pages") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := d.SitePage.Delete(uint(id)); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/pages") + return + } + ctx.SetFlash("单页已删除") + ctx.Redirect("/admin/pages") +} + +// AdminPagePublishPost 快捷发布/下架 +func (d Deps) AdminPagePublishPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect("/admin/pages") + return + } + if d.SitePage == nil { + ctx.SetFlash("单页服务未就绪") + ctx.Redirect("/admin/pages") + return + } + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + published := c.PostForm("published") == "1" || c.PostForm("published") == "on" + if err := d.SitePage.SetPublished(uint(id), published); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect("/admin/pages") + return + } + if published { + ctx.SetFlash("已发布") + } else { + ctx.SetFlash("已下架") + } + ctx.Redirect("/admin/pages") +} + +func adminPageFormFrom(c *gin.Context) adminPageForm { + sort, _ := strconv.Atoi(c.PostForm("sort_order")) + return adminPageForm{ + Title: strings.TrimSpace(c.PostForm("title")), + Slug: strings.TrimSpace(c.PostForm("slug")), + Content: c.PostForm("content"), + Published: c.PostForm("published") == "1" || c.PostForm("published") == "on", + SortOrder: sort, + ShowInNav: c.PostForm("show_in_nav") == "1" || c.PostForm("show_in_nav") == "on", + ShowInFooter: c.PostForm("show_in_footer") == "1" || c.PostForm("show_in_footer") == "on", + } +} + +func adminPageInputFrom(form adminPageForm) services.SitePageInput { + return services.SitePageInput{ + Title: form.Title, Slug: form.Slug, Content: form.Content, + Published: form.Published, SortOrder: form.SortOrder, + ShowInNav: form.ShowInNav, ShowInFooter: form.ShowInFooter, + } +} diff --git a/routers/web/chrome.go b/routers/web/chrome.go index cf8a0fb..90e3e12 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -26,6 +26,13 @@ type Deps struct { Points *services.PointsService FriendLink *services.FriendLinkApplyService Mail *services.MailService + SitePage *services.SitePageService +} + +// SitePageLink 导航/页脚站点单页链接 +type SitePageLink struct { + Title string + Slug string } // BoardView 侧栏 @@ -110,6 +117,8 @@ type PageChrome struct { ViewerPoints int // 登录用户当前积分;未登录为 0 ShowFriendLinksNav bool ShowFriendLinksFooter bool + NavPages []SitePageLink + FooterPages []SitePageLink RightAside RightAsideData } @@ -149,6 +158,7 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome if ctx.C != nil && ctx.C.Request != nil && ctx.C.Request.URL != nil { path = ctx.C.Request.URL.Path } + navPages, footerPages := d.sitePageLinks() return PageChrome{ Title: title, Description: desc, @@ -167,10 +177,33 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome ViewerPoints: viewerPoints, ShowFriendLinksNav: d.Settings.NavShowFriendLinks(), ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(), + NavPages: navPages, + FooterPages: footerPages, RightAside: d.loadRightAside(ctx, brand), } } +func (d Deps) sitePageLinks() (nav, footer []SitePageLink) { + nav, footer = []SitePageLink{}, []SitePageLink{} + if d.SitePage == nil { + return + } + list, err := d.SitePage.ListPublished() + if err != nil { + return + } + for _, p := range list { + link := SitePageLink{Title: p.Title, Slug: p.Slug} + if p.ShowInNav { + nav = append(nav, link) + } + if p.ShowInFooter { + footer = append(footer, link) + } + } + return +} + const ( rightAsideHotLimit = 5 rightAsideTagLimit = 24 diff --git a/routers/web/home.go b/routers/web/home.go index 1f1503a..2621449 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -66,6 +66,12 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.POST("/friend-links/brand/delete", deps.AdminFriendLinksBrandDeletePost) admin.POST("/friend-links/applies/:id/approve", deps.AdminFriendLinkApprovePost) admin.POST("/friend-links/applies/:id/reject", deps.AdminFriendLinkRejectPost) + admin.GET("/pages", deps.AdminPagesGet) + admin.POST("/pages", deps.AdminPageCreate) + admin.GET("/pages/:id/edit", deps.AdminPageEditGet) + admin.POST("/pages/:id", deps.AdminPageUpdate) + admin.POST("/pages/:id/delete", deps.AdminPageDelete) + admin.POST("/pages/:id/publish", deps.AdminPagePublishPost) } g.GET("/user/:id", deps.UserPublic) @@ -87,6 +93,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { g.POST("/links/logo", authMW.RequireAuth(), deps.LinksLogoUpload) g.GET("/projects", deps.PendingPage) g.GET("/boards", deps.BoardsGet) + g.GET("/page/:slug", deps.SitePageGet) } // HomePageData Feed diff --git a/routers/web/page.go b/routers/web/page.go new file mode 100644 index 0000000..d674438 --- /dev/null +++ b/routers/web/page.go @@ -0,0 +1,44 @@ +package web + +import ( + "errors" + "html/template" + "net/http" + + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +type sitePageViewData struct { + PageChrome + PageTitle string + Slug string + BodyHTML template.HTML +} + +// SitePageGet 公开站点单页 +func (d Deps) SitePageGet(c *gin.Context) { + ctx := d.ctx(c) + if d.SitePage == nil { + d.render404(ctx) + return + } + slug := c.Param("slug") + page, err := d.SitePage.GetBySlug(slug, false) + if err != nil { + if errors.Is(err, services.ErrSitePageNotFound) { + d.render404(ctx) + return + } + c.String(http.StatusInternalServerError, "加载失败") + return + } + html := services.SanitizePostHTML(page.Content) + chrome := d.chrome(ctx, page.Title+" · "+d.Settings.SiteBranding().Name, "", "") + ctx.HTML(http.StatusOK, "page/view", sitePageViewData{ + PageChrome: chrome, + PageTitle: page.Title, + Slug: page.Slug, + BodyHTML: template.HTML(html), + }) +} diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl index 0099106..26c93e1 100644 --- a/templates/admin/nav.tmpl +++ b/templates/admin/nav.tmpl @@ -4,6 +4,7 @@ 板块 审核 友链 + 单页 设置 返回前台 diff --git a/templates/admin/pages.tmpl b/templates/admin/pages.tmpl new file mode 100644 index 0000000..7d3719d --- /dev/null +++ b/templates/admin/pages.tmpl @@ -0,0 +1,72 @@ +{{define "admin/pages"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

站点单页

+ {{template "admin/nav" .}} + {{template "base/alert" .}} + +
+

{{if .Form.IsEdit}}编辑:{{.Form.Title}}{{else}}新建单页{{end}}

+
+ + + + + + + + + + {{if .Form.IsEdit}}取消编辑{{end}} +
+
+ +
+

已有单页

+ {{if .Pages}} + + + + + + {{range .Pages}} + + + + + + + + + {{end}} + +
标题Slug排序状态入口操作
{{.Title}}{{.Slug}}{{if .Published}} · 预览{{end}}{{.SortOrder}}{{if .Published}}已发布{{else}}草稿{{end}} + {{if .ShowInNav}}导航 {{end}}{{if .ShowInFooter}}页脚{{end}} + {{if and (not .ShowInNav) (not .ShowInFooter)}}—{{end}} + + 编辑 +
+ + {{if .Published}} + + + {{else}} + + + {{end}} +
+
+ + +
+
+ {{else}} +

暂无单页

+ {{end}} +
+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl index 30d11c0..2df2299 100644 --- a/templates/base/footer.tmpl +++ b/templates/base/footer.tmpl @@ -3,6 +3,7 @@

{{.SiteName}} {{if .ShowFriendLinksFooter}} · 友情链接{{end}} + {{range .FooterPages}} · {{.Title}}{{end}}

diff --git a/templates/base/navbar.tmpl b/templates/base/navbar.tmpl index a89fcf4..abba452 100644 --- a/templates/base/navbar.tmpl +++ b/templates/base/navbar.tmpl @@ -12,6 +12,7 @@ 搜索 板块 {{if .ShowFriendLinksNav}}友链{{end}} + {{range .NavPages}}{{.Title}}{{end}} {{if .LoggedIn}} 发帖 私信{{if gt .UnreadCount 0}} {{.UnreadCount}}{{end}} diff --git a/templates/embed.go b/templates/embed.go index 0040e15..c1adca1 100644 --- a/templates/embed.go +++ b/templates/embed.go @@ -2,5 +2,5 @@ package templates import "embed" -//go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl profile/*.tmpl user/*.tmpl favorites/*.tmpl messages/*.tmpl links/*.tmpl boards/*.tmpl +//go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl profile/*.tmpl user/*.tmpl favorites/*.tmpl messages/*.tmpl links/*.tmpl boards/*.tmpl page/*.tmpl var FS embed.FS diff --git a/templates/page/view.tmpl b/templates/page/view.tmpl new file mode 100644 index 0000000..0170541 --- /dev/null +++ b/templates/page/view.tmpl @@ -0,0 +1,12 @@ +{{define "page/view"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +{{if .Flash}}
{{.Flash}}
{{end}} +
+
+

{{.PageTitle}}

+
{{.BodyHTML}}
+
+
+{{template "base/footer" .}} +{{end}} diff --git a/web_src/css/site.css b/web_src/css/site.css index bcf4f0e..1f6f5f1 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -826,3 +826,16 @@ points-only[data-locked="false"] { overflow: hidden; } +.j13-page { + max-width: 800px; + margin: 0 auto; + padding: 1rem 1rem 2.5rem; +} +.j13-page__article h1 { + margin: 0 0 1rem; + font-size: 1.5rem; +} +.j13-page__body { + line-height: 1.65; +} +