From 5e623f67fb11d81124919446449670699c714db5 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 29 Aug 2026 20:16:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Admin=20settings=20=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=20aside=5Fwidgets=20=E5=BC=80=E5=85=B3=E4=B8=8E=E6=8E=92?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让运营可在 /admin/settings 配置右栏组件,并与友链页 aside 开关共用同一份 JSON。 Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 2 +- docs/rebuild-spec/06-pages-ux.md | 4 +- .../rebuild-spec/08-gitea-ssr-architecture.md | 4 +- public/assets/site.css | 18 +++ routers/web/admin.go | 146 +++++++++++++++--- routers/web/aside_widgets_test.go | 31 ++++ routers/web/home.go | 1 + services/settings.go | 25 ++- templates/admin/settings.tmpl | 19 +++ web_src/css/site.css | 18 +++ 10 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 routers/web/aside_widgets_test.go diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 5b6a4d1..6dc687a 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -19,7 +19,7 @@ - [x] 板块筛选:全部 + 单板块 - [ ] 列表样式可配:`title` | `excerpt` | `thumbnail` - [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`) -- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin 仅友链侧栏开关 +- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin `/admin/settings` 侧栏组件编辑器 - [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`) - [ ] 下拉刷新(移动端) - [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配) diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 9b47e06..d87e357 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -91,7 +91,7 @@ - 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **已迁** SSR 右栏;流水/详情仍在 `/profile#wallet` - 热门帖 — **已迁** SSR(固定块) -- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin 完整排序编辑器未迁,友链侧栏开关在 `/admin/friend-links` +- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin `/admin/settings` 侧栏组件开关与排序;友链页 aside 勾选共用同一 JSON - 桌面三列;≤900px 隐藏右栏 ### 2.4 移动端 @@ -188,7 +188,7 @@ | Dashboard | 看计数与待办;点进对应列表 | | Boards | 拖拽或数字排序;图标/色板选择;增删改 | | Pages | 列表发布开关;编辑正文(HTML textarea);nav/footer 勾选 — SSR `/admin/pages` | -| Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(完整侧栏组件编辑 / 复检按钮未迁) | +| Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(aside 与 Settings 侧栏组件共用 `aside_widgets`;复检按钮未迁) | | Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` | | Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 | | Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` | diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index 4096ecb..945e49c 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -92,7 +92,7 @@ web_src/ → public/assets/ Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。 -右栏:首页与帖详情三栏;登录用户签到/抽奖条(PRG 可回跳当前 Feed/帖页);固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链);Admin 友链页可开关侧栏友链。 +右栏:首页与帖详情三栏;登录用户签到/抽奖条(PRG 可回跳当前 Feed/帖页);固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链);Admin `/admin/settings` 可开关排序,友链页 aside 勾选与其共用同一配置。 友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、nav/footer/aside 入口开关)。 @@ -102,4 +102,4 @@ Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_ 运营标记:帖详情 Admin 条(全局/版内置顶、精华、禁编、锁评、软删);回收站 `/admin/trash`(恢复/彻底删除)。 -Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`、`/admin/trash`。 \ No newline at end of file +Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/侧栏组件/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`、`/admin/trash`。 \ No newline at end of file diff --git a/public/assets/site.css b/public/assets/site.css index 8dec6d5..1cf6c74 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -494,6 +494,24 @@ body.j13-body { border-bottom: 1px solid var(--j13-border); } .j13-admin-form { max-width: 36rem; } +.j13-admin-aside-widgets { + list-style: none; + margin: 0 0 0.75rem; + padding: 0; +} +.j13-admin-aside-widgets li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--j13-border); +} +.j13-admin-aside-move { + display: flex; + gap: 0.5rem; + margin-left: auto; +} .j13-admin-actions { display: flex; flex-wrap: wrap; diff --git a/routers/web/admin.go b/routers/web/admin.go index 2f2f7e9..1749b08 100644 --- a/routers/web/admin.go +++ b/routers/web/admin.go @@ -404,19 +404,29 @@ func (d Deps) AdminCommentReject(c *gin.Context) { ctx.Redirect("/admin/moderation") } +type adminAsideWidgetRow struct { + ID string + Label string + Enabled bool + Index int + CanMoveUp bool + CanMoveDown bool +} + type adminSettingsData struct { AdminChrome - Brand services.SiteBranding - RatePost int - RateComment int - RateReg int - RateLogin int - RateWindow int - FilterWords string - FilterCount int - Mail services.MailConfig - MailReady bool - TestTo string + Brand services.SiteBranding + RatePost int + RateComment int + RateReg int + RateLogin int + RateWindow int + FilterWords string + FilterCount int + Mail services.MailConfig + MailReady bool + TestTo string + AsideWidgets []adminAsideWidgetRow } // AdminSettingsGet 设置页 @@ -429,23 +439,48 @@ func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) { words := d.Settings.FilterWordsContent() lim := d.Settings.Limits() mail := d.Settings.MailConfigPublic() + widgets := d.Settings.AsideWidgets() + asideRows := make([]adminAsideWidgetRow, 0, len(widgets)) + last := len(widgets) - 1 + for i, w := range widgets { + asideRows = append(asideRows, adminAsideWidgetRow{ + ID: w.ID, Label: asideWidgetLabel(w.ID), Enabled: w.Enabled, Index: i, + CanMoveUp: i > 0, CanMoveDown: i < last, + }) + } data := adminSettingsData{ - AdminChrome: d.adminChrome(ctx, "站点设置", "settings"), - Brand: d.Settings.SiteBranding(), - RatePost: lim.RateLimitPost, - RateComment: lim.RateLimitComment, - RateReg: lim.RateLimitRegister, - RateLogin: lim.RateLimitLogin, - RateWindow: lim.RateLimitWindowSec, - FilterWords: words, - FilterCount: services.CountFilterWords(words), - Mail: mail, - MailReady: d.Settings.MailReady(), + AdminChrome: d.adminChrome(ctx, "站点设置", "settings"), + Brand: d.Settings.SiteBranding(), + RatePost: lim.RateLimitPost, + RateComment: lim.RateLimitComment, + RateReg: lim.RateLimitRegister, + RateLogin: lim.RateLimitLogin, + RateWindow: lim.RateLimitWindowSec, + FilterWords: words, + FilterCount: services.CountFilterWords(words), + Mail: mail, + MailReady: d.Settings.MailReady(), + AsideWidgets: asideRows, } data.Error = errMsg ctx.HTML(http.StatusOK, "admin/settings", data) } +func asideWidgetLabel(id string) string { + switch id { + case services.AsideWidgetTagCloud: + return "标签云" + case services.AsideWidgetRecentComments: + return "最新评论" + case services.AsideWidgetRecentUsers: + return "最新用户" + case services.AsideWidgetFriendLinks: + return "友链" + default: + return id + } +} + // AdminSettingsBrandPost 品牌 func (d Deps) AdminSettingsBrandPost(c *gin.Context) { ctx := d.ctx(c) @@ -567,3 +602,70 @@ func (d Deps) AdminSettingsMailTestPost(c *gin.Context) { ctx.SetFlash("测试邮件已发送至 " + to) ctx.Redirect("/admin/settings") } + +// AdminSettingsAsideWidgetsPost 侧栏组件开关与排序 +func (d Deps) AdminSettingsAsideWidgetsPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminSettings(ctx, "无效请求,请重试") + return + } + order := c.PostFormArray("order") + enabledSet := make(map[string]bool) + for _, id := range c.PostFormArray("enabled") { + id = strings.TrimSpace(id) + if id != "" { + enabledSet[id] = true + } + } + widgets := make([]services.AsideWidget, 0, len(order)) + for _, id := range order { + id = strings.TrimSpace(id) + if id == "" { + continue + } + widgets = append(widgets, services.AsideWidget{ID: id, Enabled: enabledSet[id]}) + } + widgets = services.NormalizeAsideWidgets(widgets) + if move := strings.TrimSpace(c.PostForm("move")); move != "" { + widgets = moveAsideWidget(widgets, move) + } + if err := d.Settings.UpdateAsideWidgets(widgets); err != nil { + d.renderAdminSettings(ctx, err.Error()) + return + } + ctx.SetFlash("侧栏组件已保存") + ctx.Redirect("/admin/settings") +} + +// moveAsideWidget 解析 move 值「up:id」或「down:id」并交换相邻项 +func moveAsideWidget(widgets []services.AsideWidget, move string) []services.AsideWidget { + parts := strings.SplitN(move, ":", 2) + if len(parts) != 2 { + return widgets + } + dir, id := parts[0], strings.TrimSpace(parts[1]) + idx := -1 + for i, w := range widgets { + if w.ID == id { + idx = i + break + } + } + if idx < 0 { + return widgets + } + switch dir { + case "up": + if idx == 0 { + return widgets + } + widgets[idx-1], widgets[idx] = widgets[idx], widgets[idx-1] + case "down": + if idx >= len(widgets)-1 { + return widgets + } + widgets[idx], widgets[idx+1] = widgets[idx+1], widgets[idx] + } + return widgets +} diff --git a/routers/web/aside_widgets_test.go b/routers/web/aside_widgets_test.go new file mode 100644 index 0000000..4f2d6aa --- /dev/null +++ b/routers/web/aside_widgets_test.go @@ -0,0 +1,31 @@ +package web + +import ( + "testing" + + "git.iioio.com/freefire/jiang13-forum/services" +) + +func TestMoveAsideWidget(t *testing.T) { + base := []services.AsideWidget{ + {ID: services.AsideWidgetTagCloud, Enabled: true}, + {ID: services.AsideWidgetRecentComments, Enabled: false}, + {ID: services.AsideWidgetRecentUsers, Enabled: false}, + {ID: services.AsideWidgetFriendLinks, Enabled: true}, + } + + up := moveAsideWidget(append([]services.AsideWidget(nil), base...), "up:"+services.AsideWidgetRecentComments) + if up[0].ID != services.AsideWidgetRecentComments || up[1].ID != services.AsideWidgetTagCloud { + t.Fatalf("up move failed: %+v", up) + } + + down := moveAsideWidget(append([]services.AsideWidget(nil), base...), "down:"+services.AsideWidgetTagCloud) + if down[0].ID != services.AsideWidgetRecentComments || down[1].ID != services.AsideWidgetTagCloud { + t.Fatalf("down move failed: %+v", down) + } + + noop := moveAsideWidget(append([]services.AsideWidget(nil), base...), "up:"+services.AsideWidgetTagCloud) + if noop[0].ID != services.AsideWidgetTagCloud { + t.Fatalf("top up should noop: %+v", noop) + } +} diff --git a/routers/web/home.go b/routers/web/home.go index e0f87e9..b985b32 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -68,6 +68,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) admin.POST("/settings/mail", deps.AdminSettingsMailPost) admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost) + admin.POST("/settings/aside-widgets", deps.AdminSettingsAsideWidgetsPost) admin.GET("/friend-links", deps.AdminFriendLinksGet) admin.POST("/friend-links/settings", deps.AdminFriendLinksSettingsPost) admin.POST("/friend-links/brand", deps.AdminFriendLinksBrandAddPost) diff --git a/services/settings.go b/services/settings.go index 35ce9ea..2d922f6 100644 --- a/services/settings.go +++ b/services/settings.go @@ -792,7 +792,12 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error { if !found { widgets = append(widgets, AsideWidget{ID: AsideWidgetFriendLinks, Enabled: enabled}) } - widgets = NormalizeAsideWidgets(widgets) + return s.UpdateAsideWidgets(widgets) +} + +// UpdateAsideWidgets 保存侧栏组件顺序与开关(同步 legacy bool 键) +func (s *ForumSettingsService) UpdateAsideWidgets(in []AsideWidget) error { + widgets := NormalizeAsideWidgets(in) bools := asideBoolsFromWidgets(widgets) payload, err := json.Marshal(widgets) if err != nil { @@ -801,11 +806,21 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error { if err := s.setString(SettingAsideWidgets, string(payload)); err != nil { return err } - v := "0" - if bools.friendLinks { - v = "1" + boolUpdates := map[string]bool{ + SettingAsideShowTagCloud: bools.tagCloud, + SettingAsideShowRecentComments: bools.recentComments, + SettingAsideShowFriendLinks: bools.friendLinks, } - return s.setString(SettingAsideShowFriendLinks, v) + for key, on := range boolUpdates { + v := "0" + if on { + v = "1" + } + if err := s.setString(key, v); err != nil { + return err + } + } + return nil } type asideWidgetBools struct { diff --git a/templates/admin/settings.tmpl b/templates/admin/settings.tmpl index f236e41..e338625 100644 --- a/templates/admin/settings.tmpl +++ b/templates/admin/settings.tmpl @@ -31,6 +31,25 @@ +

侧栏组件

+

控制首页与帖详情右栏可选块的显示与顺序(热门帖为固定块,不在此列)。友链页「侧栏显示」与此共用同一配置。

+
+ +
    + {{range .AsideWidgets}} +
  • + + + + {{if .CanMoveUp}}{{end}} + {{if .CanMoveDown}}{{end}} + +
  • + {{end}} +
+ +
+

敏感词(有效 {{.FilterCount}} 个)

每行一个词,# 开头为注释。保存后立即热更新。

diff --git a/web_src/css/site.css b/web_src/css/site.css index 8dec6d5..1cf6c74 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -494,6 +494,24 @@ body.j13-body { border-bottom: 1px solid var(--j13-border); } .j13-admin-form { max-width: 36rem; } +.j13-admin-aside-widgets { + list-style: none; + margin: 0 0 0.75rem; + padding: 0; +} +.j13-admin-aside-widgets li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--j13-border); +} +.j13-admin-aside-move { + display: flex; + gap: 0.5rem; + margin-left: auto; +} .j13-admin-actions { display: flex; flex-wrap: wrap;