package web import ( "fmt" "net/http" "path/filepath" "strconv" "strings" "time" "git.iioio.com/freefire/jiang13-forum/models" "git.iioio.com/freefire/jiang13-forum/modules/webctx" "git.iioio.com/freefire/jiang13-forum/services" "github.com/gin-gonic/gin" ) // AdminChrome 后台布局公共字段 type AdminChrome struct { PageChrome NavActive string } func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome { site := d.Settings.SiteBranding().Name if title == "" { title = "管理后台 · " + site } else { title = title + " · " + site } base := d.chrome(ctx, title, "", "") return AdminChrome{PageChrome: base, NavActive: nav} } type adminDashData struct { AdminChrome UserCount int64 PostCount int64 PendingPosts int64 PendingComments int64 PendingReports int64 BoardCount int64 } // AdminDashboard 概览 func (d Deps) AdminDashboard(c *gin.Context) { ctx := d.ctx(c) var users, posts, boards int64 _ = models.DB.Model(&models.User{}).Count(&users).Error _ = models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&posts).Error _ = models.DB.Model(&models.Board{}).Count(&boards).Error pendingPosts, _ := d.Post.PendingPostCount() pendingComments, _ := d.Comment.PendingCommentCount() var pendingReports int64 if d.Report != nil { pendingReports, _ = d.Report.PendingCount() } ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{ AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"), UserCount: users, PostCount: posts, PendingPosts: pendingPosts, PendingComments: pendingComments, PendingReports: pendingReports, BoardCount: boards, }) } type adminBoardRow struct { ID uint Name string Description string Icon string ColorIndex int SortOrder int PostCount int } type adminBoardsData struct { AdminChrome Boards []adminBoardRow Form adminBoardForm } type adminBoardForm struct { ID uint Name string Description string Icon string ColorIndex int SortOrder int } // AdminBoardsGet 板块列表 func (d Deps) AdminBoardsGet(c *gin.Context) { ctx := d.ctx(c) d.renderAdminBoards(ctx, "", adminBoardForm{}) } func (d Deps) renderAdminBoards(ctx *webctx.Context, errMsg string, form adminBoardForm) { list, _ := d.Board.ListWithStats() rows := make([]adminBoardRow, 0, len(list)) for _, b := range list { rows = append(rows, adminBoardRow{ ID: b.ID, Name: b.Name, Description: b.Description, Icon: b.Icon, ColorIndex: b.ColorIndex, SortOrder: b.SortOrder, PostCount: b.PostCount, }) } data := adminBoardsData{ AdminChrome: d.adminChrome(ctx, "板块", "boards"), Boards: rows, Form: form, } data.Error = errMsg ctx.HTML(http.StatusOK, "admin/boards", data) } // AdminBoardCreate 新建板块 func (d Deps) AdminBoardCreate(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c)) return } form := adminBoardFormFrom(c) if strings.TrimSpace(form.Name) == "" { d.renderAdminBoards(ctx, "请填写板块名称", form) return } if _, err := d.Board.Create(form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil { d.renderAdminBoards(ctx, err.Error(), form) return } ctx.SetFlash("板块已创建") ctx.Redirect("/admin/boards") } // AdminBoardUpdate 更新板块 func (d Deps) AdminBoardUpdate(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c)) return } id, _ := strconv.ParseUint(c.Param("id"), 10, 64) form := adminBoardFormFrom(c) form.ID = uint(id) if strings.TrimSpace(form.Name) == "" { d.renderAdminBoards(ctx, "请填写板块名称", form) return } if err := d.Board.Update(uint(id), form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil { d.renderAdminBoards(ctx, err.Error(), form) return } ctx.SetFlash("板块已更新") ctx.Redirect("/admin/boards") } // AdminBoardDelete 删除板块 func (d Deps) AdminBoardDelete(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect("/admin/boards") return } id, _ := strconv.ParseUint(c.Param("id"), 10, 64) if err := d.Board.Delete(uint(id)); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect("/admin/boards") return } ctx.SetFlash("板块已删除") ctx.Redirect("/admin/boards") } func adminBoardFormFrom(c *gin.Context) adminBoardForm { color, _ := strconv.Atoi(c.PostForm("color_index")) sort, _ := strconv.Atoi(c.PostForm("sort_order")) return adminBoardForm{ Name: strings.TrimSpace(c.PostForm("name")), Description: strings.TrimSpace(c.PostForm("description")), Icon: strings.TrimSpace(c.PostForm("icon")), ColorIndex: color, SortOrder: sort, } } type adminModPostRow struct { ID uint Title string AuthorName string BoardName string CreatedAt string } type adminModCommentRow struct { ID uint PostID uint PostTitle string Floor int AuthorName string Excerpt string CreatedAt string } type adminModData struct { AdminChrome Posts []adminModPostRow Comments []adminModCommentRow } // AdminModerationGet 待审帖/评 func (d Deps) AdminModerationGet(c *gin.Context) { ctx := d.ctx(c) posts, _, _ := d.Post.List(services.PostListQuery{ Page: 1, Size: 50, ViewerIsAdmin: true, Status: models.ContentStatusPending, Sort: "latest", }) postRows := make([]adminModPostRow, 0, len(posts)) for _, p := range posts { author := "" if p.User.ID > 0 { author = p.User.Nickname if author == "" { author = p.User.Username } } board := "" if p.Board.ID > 0 { board = p.Board.Name } postRows = append(postRows, adminModPostRow{ ID: p.ID, Title: p.Title, AuthorName: author, BoardName: board, CreatedAt: p.CreatedAt.Format("2006-01-02 15:04"), }) } comments, _, _ := d.Comment.ListPending(1, 50) commentRows := make([]adminModCommentRow, 0, len(comments)) for _, cm := range comments { author := "游客" if cm.UserID > 0 { author = cm.User.Nickname if author == "" { author = cm.User.Username } } else if cm.GuestNick != "" { author = cm.GuestNick } excerpt := strings.TrimSpace(stripTagsRough(cm.Content)) runes := []rune(excerpt) if len(runes) > 80 { excerpt = string(runes[:80]) + "…" } title := "" if cm.Post.ID > 0 { title = cm.Post.Title } commentRows = append(commentRows, adminModCommentRow{ ID: cm.ID, PostID: cm.PostID, PostTitle: title, Floor: cm.Floor, AuthorName: author, Excerpt: excerpt, CreatedAt: cm.CreatedAt.Format("2006-01-02 15:04"), }) } ctx.HTML(http.StatusOK, "admin/moderation", adminModData{ AdminChrome: d.adminChrome(ctx, "内容审核", "moderation"), Posts: postRows, Comments: commentRows, }) } func stripTagsRough(s string) string { var b strings.Builder inTag := false for _, r := range s { switch { case r == '<': inTag = true case r == '>': inTag = false case !inTag: b.WriteRune(r) } } return b.String() } // AdminPostApprove 通过帖子 func (d Deps) AdminPostApprove(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect("/admin/moderation") return } id, _ := strconv.ParseUint(c.Param("id"), 10, 64) if err := d.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil { ctx.SetFlash(err.Error()) } else { ctx.SetFlash("帖子已通过") } ctx.Redirect("/admin/moderation") } // AdminPostReject 拒绝帖子 func (d Deps) AdminPostReject(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect("/admin/moderation") return } id, _ := strconv.ParseUint(c.Param("id"), 10, 64) reason := strings.TrimSpace(c.PostForm("reason")) if reason == "" { ctx.SetFlash("请填写拒绝原因") ctx.Redirect("/admin/moderation") return } post, err := d.Post.FindByID(uint(id)) if err != nil { ctx.SetFlash(err.Error()) ctx.Redirect("/admin/moderation") return } if err := d.Post.SetStatus(post.ID, models.ContentStatusRejected); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect("/admin/moderation") return } pid := post.ID if d.Message != nil { _, _ = d.Message.SendSystem( post.UserID, "帖子《"+post.Title+"》未通过审核", services.FormatRejectContent(post.Title, post.ID, reason), models.MessageKindReject, &pid, nil, ) } ctx.SetFlash("已拒绝该帖") ctx.Redirect("/admin/moderation") } // AdminCommentApprove 通过评论 func (d Deps) AdminCommentApprove(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect("/admin/moderation") return } id, _ := strconv.ParseUint(c.Param("id"), 10, 64) if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil { ctx.SetFlash(err.Error()) } else { if d.Notify != nil { if comment, err := d.Comment.GetByID(uint(id)); err == nil { comment.Status = models.ContentStatusPublished d.Notify.AsyncNotifyCommentPublished(comment) d.Notify.AsyncNotifyCommentMentions(comment) } } ctx.SetFlash("评论已通过") } ctx.Redirect("/admin/moderation") } // AdminCommentReject 拒绝评论 func (d Deps) AdminCommentReject(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { ctx.SetFlash("无效请求,请重试") ctx.Redirect("/admin/moderation") return } id, _ := strconv.ParseUint(c.Param("id"), 10, 64) reason := strings.TrimSpace(c.PostForm("reason")) if reason == "" { ctx.SetFlash("请填写拒绝原因") ctx.Redirect("/admin/moderation") return } cm, err := d.Comment.GetByID(uint(id)) if err != nil { ctx.SetFlash(err.Error()) ctx.Redirect("/admin/moderation") return } if err := d.Comment.SetStatus(cm.ID, models.ContentStatusRejected); err != nil { ctx.SetFlash(err.Error()) ctx.Redirect("/admin/moderation") return } if d.Message != nil && cm.UserID > 0 { pid := cm.PostID title := cm.Post.Title _, _ = d.Message.SendSystem( cm.UserID, fmt.Sprintf("评论未通过审核(帖 #%d)", cm.PostID), services.FormatCommentRejectContent(title, cm.PostID, cm.Floor, reason), models.MessageKindReject, &pid, nil, ) } ctx.SetFlash("已拒绝该评论") 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 Limits services.ForumLimits FilterWords string FilterCount int Mail services.MailConfig MailReady bool TestTo string AsideWidgets []adminAsideWidgetRow CanBackup bool } // AdminSettingsGet 设置页 func (d Deps) AdminSettingsGet(c *gin.Context) { ctx := d.ctx(c) d.renderAdminSettings(ctx, "") } 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, Limits: lim, FilterWords: words, FilterCount: services.CountFilterWords(words), Mail: mail, MailReady: d.Settings.MailReady(), AsideWidgets: asideRows, CanBackup: models.DialectorName() == "sqlite", } 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) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } cur := d.Settings.SiteBranding() in := services.SiteBranding{ Name: strings.TrimSpace(c.PostForm("name")), Slogan: strings.TrimSpace(c.PostForm("slogan")), Description: strings.TrimSpace(c.PostForm("description")), Keywords: strings.TrimSpace(c.PostForm("keywords")), LogoMark: strings.TrimSpace(c.PostForm("logo_mark")), Logo: cur.Logo, Favicon: cur.Favicon, OGImage: cur.OGImage, ICPBeian: strings.TrimSpace(c.PostForm("icp_beian")), ICPBeianURL: strings.TrimSpace(c.PostForm("icp_beian_url")), FriendLinks: cur.FriendLinks, } if err := d.Settings.UpdateSiteBranding(in); err != nil { d.renderAdminSettings(ctx, err.Error()) return } ctx.SetFlash("品牌设置已保存") ctx.Redirect("/admin/settings") } // AdminSettingsBrandUploadPost 上传 Logo / Favicon / OG 图 func (d Deps) AdminSettingsBrandUploadPost(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } if d.Store == nil { d.renderAdminSettings(ctx, "上传存储未就绪") return } kind := strings.TrimSpace(c.PostForm("kind")) if kind != "logo" && kind != "favicon" && kind != "og_image" { d.renderAdminSettings(ctx, "请选择 Logo、Favicon 或 OG 图") return } file, err := c.FormFile("file") if err != nil { d.renderAdminSettings(ctx, "请选择图片文件") return } const maxBytes = 2 * 1024 * 1024 if file.Size > maxBytes { d.renderAdminSettings(ctx, "图片不能超过 2MB") return } url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, kind) if err != nil { d.renderAdminSettings(ctx, err.Error()) return } prev := d.Settings.SiteBranding() switch kind { case "logo": _ = d.Settings.SetSiteLogo(url) d.Store.DeleteByURL(prev.Logo) ctx.SetFlash("Logo 已上传") case "favicon": _ = d.Settings.SetSiteFavicon(url) d.Store.DeleteByURL(prev.Favicon) ctx.SetFlash("Favicon 已上传") case "og_image": _ = d.Settings.SetSiteOGImage(url) d.Store.DeleteByURL(prev.OGImage) ctx.SetFlash("默认 OG 图已上传") } ctx.Redirect("/admin/settings") } // AdminSettingsBrandClearPost 清除品牌图片 func (d Deps) AdminSettingsBrandClearPost(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } kind := strings.TrimSpace(c.PostForm("kind")) brand := d.Settings.SiteBranding() switch kind { case "logo": _ = d.Settings.SetSiteLogo("") if d.Store != nil { d.Store.DeleteByURL(brand.Logo) } ctx.SetFlash("已清除 Logo") case "favicon": _ = d.Settings.SetSiteFavicon("") if d.Store != nil { d.Store.DeleteByURL(brand.Favicon) } ctx.SetFlash("已清除 Favicon") case "og_image": _ = d.Settings.SetSiteOGImage("") if d.Store != nil { d.Store.DeleteByURL(brand.OGImage) } ctx.SetFlash("已清除 OG 图") default: d.renderAdminSettings(ctx, "无效的资源类型") return } ctx.Redirect("/admin/settings") } // AdminSettingsLimitsPost 限流 func (d Deps) AdminSettingsLimitsPost(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } postN, _ := strconv.Atoi(c.PostForm("rate_limit_post")) commentN, _ := strconv.Atoi(c.PostForm("rate_limit_comment")) regN, _ := strconv.Atoi(c.PostForm("rate_limit_register")) loginN, _ := strconv.Atoi(c.PostForm("rate_limit_login")) windowN, _ := strconv.Atoi(c.PostForm("rate_limit_window_sec")) if err := d.Settings.UpdateRateLimits(postN, commentN, regN, loginN, windowN); err != nil { d.renderAdminSettings(ctx, err.Error()) return } ctx.SetFlash("限流设置已保存") ctx.Redirect("/admin/settings") } // AdminSettingsContentLimitsPost 字数与编辑窗等 func (d Deps) AdminSettingsContentLimitsPost(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } atoi := func(name string) int { n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm(name))) return n } err := d.Settings.UpdateContentLimits( atoi("post_edit_window_hours"), atoi("comment_edit_window_minutes"), atoi("post_title_max"), atoi("post_tags_max"), atoi("post_content_max"), atoi("comment_max"), atoi("search_keyword_min"), atoi("search_keyword_max"), atoi("page_size_default"), atoi("password_min_len"), atoi("avatar_max_mb"), atoi("signature_max"), c.PostForm("open_posts_in_new_tab") == "1" || c.PostForm("open_posts_in_new_tab") == "on", c.PostForm("open_content_links_in_new_tab") == "1" || c.PostForm("open_content_links_in_new_tab") == "on", strings.TrimSpace(c.PostForm("feed_list_style")), ) if err != nil { d.renderAdminSettings(ctx, "内容限制无效,请检查数值范围") return } ctx.SetFlash("内容限制已保存") ctx.Redirect("/admin/settings") } // AdminSettingsPermalinkPost 伪静态 func (d Deps) AdminSettingsPermalinkPost(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } enabled := c.PostForm("permalink_enabled") == "1" || c.PostForm("permalink_enabled") == "on" ext := strings.TrimSpace(c.PostForm("permalink_ext")) if err := d.Settings.UpdatePermalinkSettings(enabled, ext); err != nil { d.renderAdminSettings(ctx, "伪静态后缀无效(1–16 位字母数字)") return } ctx.SetFlash("伪静态设置已保存") ctx.Redirect("/admin/settings") } // AdminSettingsFilterWordsPost 敏感词 func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } content := c.PostForm("filter_words") if err := d.Settings.UpdateFilterWords(content, d.Filter); err != nil { d.renderAdminSettings(ctx, err.Error()) return } ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05"))) ctx.Redirect("/admin/settings") } // AdminSettingsMailPost 保存 SMTP func (d Deps) AdminSettingsMailPost(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } port, _ := strconv.Atoi(c.PostForm("port")) cfg := services.MailConfig{ Enabled: c.PostForm("enabled") == "1" || c.PostForm("enabled") == "on", Host: strings.TrimSpace(c.PostForm("host")), Port: port, Username: strings.TrimSpace(c.PostForm("username")), Password: c.PostForm("password"), From: strings.TrimSpace(c.PostForm("from")), FromName: strings.TrimSpace(c.PostForm("from_name")), Encryption: strings.TrimSpace(c.PostForm("encryption")), } if err := d.Settings.UpdateMailConfig(cfg); err != nil { d.renderAdminSettings(ctx, err.Error()) return } ctx.SetFlash("邮件设置已保存") ctx.Redirect("/admin/settings") } // AdminSettingsMailTestPost 发送测试信 func (d Deps) AdminSettingsMailTestPost(c *gin.Context) { ctx := d.ctx(c) to := strings.TrimSpace(c.PostForm("test_to")) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } if err := services.ValidateEmail(to); err != nil { d.renderAdminSettings(ctx, err.Error()) return } if !d.Settings.MailReady() { d.renderAdminSettings(ctx, services.ErrMailNotConfigured.Error()) return } if d.Mail == nil { d.renderAdminSettings(ctx, "邮件服务未就绪") return } siteName := d.Settings.SiteBranding().Name err := d.Mail.Send(services.NormalizeEmail(to), "邮件配置测试", fmt.Sprintf("这是一封来自%s的测试邮件,说明 SMTP 配置正常。", siteName)) if err != nil { d.renderAdminSettings(ctx, err.Error()) return } 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") } // AdminSettingsBackupPost 一键导出 SQLite 备份并跳转下载 func (d Deps) AdminSettingsBackupPost(c *gin.Context) { ctx := d.ctx(c) if !ctx.CheckCSRF() { d.renderAdminSettings(ctx, "无效请求,请重试") return } if d.Backup == nil { d.renderAdminSettings(ctx, "备份服务未就绪") return } path, err := d.Backup.ExportSQLite() if err != nil { d.renderAdminSettings(ctx, err.Error()) return } name := filepath.Base(path) ctx.Redirect("/admin/settings/backup/download/" + name) } // AdminSettingsBackupDownload 下载 data 目录下的备份文件 func (d Deps) AdminSettingsBackupDownload(c *gin.Context) { name := c.Param("name") if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") { c.String(http.StatusBadRequest, "无效的备份文件名") return } path := filepath.Join(d.DataDir, name) c.FileAttachment(path, name) } // 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 }