From 7bc50bfb807ab40fa7f87872894eedcba67ce182 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 29 Aug 2026 06:30:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSR=20=E5=8F=8B=E9=93=BE=E9=A1=B5?= =?UTF-8?q?=E4=B8=8E=E6=9C=80=E5=B0=8F=20Admin=20=E5=AE=A1=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公开 /links 列表与申请/取消,导航页脚可配入口;后台品牌增删与通过/拒绝。 Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 12 +- docs/rebuild-spec/06-pages-ux.md | 16 +- .../rebuild-spec/08-gitea-ssr-architecture.md | 4 +- modules/webrender/render.go | 1 + public/assets/site.css | 64 ++++- public/assets/site.js | 27 ++ routers/setup.go | 3 +- routers/web/admin_friend_links.go | 209 ++++++++++++++ routers/web/chrome.go | 41 +-- routers/web/home.go | 11 +- routers/web/links.go | 255 ++++++++++++++++++ templates/admin/friend_links.tmpl | 95 +++++++ templates/admin/nav.tmpl | 1 + templates/base/footer.tmpl | 5 +- templates/base/navbar.tmpl | 1 + templates/embed.go | 2 +- templates/links/list.tmpl | 84 ++++++ web_src/css/site.css | 64 ++++- web_src/js/site.js | 27 ++ 19 files changed, 880 insertions(+), 42 deletions(-) create mode 100644 routers/web/admin_friend_links.go create mode 100644 routers/web/links.go create mode 100644 templates/admin/friend_links.tmpl create mode 100644 templates/links/list.tmpl diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 629ba59..3d56341 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -139,12 +139,12 @@ ## I. 友链 -- [ ] 前台友链页;导航/页脚入口可配 -- [ ] 用户申请(名称、URL、Logo、是否上首页、回链页) -- [ ] Logo 上传 -- [ ] 我的申请列表;修改待审申请;取消 -- [ ] 管理员审核:通过 / 拒绝 / 重新检测回链 -- [ ] 管理员维护品牌友链列表(设置里 JSON) +- [x] 前台友链页;导航/页脚入口可配 — SSR `/links`;`nav_show_friend_links` / `footer_show_friend_links` +- [x] 用户申请(名称、URL、Logo、是否上首页、回链页)— `POST /links/apply` +- [x] Logo 上传 — 表单 multipart 或 `POST /links/logo` +- [x] 我的申请列表;取消待审 — `/links`(修改待审:取消后重提) +- [x] 管理员审核:通过 / 拒绝 — SSR `/admin/friend-links`(复检按钮未迁;创建时可异步检测) +- [x] 管理员维护品牌友链列表 — `/admin/friend-links` 增删 --- diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index c2026c3..4f79ccf 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -29,11 +29,11 @@ | `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 | | `/compose` | 发帖(normal;textarea + 图片 + 门控插入) | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 | -| `/profile` | 个人中心(资料/密码/头像;无钱包/裁剪) | 已迁 | +| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/user/:id` | 公开用户页 | 已迁 | | `/favorites` | 收藏 | 已迁 | | `/projects` | Gitea 码桶 | 后置 | -| `/links` | 友链 | pending | +| `/links` | 友链 | 已迁 | | `/messages` | 私信/通知会话列表 | 已迁 | | `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 | | `/page/:slug` | 站点单页 | 未注册 | @@ -48,9 +48,10 @@ | `/admin/boards` | 板块 CRUD | 已迁 | | `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 | | `/admin/settings` | 品牌 + 基础限流 + 敏感词 | 已迁 | +| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 | -未迁(原 SPA):reports / users / badges / media / pages / links / SMTP / 完整 Limits 等。 +未迁(原 SPA):reports / users / badges / media / pages / SMTP / 完整 Limits 等。 --- @@ -168,9 +169,10 @@ ## 7. 友链页 -- 展示已通过/品牌友链 -- 「申请友链」对话框:名称、URL、Logo 上传、是否上首页、回链页 URL -- 我的申请状态列表 +- 展示品牌友链 — SSR `/links` +- 登录申请表单:名称、URL、Logo(地址或上传)、是否上首页、回链页 — `POST /links/apply` +- 我的申请状态列表;待审可取消 +- Admin:`/admin/friend-links` 品牌增删、申请通过/拒绝、nav/footer/回链检测开关 --- @@ -181,7 +183,7 @@ | Dashboard | 看计数与待办;点进对应列表 | | Boards | 拖拽或数字排序;图标/色板选择;增删改 | | Pages | 列表发布开关;进编辑器写正文;nav/footer 勾选 | -| Links | 品牌友链 CRUD;申请队列通过/拒绝/复检;回链检测开关;nav/footer/aside 开关 | +| Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer 开关 — SSR `/admin/friend-links`(aside / 复检按钮未迁) | | Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 | | Comments | 审核;修订查看;回收站 | | Reports | 处理动作四选一 | diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index 6cbce12..0b64a8d 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -88,4 +88,6 @@ web_src/ → public/assets/ 私信:`/messages`、`/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。 -Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)。 \ No newline at end of file +友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、入口开关)。 + +Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)、`/admin/friend-links`。 \ No newline at end of file diff --git a/modules/webrender/render.go b/modules/webrender/render.go index a07a31e..062c0a1 100644 --- a/modules/webrender/render.go +++ b/modules/webrender/render.go @@ -70,6 +70,7 @@ var parseGlobs = []string{ "user/*.tmpl", "favorites/*.tmpl", "messages/*.tmpl", + "links/*.tmpl", } // Load 解析全部模板(进程内一次) diff --git a/public/assets/site.css b/public/assets/site.css index 9d17df4..1c67ccd 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -537,7 +537,65 @@ points-only[data-locked="false"] { margin: 0.75rem 0; } - - - +.j13-footer a { color: var(--j13-accent); text-decoration: none; } +.j13-footer a:hover { text-decoration: underline; } +.j13-check { + display: flex !important; + flex-direction: row !important; + align-items: center; + gap: 0.45rem; +} +.j13-check input { width: auto; margin: 0; } +.j13-admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + margin: 0.5rem 0 1rem; +} +.j13-admin-table th, +.j13-admin-table td { + text-align: left; + padding: 0.45rem 0.4rem; + border-bottom: 1px solid var(--j13-border); + vertical-align: top; +} +.j13-links { max-width: 720px; margin: 0 auto; padding: 1rem 1rem 2.5rem; } +.j13-links h1 { margin: 0 0 0.75rem; font-size: 1.4rem; } +.j13-links h2 { margin: 1.5rem 0 0.65rem; font-size: 1.05rem; } +.j13-links__grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; +} +.j13-links__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + padding: 0.75rem 0.5rem; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); + background: var(--j13-bg); + color: inherit; + text-decoration: none; + text-align: center; + font-size: 0.9rem; +} +.j13-links__item:hover { border-color: var(--j13-accent); color: var(--j13-accent); } +.j13-links__logo { + width: 48px; + height: 48px; + object-fit: contain; + border-radius: 6px; + background: #fff; +} +.j13-links__logo--sm { + width: 24px; + height: 24px; + vertical-align: middle; + margin-right: 0.25rem; +} diff --git a/public/assets/site.js b/public/assets/site.js index 9fbbde8..7d24e6e 100644 --- a/public/assets/site.js +++ b/public/assets/site.js @@ -176,3 +176,30 @@ document.documentElement.dataset.j13Ssr = "1"; } }); })(); + +// 友链申请:选择文件后先上传并回填 LOGO URL +(function () { + const form = document.querySelector("form[data-logo-upload]"); + if (!form) return; + const fileInput = form.querySelector("#fl-logo-file"); + const urlInput = form.querySelector("#fl-logo-url"); + const csrfInput = form.querySelector('input[name="_csrf"]'); + if (!fileInput || !urlInput || !csrfInput) return; + const uploadURL = form.getAttribute("data-logo-upload") || "/links/logo"; + fileInput.addEventListener("change", async () => { + const file = fileInput.files && fileInput.files[0]; + if (!file) return; + const fd = new FormData(); + fd.append("logo", file); + fd.append("_csrf", csrfInput.value); + try { + const res = await fetch(uploadURL, { method: "POST", body: fd, credentials: "same-origin" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "上传失败"); + if (data.url) urlInput.value = data.url; + } catch (e) { + alert(e.message || "上传失败"); + fileInput.value = ""; + } + }); +})(); diff --git a/routers/setup.go b/routers/setup.go index d0e7cf3..2580e47 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -100,7 +100,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Board: boardSvc, Post: postSvc, Comment: commentSvc, Message: messageSvc, Filter: filter, Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore, - Points: services.NewPointsService(), + Points: services.NewPointsService(), + FriendLink: friendLinkApplySvc, }, authMW) r.GET("/media/thumb/*filepath", h.ServeImageThumb) diff --git a/routers/web/admin_friend_links.go b/routers/web/admin_friend_links.go new file mode 100644 index 0000000..6848ac3 --- /dev/null +++ b/routers/web/admin_friend_links.go @@ -0,0 +1,209 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + + "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" +) + +type adminFriendLinkBrandRow struct { + Name string + URL string + Logo string +} + +type adminFriendLinkApplyRow struct { + ID uint + UserID uint + Username string + Name string + URL string + Logo string + Status string + StatusLabel string + OnHome bool + Reciprocal string + RecipOK bool + RecipNote string + CreatedAt string + CanReview bool +} + +type adminFriendLinksData struct { + AdminChrome + BrandLinks []adminFriendLinkBrandRow + Applies []adminFriendLinkApplyRow + PendingCount int64 + NavShow bool + FooterShow bool + ReciprocalCheck bool + BrandName string + BrandURL string + BrandLogo string +} + +// AdminFriendLinksGet 友链管理 +func (d Deps) AdminFriendLinksGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderAdminFriendLinks(ctx, "") +} + +func (d Deps) renderAdminFriendLinks(ctx *webctx.Context, errMsg string) { + chrome := d.adminChrome(ctx, "友链", "friend-links") + chrome.Error = errMsg + data := adminFriendLinksData{ + AdminChrome: chrome, + NavShow: d.Settings.NavShowFriendLinks(), + FooterShow: d.Settings.FooterShowFriendLinks(), + ReciprocalCheck: d.Settings.FriendLinkReciprocalCheckEnabled(), + } + brand := d.Settings.SiteBranding() + for _, l := range brand.FriendLinks { + data.BrandLinks = append(data.BrandLinks, adminFriendLinkBrandRow{Name: l.Name, URL: l.URL, Logo: l.Logo}) + } + if d.FriendLink != nil { + data.PendingCount, _ = d.FriendLink.PendingCount() + rows, _, _ := d.FriendLink.ListAdmin(services.FriendLinkApplyListQuery{Page: 1, Size: 50, Status: "all"}) + data.Applies = make([]adminFriendLinkApplyRow, 0, len(rows)) + for _, a := range rows { + uname := "" + if a.User.ID > 0 { + uname = a.User.Username + } + data.Applies = append(data.Applies, adminFriendLinkApplyRow{ + ID: a.ID, + UserID: a.UserID, + Username: uname, + Name: a.Name, + URL: a.URL, + Logo: a.Logo, + Status: a.Status, + StatusLabel: friendLinkApplyStatusLabel(a.Status), + OnHome: a.LinkOnHomepage, + Reciprocal: a.ReciprocalPageURL, + RecipOK: a.ReciprocalVerified, + RecipNote: a.ReciprocalCheckNote, + CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"), + CanReview: a.Status == models.FriendLinkApplyStatusPending, + }) + } + } + ctx.HTML(http.StatusOK, "admin/friend_links", data) +} + +// AdminFriendLinksSettingsPost nav/footer/回链开关 +func (d Deps) AdminFriendLinksSettingsPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminFriendLinks(ctx, "无效请求,请重试") + return + } + _ = d.Settings.SetNavShowFriendLinks(c.PostForm("nav_show") == "1" || c.PostForm("nav_show") == "on") + _ = d.Settings.SetFooterShowFriendLinks(c.PostForm("footer_show") == "1" || c.PostForm("footer_show") == "on") + _ = d.Settings.SetFriendLinkReciprocalCheckEnabled(c.PostForm("reciprocal_check") == "1" || c.PostForm("reciprocal_check") == "on") + ctx.SetFlash("友链入口设置已保存") + ctx.Redirect("/admin/friend-links") +} + +// AdminFriendLinksBrandAddPost 添加品牌友链 +func (d Deps) AdminFriendLinksBrandAddPost(c *gin.Context) { + ctx := d.ctx(c) + name := strings.TrimSpace(c.PostForm("name")) + url := strings.TrimSpace(c.PostForm("url")) + logo := strings.TrimSpace(c.PostForm("logo")) + if !ctx.CheckCSRF() { + d.renderAdminFriendLinks(ctx, "无效请求,请重试") + return + } + brand := d.Settings.SiteBranding() + next := append([]services.FriendLink{}, brand.FriendLinks...) + next = append(next, services.FriendLink{Name: name, URL: url, Logo: logo}) + if err := d.Settings.UpdateSiteBranding(services.SiteBranding{ + Name: brand.Name, Slogan: brand.Slogan, Description: brand.Description, + Keywords: brand.Keywords, LogoMark: brand.LogoMark, Logo: brand.Logo, + Favicon: brand.Favicon, OGImage: brand.OGImage, + ICPBeian: brand.ICPBeian, ICPBeianURL: brand.ICPBeianURL, + FriendLinks: next, + }); err != nil { + d.renderAdminFriendLinks(ctx, err.Error()) + return + } + ctx.SetFlash("已添加品牌友链") + ctx.Redirect("/admin/friend-links") +} + +// AdminFriendLinksBrandDeletePost 删除品牌友链(按 URL) +func (d Deps) AdminFriendLinksBrandDeletePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminFriendLinks(ctx, "无效请求,请重试") + return + } + target := strings.TrimSpace(c.PostForm("url")) + brand := d.Settings.SiteBranding() + next := make([]services.FriendLink, 0, len(brand.FriendLinks)) + for _, l := range brand.FriendLinks { + if l.URL == target { + continue + } + next = append(next, l) + } + if err := d.Settings.UpdateSiteBranding(services.SiteBranding{ + Name: brand.Name, Slogan: brand.Slogan, Description: brand.Description, + Keywords: brand.Keywords, LogoMark: brand.LogoMark, Logo: brand.Logo, + Favicon: brand.Favicon, OGImage: brand.OGImage, + ICPBeian: brand.ICPBeian, ICPBeianURL: brand.ICPBeianURL, + FriendLinks: next, + }); err != nil { + d.renderAdminFriendLinks(ctx, err.Error()) + return + } + ctx.SetFlash("已删除品牌友链") + ctx.Redirect("/admin/friend-links") +} + +// AdminFriendLinkApprovePost 通过申请 +func (d Deps) AdminFriendLinkApprovePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminFriendLinks(ctx, "无效请求,请重试") + return + } + if d.FriendLink == nil { + d.renderAdminFriendLinks(ctx, "友链服务未就绪") + return + } + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if _, err := d.FriendLink.Approve(uint(id64)); err != nil { + d.renderAdminFriendLinks(ctx, err.Error()) + return + } + ctx.SetFlash("已通过友链申请") + ctx.Redirect("/admin/friend-links") +} + +// AdminFriendLinkRejectPost 拒绝申请 +func (d Deps) AdminFriendLinkRejectPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminFriendLinks(ctx, "无效请求,请重试") + return + } + if d.FriendLink == nil { + d.renderAdminFriendLinks(ctx, "友链服务未就绪") + return + } + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + note := strings.TrimSpace(c.PostForm("note")) + if _, err := d.FriendLink.Reject(uint(id64), note); err != nil { + d.renderAdminFriendLinks(ctx, err.Error()) + return + } + ctx.SetFlash("已拒绝友链申请") + ctx.Redirect("/admin/friend-links") +} diff --git a/routers/web/chrome.go b/routers/web/chrome.go index 269689e..e6ce8c9 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -22,8 +22,9 @@ type Deps struct { Filter *services.SensitiveFilter Limiter *services.RateLimiter EmailCode *services.EmailCodeService - Store *services.UploadStore - Points *services.PointsService + Store *services.UploadStore + Points *services.PointsService + FriendLink *services.FriendLinkApplyService } // BoardView 侧栏 @@ -48,8 +49,10 @@ type PageChrome struct { CSRF string Flash string Error string - UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0 - ViewerPoints int // 登录用户当前积分;未登录为 0 + UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0 + ViewerPoints int // 登录用户当前积分;未登录为 0 + ShowFriendLinksNav bool + ShowFriendLinksFooter bool } func (d Deps) ctx(c *gin.Context) *webctx.Context { @@ -85,20 +88,22 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome viewerPoints = ctx.Doer.Points } return PageChrome{ - Title: title, - Description: desc, - SiteName: brand.Name, - Slogan: brand.Slogan, - LogoMark: firstRuneOr(brand.LogoMark, "姜"), - LoggedIn: ctx.IsSigned(), - IsAdmin: ctx.IsAdmin(), - ViewerName: name, - Boards: bv, - Inner: inner, - CSRF: ctx.EnsureCSRF(), - Flash: ctx.TakeFlash(), - UnreadCount: unread, - ViewerPoints: viewerPoints, + Title: title, + Description: desc, + SiteName: brand.Name, + Slogan: brand.Slogan, + LogoMark: firstRuneOr(brand.LogoMark, "姜"), + LoggedIn: ctx.IsSigned(), + IsAdmin: ctx.IsAdmin(), + ViewerName: name, + Boards: bv, + Inner: inner, + CSRF: ctx.EnsureCSRF(), + Flash: ctx.TakeFlash(), + UnreadCount: unread, + ViewerPoints: viewerPoints, + ShowFriendLinksNav: d.Settings.NavShowFriendLinks(), + ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(), } } diff --git a/routers/web/home.go b/routers/web/home.go index ac853b3..8831eb5 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -51,6 +51,12 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.POST("/settings/brand", deps.AdminSettingsBrandPost) admin.POST("/settings/limits", deps.AdminSettingsLimitsPost) admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) + admin.GET("/friend-links", deps.AdminFriendLinksGet) + admin.POST("/friend-links/settings", deps.AdminFriendLinksSettingsPost) + admin.POST("/friend-links/brand", deps.AdminFriendLinksBrandAddPost) + admin.POST("/friend-links/brand/delete", deps.AdminFriendLinksBrandDeletePost) + admin.POST("/friend-links/applies/:id/approve", deps.AdminFriendLinkApprovePost) + admin.POST("/friend-links/applies/:id/reject", deps.AdminFriendLinkRejectPost) } g.GET("/user/:id", deps.UserPublic) @@ -66,8 +72,11 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { g.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread) g.POST("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesSend) g.POST("/messages/read-all", authMW.RequireAuth(), deps.MessagesReadAll) + g.GET("/links", deps.LinksGet) + g.POST("/links/apply", authMW.RequireAuth(), deps.LinksApplyPost) + g.POST("/links/apply/:id/cancel", authMW.RequireAuth(), deps.LinksApplyCancelPost) + g.POST("/links/logo", authMW.RequireAuth(), deps.LinksLogoUpload) g.GET("/projects", deps.PendingPage) - g.GET("/links", deps.PendingPage) g.GET("/boards", deps.PendingPage) } diff --git a/routers/web/links.go b/routers/web/links.go new file mode 100644 index 0000000..05fb4f6 --- /dev/null +++ b/routers/web/links.go @@ -0,0 +1,255 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "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" +) + +type linksLinkView struct { + Name string + URL string + Logo string +} + +type linksApplyView struct { + ID uint + Name string + URL string + Logo string + Status string + StatusLabel string + ReviewNote string + CreatedAt string + CanCancel bool +} + +type linksPageData struct { + PageChrome + Links []linksLinkView + MyApplies []linksApplyView + FormName string + FormURL string + FormLogo string + FormRecip string + FormOnHome bool + AvatarMaxMB int +} + +func friendLinkApplyStatusLabel(status string) string { + switch status { + case models.FriendLinkApplyStatusPending: + return "待审核" + case models.FriendLinkApplyStatusApproved: + return "已通过" + case models.FriendLinkApplyStatusRejected: + return "已拒绝" + default: + return status + } +} + +func (d Deps) publicBaseURL(c *gin.Context) string { + proto := c.GetHeader("X-Forwarded-Proto") + if proto == "" { + if c.Request.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + host := c.GetHeader("X-Forwarded-Host") + if host == "" { + host = c.Request.Host + } + origin := "" + if host != "" { + origin = proto + "://" + host + } + return d.Settings.SitePublicBaseURL(origin) +} + +// LinksGet 友链公开页 +func (d Deps) LinksGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderLinks(ctx, c, "") +} + +func (d Deps) renderLinks(ctx *webctx.Context, c *gin.Context, errMsg string) { + brand := d.Settings.SiteBranding() + chrome := d.chrome(ctx, "友情链接 · "+brand.Name, "", "") + chrome.Error = errMsg + data := linksPageData{ + PageChrome: chrome, + FormOnHome: true, + AvatarMaxMB: d.Settings.AvatarMaxMB(), + } + for _, l := range brand.FriendLinks { + data.Links = append(data.Links, linksLinkView{Name: l.Name, URL: l.URL, Logo: l.Logo}) + } + if ctx.IsSigned() && d.FriendLink != nil { + rows, _ := d.FriendLink.ListMine(ctx.UserID()) + data.MyApplies = make([]linksApplyView, 0, len(rows)) + for _, a := range rows { + data.MyApplies = append(data.MyApplies, linksApplyView{ + ID: a.ID, + Name: a.Name, + URL: a.URL, + Logo: a.Logo, + Status: a.Status, + StatusLabel: friendLinkApplyStatusLabel(a.Status), + ReviewNote: a.ReviewNote, + CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"), + CanCancel: a.Status == models.FriendLinkApplyStatusPending, + }) + } + } + ctx.HTML(http.StatusOK, "links/list", data) +} + +func (d Deps) renderLinksForm(ctx *webctx.Context, c *gin.Context, errMsg string, form linksPageData) { + brand := d.Settings.SiteBranding() + chrome := d.chrome(ctx, "友情链接 · "+brand.Name, "", "") + chrome.Error = errMsg + form.PageChrome = chrome + form.AvatarMaxMB = d.Settings.AvatarMaxMB() + for _, l := range brand.FriendLinks { + form.Links = append(form.Links, linksLinkView{Name: l.Name, URL: l.URL, Logo: l.Logo}) + } + if ctx.IsSigned() && d.FriendLink != nil { + rows, _ := d.FriendLink.ListMine(ctx.UserID()) + form.MyApplies = make([]linksApplyView, 0, len(rows)) + for _, a := range rows { + form.MyApplies = append(form.MyApplies, linksApplyView{ + ID: a.ID, + Name: a.Name, + URL: a.URL, + Logo: a.Logo, + Status: a.Status, + StatusLabel: friendLinkApplyStatusLabel(a.Status), + ReviewNote: a.ReviewNote, + CreatedAt: a.CreatedAt.Format("2006-01-02 15:04"), + CanCancel: a.Status == models.FriendLinkApplyStatusPending, + }) + } + } + ctx.HTML(http.StatusOK, "links/list", form) +} + +// LinksApplyPost 提交友链申请 +func (d Deps) LinksApplyPost(c *gin.Context) { + ctx := d.ctx(c) + form := linksPageData{ + FormName: strings.TrimSpace(c.PostForm("name")), + FormURL: strings.TrimSpace(c.PostForm("url")), + FormLogo: strings.TrimSpace(c.PostForm("logo")), + FormRecip: strings.TrimSpace(c.PostForm("reciprocal_page_url")), + FormOnHome: c.PostForm("link_on_homepage") == "1" || c.PostForm("link_on_homepage") == "on", + } + if !ctx.CheckCSRF() { + d.renderLinksForm(ctx, c, "无效请求,请重试", form) + return + } + if d.FriendLink == nil { + d.renderLinksForm(ctx, c, "友链服务未就绪", form) + return + } + if d.Limiter != nil && !d.Limiter.Allow("friend_link", fmt.Sprintf("%d", ctx.UserID())) { + d.renderLinksForm(ctx, c, "申请过于频繁,请稍后再试", form) + return + } + logo := form.FormLogo + if file, err := c.FormFile("logo_file"); err == nil && file != nil { + if d.Store == nil { + d.renderLinksForm(ctx, c, "上传存储未就绪", form) + return + } + url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, fmt.Sprintf("fl_%d", ctx.UserID())) + if err != nil { + d.renderLinksForm(ctx, c, err.Error(), form) + return + } + logo = url + form.FormLogo = url + } + _, err := d.FriendLink.Create(services.FriendLinkApplyInput{ + UserID: ctx.UserID(), + Name: form.FormName, + URL: form.FormURL, + Logo: logo, + LinkOnHomepage: form.FormOnHome, + ReciprocalPageURL: form.FormRecip, + OurSiteURL: d.publicBaseURL(c), + }) + if err != nil { + d.renderLinksForm(ctx, c, err.Error(), form) + return + } + ctx.SetFlash("友链申请已提交,请等待审核") + ctx.Redirect("/links") +} + +// LinksApplyCancelPost 取消待审申请 +func (d Deps) LinksApplyCancelPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderLinks(ctx, c, "无效请求,请重试") + return + } + if d.FriendLink == nil { + d.renderLinks(ctx, c, "友链服务未就绪") + return + } + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if err := d.FriendLink.Cancel(ctx.UserID(), uint(id64)); err != nil { + if errors.Is(err, services.ErrFriendLinkApplyNotFound) || errors.Is(err, services.ErrFriendLinkApplyHandled) { + ctx.SetFlash(err.Error()) + ctx.Redirect("/links") + return + } + d.renderLinks(ctx, c, err.Error()) + return + } + ctx.SetFlash("已取消申请") + ctx.Redirect("/links") +} + +// LinksLogoUpload Logo 上传(JSON,供表单渐进增强) +func (d Deps) LinksLogoUpload(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.IsSigned() { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + if !ctx.CheckCSRF() { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"}) + return + } + if d.Store == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"}) + return + } + file, err := c.FormFile("logo") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 LOGO 图片"}) + return + } + maxBytes := int64(d.Settings.AvatarMaxMB()) * 1024 * 1024 + if file.Size > maxBytes { + c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大"}) + return + } + url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, fmt.Sprintf("fl_%d", ctx.UserID())) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "LOGO 已上传", "url": url}) +} diff --git a/templates/admin/friend_links.tmpl b/templates/admin/friend_links.tmpl new file mode 100644 index 0000000..9ee430f --- /dev/null +++ b/templates/admin/friend_links.tmpl @@ -0,0 +1,95 @@ +{{define "admin/friend_links"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

