feat: Admin settings 侧栏 aside_widgets 开关与排序
让运营可在 /admin/settings 配置右栏组件,并与友链页 aside 开关共用同一份 JSON。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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` 等形式(后缀后台可配)
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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`。
|
||||
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/侧栏组件/敏感词/SMTP)、`/admin/friend-links`、`/admin/pages`、`/admin/reports`、`/admin/trash`。
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
31
routers/web/aside_widgets_test.go
Normal file
31
routers/web/aside_widgets_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,6 +31,25 @@
|
||||
<button type="submit">保存限流</button>
|
||||
</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>
|
||||
<p class="j13-muted">每行一个词,# 开头为注释。保存后立即热更新。</p>
|
||||
<form method="post" action="/admin/settings/filter-words" class="j13-form j13-admin-form">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user