feat: SSR 友链页与最小 Admin 审核
公开 /links 列表与申请/取消,导航页脚可配入口;后台品牌增删与通过/拒绝。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -139,12 +139,12 @@
|
|||||||
|
|
||||||
## I. 友链
|
## I. 友链
|
||||||
|
|
||||||
- [ ] 前台友链页;导航/页脚入口可配
|
- [x] 前台友链页;导航/页脚入口可配 — SSR `/links`;`nav_show_friend_links` / `footer_show_friend_links`
|
||||||
- [ ] 用户申请(名称、URL、Logo、是否上首页、回链页)
|
- [x] 用户申请(名称、URL、Logo、是否上首页、回链页)— `POST /links/apply`
|
||||||
- [ ] Logo 上传
|
- [x] Logo 上传 — 表单 multipart 或 `POST /links/logo`
|
||||||
- [ ] 我的申请列表;修改待审申请;取消
|
- [x] 我的申请列表;取消待审 — `/links`(修改待审:取消后重提)
|
||||||
- [ ] 管理员审核:通过 / 拒绝 / 重新检测回链
|
- [x] 管理员审核:通过 / 拒绝 — SSR `/admin/friend-links`(复检按钮未迁;创建时可异步检测)
|
||||||
- [ ] 管理员维护品牌友链列表(设置里 JSON)
|
- [x] 管理员维护品牌友链列表 — `/admin/friend-links` 增删
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,11 @@
|
|||||||
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
|
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
|
||||||
| `/compose` | 发帖(normal;textarea + 图片 + 门控插入) | 已迁 |
|
| `/compose` | 发帖(normal;textarea + 图片 + 门控插入) | 已迁 |
|
||||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
||||||
| `/profile` | 个人中心(资料/密码/头像;无钱包/裁剪) | 已迁 |
|
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
||||||
| `/user/:id` | 公开用户页 | 已迁 |
|
| `/user/:id` | 公开用户页 | 已迁 |
|
||||||
| `/favorites` | 收藏 | 已迁 |
|
| `/favorites` | 收藏 | 已迁 |
|
||||||
| `/projects` | Gitea 码桶 | 后置 |
|
| `/projects` | Gitea 码桶 | 后置 |
|
||||||
| `/links` | 友链 | pending |
|
| `/links` | 友链 | 已迁 |
|
||||||
| `/messages` | 私信/通知会话列表 | 已迁 |
|
| `/messages` | 私信/通知会话列表 | 已迁 |
|
||||||
| `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 |
|
| `/messages/with/:peerId` | 会话详情(peer=0 系统通知) | 已迁 |
|
||||||
| `/page/:slug` | 站点单页 | 未注册 |
|
| `/page/:slug` | 站点单页 | 未注册 |
|
||||||
@@ -48,9 +48,10 @@
|
|||||||
| `/admin/boards` | 板块 CRUD | 已迁 |
|
| `/admin/boards` | 板块 CRUD | 已迁 |
|
||||||
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
|
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
|
||||||
| `/admin/settings` | 品牌 + 基础限流 + 敏感词 | 已迁 |
|
| `/admin/settings` | 品牌 + 基础限流 + 敏感词 | 已迁 |
|
||||||
|
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
|
||||||
| `/admin/login` | 重定向前台登录 | 已迁 |
|
| `/admin/login` | 重定向前台登录 | 已迁 |
|
||||||
|
|
||||||
未迁(原 SPA):reports / users / badges / media / pages / links / SMTP / 完整 Limits 等。
|
未迁(原 SPA):reports / users / badges / media / pages / SMTP / 完整 Limits 等。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -168,9 +169,10 @@
|
|||||||
|
|
||||||
## 7. 友链页
|
## 7. 友链页
|
||||||
|
|
||||||
- 展示已通过/品牌友链
|
- 展示品牌友链 — SSR `/links`
|
||||||
- 「申请友链」对话框:名称、URL、Logo 上传、是否上首页、回链页 URL
|
- 登录申请表单:名称、URL、Logo(地址或上传)、是否上首页、回链页 — `POST /links/apply`
|
||||||
- 我的申请状态列表
|
- 我的申请状态列表;待审可取消
|
||||||
|
- Admin:`/admin/friend-links` 品牌增删、申请通过/拒绝、nav/footer/回链检测开关
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -181,7 +183,7 @@
|
|||||||
| Dashboard | 看计数与待办;点进对应列表 |
|
| Dashboard | 看计数与待办;点进对应列表 |
|
||||||
| Boards | 拖拽或数字排序;图标/色板选择;增删改 |
|
| Boards | 拖拽或数字排序;图标/色板选择;增删改 |
|
||||||
| Pages | 列表发布开关;进编辑器写正文;nav/footer 勾选 |
|
| Pages | 列表发布开关;进编辑器写正文;nav/footer 勾选 |
|
||||||
| Links | 品牌友链 CRUD;申请队列通过/拒绝/复检;回链检测开关;nav/footer/aside 开关 |
|
| Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer 开关 — SSR `/admin/friend-links`(aside / 复检按钮未迁) |
|
||||||
| Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 |
|
| Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 |
|
||||||
| Comments | 审核;修订查看;回收站 |
|
| Comments | 审核;修订查看;回收站 |
|
||||||
| Reports | 处理动作四选一 |
|
| Reports | 处理动作四选一 |
|
||||||
|
|||||||
@@ -88,4 +88,6 @@ web_src/ → public/assets/
|
|||||||
|
|
||||||
私信:`/messages`、`/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。
|
私信:`/messages`、`/messages/with/:peerId`(系统 peer=0 只读;打开会话标已读)。
|
||||||
|
|
||||||
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)。
|
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、入口开关)。
|
||||||
|
|
||||||
|
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)、`/admin/friend-links`。
|
||||||
@@ -70,6 +70,7 @@ var parseGlobs = []string{
|
|||||||
"user/*.tmpl",
|
"user/*.tmpl",
|
||||||
"favorites/*.tmpl",
|
"favorites/*.tmpl",
|
||||||
"messages/*.tmpl",
|
"messages/*.tmpl",
|
||||||
|
"links/*.tmpl",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 解析全部模板(进程内一次)
|
// Load 解析全部模板(进程内一次)
|
||||||
|
|||||||
@@ -537,7 +537,65 @@ points-only[data-locked="false"] {
|
|||||||
margin: 0.75rem 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
||||||
Message: messageSvc, Filter: filter,
|
Message: messageSvc, Filter: filter,
|
||||||
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
|
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
|
||||||
Points: services.NewPointsService(),
|
Points: services.NewPointsService(),
|
||||||
|
FriendLink: friendLinkApplySvc,
|
||||||
}, authMW)
|
}, authMW)
|
||||||
|
|
||||||
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
|
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
|
||||||
|
|||||||
209
routers/web/admin_friend_links.go
Normal file
209
routers/web/admin_friend_links.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -22,8 +22,9 @@ type Deps struct {
|
|||||||
Filter *services.SensitiveFilter
|
Filter *services.SensitiveFilter
|
||||||
Limiter *services.RateLimiter
|
Limiter *services.RateLimiter
|
||||||
EmailCode *services.EmailCodeService
|
EmailCode *services.EmailCodeService
|
||||||
Store *services.UploadStore
|
Store *services.UploadStore
|
||||||
Points *services.PointsService
|
Points *services.PointsService
|
||||||
|
FriendLink *services.FriendLinkApplyService
|
||||||
}
|
}
|
||||||
|
|
||||||
// BoardView 侧栏
|
// BoardView 侧栏
|
||||||
@@ -48,8 +49,10 @@ type PageChrome struct {
|
|||||||
CSRF string
|
CSRF string
|
||||||
Flash string
|
Flash string
|
||||||
Error string
|
Error string
|
||||||
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
|
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
|
||||||
ViewerPoints int // 登录用户当前积分;未登录为 0
|
ViewerPoints int // 登录用户当前积分;未登录为 0
|
||||||
|
ShowFriendLinksNav bool
|
||||||
|
ShowFriendLinksFooter bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d Deps) ctx(c *gin.Context) *webctx.Context {
|
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
|
viewerPoints = ctx.Doer.Points
|
||||||
}
|
}
|
||||||
return PageChrome{
|
return PageChrome{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: desc,
|
Description: desc,
|
||||||
SiteName: brand.Name,
|
SiteName: brand.Name,
|
||||||
Slogan: brand.Slogan,
|
Slogan: brand.Slogan,
|
||||||
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
|
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
|
||||||
LoggedIn: ctx.IsSigned(),
|
LoggedIn: ctx.IsSigned(),
|
||||||
IsAdmin: ctx.IsAdmin(),
|
IsAdmin: ctx.IsAdmin(),
|
||||||
ViewerName: name,
|
ViewerName: name,
|
||||||
Boards: bv,
|
Boards: bv,
|
||||||
Inner: inner,
|
Inner: inner,
|
||||||
CSRF: ctx.EnsureCSRF(),
|
CSRF: ctx.EnsureCSRF(),
|
||||||
Flash: ctx.TakeFlash(),
|
Flash: ctx.TakeFlash(),
|
||||||
UnreadCount: unread,
|
UnreadCount: unread,
|
||||||
ViewerPoints: viewerPoints,
|
ViewerPoints: viewerPoints,
|
||||||
|
ShowFriendLinksNav: d.Settings.NavShowFriendLinks(),
|
||||||
|
ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
|
|||||||
admin.POST("/settings/brand", deps.AdminSettingsBrandPost)
|
admin.POST("/settings/brand", deps.AdminSettingsBrandPost)
|
||||||
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
|
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
|
||||||
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
|
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)
|
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.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread)
|
||||||
g.POST("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesSend)
|
g.POST("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesSend)
|
||||||
g.POST("/messages/read-all", authMW.RequireAuth(), deps.MessagesReadAll)
|
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("/projects", deps.PendingPage)
|
||||||
g.GET("/links", deps.PendingPage)
|
|
||||||
g.GET("/boards", deps.PendingPage)
|
g.GET("/boards", deps.PendingPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
255
routers/web/links.go
Normal file
255
routers/web/links.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
95
templates/admin/friend_links.tmpl
Normal file
95
templates/admin/friend_links.tmpl
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{{define "admin/friend_links"}}
|
||||||
|
{{template "base/head" .}}
|
||||||
|
{{template "base/navbar" .}}
|
||||||
|
<main class="j13-main j13-admin">
|
||||||
|
<h1>友链管理</h1>
|
||||||
|
{{template "admin/nav" .}}
|
||||||
|
{{template "base/alert" .}}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>入口与检测</h2>
|
||||||
|
<form method="post" action="/admin/friend-links/settings" class="j13-form j13-admin-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<label class="j13-check"><input type="checkbox" name="nav_show" value="1"{{if .NavShow}} checked{{end}}/> 导航显示友链入口</label>
|
||||||
|
<label class="j13-check"><input type="checkbox" name="footer_show" value="1"{{if .FooterShow}} checked{{end}}/> 页脚显示友链入口</label>
|
||||||
|
<label class="j13-check"><input type="checkbox" name="reciprocal_check" value="1"{{if .ReciprocalCheck}} checked{{end}}/> 启用回链检测</label>
|
||||||
|
<button type="submit">保存设置</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>品牌友链({{len .BrandLinks}}/20)</h2>
|
||||||
|
{{if .BrandLinks}}
|
||||||
|
<table class="j13-admin-table">
|
||||||
|
<thead><tr><th>名称</th><th>URL</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .BrandLinks}}
|
||||||
|
<tr>
|
||||||
|
<td>{{if .Logo}}<img src="{{.Logo}}" alt="" class="j13-links__logo j13-links__logo--sm"/> {{end}}{{.Name}}</td>
|
||||||
|
<td><a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/friend-links/brand/delete" class="j13-inline-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
||||||
|
<input type="hidden" name="url" value="{{.URL}}"/>
|
||||||
|
<button type="submit" class="j13-linkbtn">删除</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="j13-muted">暂无品牌友链</p>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/admin/friend-links/brand" class="j13-form j13-admin-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<h3>添加品牌友链</h3>
|
||||||
|
<label>名称 <input name="name" maxlength="32" required/></label>
|
||||||
|
<label>URL <input name="url" type="url" required placeholder="https://"/></label>
|
||||||
|
<label>LOGO(可选) <input name="logo" type="text" placeholder="https:// 或 /uploads/..."/></label>
|
||||||
|
<button type="submit">添加</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>申请队列{{if gt .PendingCount 0}}(待审 {{.PendingCount}}){{end}}</h2>
|
||||||
|
{{if .Applies}}
|
||||||
|
<table class="j13-admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>时间</th><th>申请人</th><th>站点</th><th>状态</th><th>操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Applies}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td>{{if .Username}}{{.Username}}{{else}}#{{.UserID}}{{end}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .Logo}}<img src="{{.Logo}}" alt="" class="j13-links__logo j13-links__logo--sm"/>{{end}}
|
||||||
|
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.Name}}</a>
|
||||||
|
<div class="j13-muted">回链:{{if .Reciprocal}}<a href="{{.Reciprocal}}" target="_blank" rel="noopener noreferrer">页面</a>{{else}}—{{end}}{{if .RecipOK}} · 已验证{{else if .RecipNote}} · {{.RecipNote}}{{end}}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{.StatusLabel}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .CanReview}}
|
||||||
|
<form method="post" action="/admin/friend-links/applies/{{.ID}}/approve" class="j13-inline-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
||||||
|
<button type="submit">通过</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/friend-links/applies/{{.ID}}/reject" class="j13-admin-reject">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
||||||
|
<input name="note" placeholder="拒绝原因(可选)"/>
|
||||||
|
<button type="submit">拒绝</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="j13-muted">暂无申请</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{{template "base/footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<a href="/admin/dashboard"{{if eq .NavActive "dashboard"}} class="is-active"{{end}}>仪表盘</a>
|
<a href="/admin/dashboard"{{if eq .NavActive "dashboard"}} class="is-active"{{end}}>仪表盘</a>
|
||||||
<a href="/admin/boards"{{if eq .NavActive "boards"}} class="is-active"{{end}}>板块</a>
|
<a href="/admin/boards"{{if eq .NavActive "boards"}} class="is-active"{{end}}>板块</a>
|
||||||
<a href="/admin/moderation"{{if eq .NavActive "moderation"}} class="is-active"{{end}}>审核</a>
|
<a href="/admin/moderation"{{if eq .NavActive "moderation"}} class="is-active"{{end}}>审核</a>
|
||||||
|
<a href="/admin/friend-links"{{if eq .NavActive "friend-links"}} class="is-active"{{end}}>友链</a>
|
||||||
<a href="/admin/settings"{{if eq .NavActive "settings"}} class="is-active"{{end}}>设置</a>
|
<a href="/admin/settings"{{if eq .NavActive "settings"}} class="is-active"{{end}}>设置</a>
|
||||||
<a href="/">返回前台</a>
|
<a href="/">返回前台</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{{define "base/footer"}}
|
{{define "base/footer"}}
|
||||||
<footer class="j13-footer">
|
<footer class="j13-footer">
|
||||||
<p>{{.SiteName}}</p>
|
<p>
|
||||||
|
{{.SiteName}}
|
||||||
|
{{if .ShowFriendLinksFooter}} · <a href="/links">友情链接</a>{{end}}
|
||||||
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/ssr-assets/site.js" defer></script>
|
<script src="/ssr-assets/site.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="j13-nav">
|
<nav class="j13-nav">
|
||||||
|
{{if .ShowFriendLinksNav}}<a href="/links">友链</a>{{end}}
|
||||||
{{if .LoggedIn}}
|
{{if .LoggedIn}}
|
||||||
<a href="/compose">发帖</a>
|
<a href="/compose">发帖</a>
|
||||||
<a href="/messages">私信{{if gt .UnreadCount 0}} <span class="j13-badge">{{.UnreadCount}}</span>{{end}}</a>
|
<a href="/messages">私信{{if gt .UnreadCount 0}} <span class="j13-badge">{{.UnreadCount}}</span>{{end}}</a>
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ package templates
|
|||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl profile/*.tmpl user/*.tmpl favorites/*.tmpl messages/*.tmpl
|
//go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl profile/*.tmpl user/*.tmpl favorites/*.tmpl messages/*.tmpl links/*.tmpl
|
||||||
var FS embed.FS
|
var FS embed.FS
|
||||||
|
|||||||
84
templates/links/list.tmpl
Normal file
84
templates/links/list.tmpl
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{{define "links/list"}}
|
||||||
|
{{template "base/head" .}}
|
||||||
|
{{template "base/navbar" .}}
|
||||||
|
<main class="j13-main j13-links">
|
||||||
|
<h1>友情链接</h1>
|
||||||
|
{{template "base/alert" .}}
|
||||||
|
|
||||||
|
<section class="j13-links__list">
|
||||||
|
{{if .Links}}
|
||||||
|
<ul class="j13-links__grid">
|
||||||
|
{{range .Links}}
|
||||||
|
<li>
|
||||||
|
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer" class="j13-links__item">
|
||||||
|
{{if .Logo}}<img src="{{.Logo}}" alt="" class="j13-links__logo"/>{{end}}
|
||||||
|
<span>{{.Name}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="j13-muted">暂无友情链接</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .LoggedIn}}
|
||||||
|
<section class="j13-links__apply">
|
||||||
|
<h2>申请友链</h2>
|
||||||
|
<form method="post" action="/links/apply" enctype="multipart/form-data" class="j13-form j13-admin-form" data-logo-upload="/links/logo">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<label>站点名称 <input name="name" value="{{.FormName}}" maxlength="32" required/></label>
|
||||||
|
<label>站点 URL <input name="url" type="url" value="{{.FormURL}}" placeholder="https://" required/></label>
|
||||||
|
<label>LOGO 地址 <input name="logo" id="fl-logo-url" type="text" value="{{.FormLogo}}" placeholder="https:// 或先上传"/></label>
|
||||||
|
<label>或上传 LOGO(最大 {{.AvatarMaxMB}} MB)
|
||||||
|
<input type="file" name="logo_file" id="fl-logo-file" accept="image/*"/>
|
||||||
|
</label>
|
||||||
|
<label class="j13-check">
|
||||||
|
<input type="checkbox" name="link_on_homepage" value="1"{{if .FormOnHome}} checked{{end}}/>
|
||||||
|
已在对方首页放置本站链接
|
||||||
|
</label>
|
||||||
|
<label>回链页 URL(未勾选上一项时必填)
|
||||||
|
<input name="reciprocal_page_url" type="url" value="{{.FormRecip}}" placeholder="https://"/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">提交申请</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="j13-links__mine">
|
||||||
|
<h2>我的申请</h2>
|
||||||
|
{{if .MyApplies}}
|
||||||
|
<table class="j13-profile__ledger">
|
||||||
|
<thead>
|
||||||
|
<tr><th>时间</th><th>站点</th><th>状态</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .MyApplies}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.Name}}</a>
|
||||||
|
{{if .ReviewNote}}<div class="j13-muted">{{.ReviewNote}}</div>{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{.StatusLabel}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .CanCancel}}
|
||||||
|
<form method="post" action="/links/apply/{{.ID}}/cancel" class="j13-inline-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
||||||
|
<button type="submit" class="j13-linkbtn">取消</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="j13-muted">暂无申请记录</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{else}}
|
||||||
|
<p class="j13-muted"><a href="/login?redirect=/links">登录</a> 后可申请友情链接。</p>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
{{template "base/footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -537,7 +537,65 @@ points-only[data-locked="false"] {
|
|||||||
margin: 0.75rem 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user