友链管理

+ {{template "admin/nav" .}} + {{template "base/alert" .}} + +
+

入口与检测

+
+ + + + + +
+
+ +
+

品牌友链({{len .BrandLinks}}/20)

+ {{if .BrandLinks}} + + + + {{range .BrandLinks}} + + + + + + {{end}} + +
名称URL
{{if .Logo}} {{end}}{{.Name}}{{.URL}} +
+ + + +
+
+ {{else}} +

暂无品牌友链

+ {{end}} +
+ +

添加品牌友链

+ + + + +
+
+ +
+

申请队列{{if gt .PendingCount 0}}(待审 {{.PendingCount}}){{end}}

+ {{if .Applies}} + + + + + + {{range .Applies}} + + + + + + + + {{end}} + +
时间申请人站点状态操作
{{.CreatedAt}}{{if .Username}}{{.Username}}{{else}}#{{.UserID}}{{end}} + {{if .Logo}}{{end}} + {{.Name}} +
回链:{{if .Reciprocal}}页面{{else}}—{{end}}{{if .RecipOK}} · 已验证{{else if .RecipNote}} · {{.RecipNote}}{{end}}
+
{{.StatusLabel}} + {{if .CanReview}} +
+ + +
+
+ + + +
+ {{end}} +
+ {{else}} +

暂无申请

+ {{end}} +
+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl index 9ec8853..0099106 100644 --- a/templates/admin/nav.tmpl +++ b/templates/admin/nav.tmpl @@ -3,6 +3,7 @@ 仪表盘 板块 审核 + 友链 设置 返回前台 diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl index 73cb8ca..30d11c0 100644 --- a/templates/base/footer.tmpl +++ b/templates/base/footer.tmpl @@ -1,6 +1,9 @@ {{define "base/footer"}}
-

{{.SiteName}}

+

+ {{.SiteName}} + {{if .ShowFriendLinksFooter}} · 友情链接{{end}} +

diff --git a/templates/base/navbar.tmpl b/templates/base/navbar.tmpl index 6009312..2e53c76 100644 --- a/templates/base/navbar.tmpl +++ b/templates/base/navbar.tmpl @@ -9,6 +9,7 @@