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,19 +404,29 @@ 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
RatePost int RatePost int
RateComment int RateComment int
RateReg int RateReg int
RateLogin int RateLogin int
RateWindow int RateWindow int
FilterWords string FilterWords string
FilterCount int FilterCount int
Mail services.MailConfig Mail services.MailConfig
MailReady bool MailReady bool
TestTo string TestTo string
AsideWidgets []adminAsideWidgetRow
} }
// AdminSettingsGet 设置页 // AdminSettingsGet 设置页
@@ -429,23 +439,48 @@ 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(),
RatePost: lim.RateLimitPost, RatePost: lim.RateLimitPost,
RateComment: lim.RateLimitComment, RateComment: lim.RateLimitComment,
RateReg: lim.RateLimitRegister, RateReg: lim.RateLimitRegister,
RateLogin: lim.RateLimitLogin, RateLogin: lim.RateLimitLogin,
RateWindow: lim.RateLimitWindowSec, RateWindow: lim.RateLimitWindowSec,
FilterWords: words, FilterWords: words,
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
} }
v := "0" boolUpdates := map[string]bool{
if bools.friendLinks { SettingAsideShowTagCloud: bools.tagCloud,
v = "1" 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 { 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;