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