feat: Admin settings 侧栏 aside_widgets 开关与排序

让运营可在 /admin/settings 配置右栏组件,并与友链页 aside 开关共用同一份 JSON。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 20:16:07 +08:00
parent 2d09729976
commit 5e623f67fb
10 changed files with 236 additions and 32 deletions

View File

@@ -19,7 +19,7 @@
- [x] 板块筛选:全部 + 单板块 - [x] 板块筛选:全部 + 单板块
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail` - [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id` - [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`
- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`Admin 仅友链侧栏开关 - [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`Admin `/admin/settings` 侧栏组件编辑器
- [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet` - [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`
- [ ] 下拉刷新(移动端) - [ ] 下拉刷新(移动端)
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配) - [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)

View File

@@ -91,7 +91,7 @@
- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **已迁** SSR 右栏;流水/详情仍在 `/profile#wallet` - 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **已迁** SSR 右栏;流水/详情仍在 `/profile#wallet`
- 热门帖 — **已迁** SSR固定块 - 热门帖 — **已迁** SSR固定块
- 可配置 widgets`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSRAdmin 完整排序编辑器未迁,友链侧栏开关在 `/admin/friend-links` - 可配置 widgets`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSRAdmin `/admin/settings` 侧栏组件开关与排序;友链页 aside 勾选共用同一 JSON
- 桌面三列≤900px 隐藏右栏 - 桌面三列≤900px 隐藏右栏
### 2.4 移动端 ### 2.4 移动端
@@ -188,7 +188,7 @@
| Dashboard | 看计数与待办;点进对应列表 | | Dashboard | 看计数与待办;点进对应列表 |
| Boards | 拖拽或数字排序;图标/色板选择;增删改 | | Boards | 拖拽或数字排序;图标/色板选择;增删改 |
| Pages | 列表发布开关编辑正文HTML textareanav/footer 勾选 — SSR `/admin/pages` | | Pages | 列表发布开关编辑正文HTML textareanav/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` | | Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` |
| Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 | | Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 |
| Reports | 处理动作dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` | | Reports | 处理动作dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |

View File

@@ -92,7 +92,7 @@ web_src/ → public/assets/
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search` 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 入口开关)。 友链:`/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/trash`(恢复/彻底删除)。
Admin`/admin/dashboard``/admin/boards``/admin/moderation``/admin/settings`(品牌/限流/敏感词/SMTP`/admin/friend-links``/admin/pages``/admin/reports``/admin/trash` Admin`/admin/dashboard``/admin/boards``/admin/moderation``/admin/settings`(品牌/限流/侧栏组件/敏感词/SMTP`/admin/friend-links``/admin/pages``/admin/reports``/admin/trash`

View File

@@ -494,6 +494,24 @@ body.j13-body {
border-bottom: 1px solid var(--j13-border); border-bottom: 1px solid var(--j13-border);
} }
.j13-admin-form { max-width: 36rem; } .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 { .j13-admin-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -404,6 +404,15 @@ func (d Deps) AdminCommentReject(c *gin.Context) {
ctx.Redirect("/admin/moderation") ctx.Redirect("/admin/moderation")
} }
type adminAsideWidgetRow struct {
ID string
Label string
Enabled bool
Index int
CanMoveUp bool
CanMoveDown bool
}
type adminSettingsData struct { type adminSettingsData struct {
AdminChrome AdminChrome
Brand services.SiteBranding Brand services.SiteBranding
@@ -417,6 +426,7 @@ type adminSettingsData struct {
Mail services.MailConfig Mail services.MailConfig
MailReady bool MailReady bool
TestTo string TestTo string
AsideWidgets []adminAsideWidgetRow
} }
// AdminSettingsGet 设置页 // AdminSettingsGet 设置页
@@ -429,6 +439,15 @@ func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
words := d.Settings.FilterWordsContent() words := d.Settings.FilterWordsContent()
lim := d.Settings.Limits() lim := d.Settings.Limits()
mail := d.Settings.MailConfigPublic() 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{ data := adminSettingsData{
AdminChrome: d.adminChrome(ctx, "站点设置", "settings"), AdminChrome: d.adminChrome(ctx, "站点设置", "settings"),
Brand: d.Settings.SiteBranding(), Brand: d.Settings.SiteBranding(),
@@ -441,11 +460,27 @@ func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
FilterCount: services.CountFilterWords(words), FilterCount: services.CountFilterWords(words),
Mail: mail, Mail: mail,
MailReady: d.Settings.MailReady(), MailReady: d.Settings.MailReady(),
AsideWidgets: asideRows,
} }
data.Error = errMsg data.Error = errMsg
ctx.HTML(http.StatusOK, "admin/settings", data) 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 品牌 // AdminSettingsBrandPost 品牌
func (d Deps) AdminSettingsBrandPost(c *gin.Context) { func (d Deps) AdminSettingsBrandPost(c *gin.Context) {
ctx := d.ctx(c) ctx := d.ctx(c)
@@ -567,3 +602,70 @@ func (d Deps) AdminSettingsMailTestPost(c *gin.Context) {
ctx.SetFlash("测试邮件已发送至 " + to) ctx.SetFlash("测试邮件已发送至 " + to)
ctx.Redirect("/admin/settings") 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
}

View File

@@ -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)
}
}

View File

@@ -68,6 +68,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
admin.POST("/settings/mail", deps.AdminSettingsMailPost) admin.POST("/settings/mail", deps.AdminSettingsMailPost)
admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost) admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost)
admin.POST("/settings/aside-widgets", deps.AdminSettingsAsideWidgetsPost)
admin.GET("/friend-links", deps.AdminFriendLinksGet) admin.GET("/friend-links", deps.AdminFriendLinksGet)
admin.POST("/friend-links/settings", deps.AdminFriendLinksSettingsPost) admin.POST("/friend-links/settings", deps.AdminFriendLinksSettingsPost)
admin.POST("/friend-links/brand", deps.AdminFriendLinksBrandAddPost) admin.POST("/friend-links/brand", deps.AdminFriendLinksBrandAddPost)

View File

@@ -792,7 +792,12 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
if !found { if !found {
widgets = append(widgets, AsideWidget{ID: AsideWidgetFriendLinks, Enabled: enabled}) 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) bools := asideBoolsFromWidgets(widgets)
payload, err := json.Marshal(widgets) payload, err := json.Marshal(widgets)
if err != nil { if err != nil {
@@ -801,11 +806,21 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
if err := s.setString(SettingAsideWidgets, string(payload)); err != nil { if err := s.setString(SettingAsideWidgets, string(payload)); err != nil {
return err return err
} }
boolUpdates := map[string]bool{
SettingAsideShowTagCloud: bools.tagCloud,
SettingAsideShowRecentComments: bools.recentComments,
SettingAsideShowFriendLinks: bools.friendLinks,
}
for key, on := range boolUpdates {
v := "0" v := "0"
if bools.friendLinks { if on {
v = "1" v = "1"
} }
return s.setString(SettingAsideShowFriendLinks, v) if err := s.setString(key, v); err != nil {
return err
}
}
return nil
} }
type asideWidgetBools struct { type asideWidgetBools struct {

View File

@@ -31,6 +31,25 @@
<button type="submit">保存限流</button> <button type="submit">保存限流</button>
</form> </form>
<h2>侧栏组件</h2>
<p class="j13-muted">控制首页与帖详情右栏可选块的显示与顺序(热门帖为固定块,不在此列)。友链页「侧栏显示」与此共用同一配置。</p>
<form method="post" action="/admin/settings/aside-widgets" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<ul class="j13-admin-aside-widgets">
{{range .AsideWidgets}}
<li>
<input type="hidden" name="order" value="{{.ID}}"/>
<label class="j13-check"><input type="checkbox" name="enabled" value="{{.ID}}"{{if .Enabled}} checked{{end}}/> {{.Label}}</label>
<span class="j13-admin-aside-move">
{{if .CanMoveUp}}<button type="submit" name="move" value="up:{{.ID}}" class="j13-btn-secondary">上移</button>{{end}}
{{if .CanMoveDown}}<button type="submit" name="move" value="down:{{.ID}}" class="j13-btn-secondary">下移</button>{{end}}
</span>
</li>
{{end}}
</ul>
<button type="submit">保存侧栏</button>
</form>
<h2>敏感词(有效 {{.FilterCount}} 个)</h2> <h2>敏感词(有效 {{.FilterCount}} 个)</h2>
<p class="j13-muted">每行一个词,# 开头为注释。保存后立即热更新。</p> <p class="j13-muted">每行一个词,# 开头为注释。保存后立即热更新。</p>
<form method="post" action="/admin/settings/filter-words" class="j13-form j13-admin-form"> <form method="post" action="/admin/settings/filter-words" class="j13-form j13-admin-form">

View File

@@ -494,6 +494,24 @@ body.j13-body {
border-bottom: 1px solid var(--j13-border); border-bottom: 1px solid var(--j13-border);
} }
.j13-admin-form { max-width: 36rem; } .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 { .j13-admin-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;