Files
jiang13-forum/routers/web/chrome.go
freefire 2ba010f3ea feat: 首页对齐 main SPA 草绿壳与 Feed v2
顶栏搜索胶囊/发帖绿钮/Ctrl+K,侧栏板块色槽,列表 post-row--v2 与 SPA 同密度。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 06:06:04 +08:00

387 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
"git.iioio.com/freefire/jiang13-forum/services"
"github.com/gin-gonic/gin"
)
// Deps 页面依赖
type Deps struct {
DataDir string
JWTSecret string
AssetVersion string // 静态资源 ?v=;来自构建 ldflags
Settings *services.ForumSettingsService
Auth *services.AuthService
User *services.UserService
Board *services.BoardService
Post *services.PostService
Comment *services.CommentService
Message *services.MessageService
Filter *services.SensitiveFilter
Limiter *services.RateLimiter
EmailCode *services.EmailCodeService
Store *services.UploadStore
Points *services.PointsService
Badge *services.BadgeService
FriendLink *services.FriendLinkApplyService
Mail *services.MailService
SitePage *services.SitePageService
Report *services.ReportService
Captcha *services.CaptchaService
Notify *services.NotifyService
Backup *services.BackupService
}
// SitePageLink 导航/页脚站点单页链接
type SitePageLink struct {
Title string
Slug string
}
// BoardView 侧栏
type BoardView struct {
ID uint
Name string
Href string
PostCount int
ColorSlot int // 07 板块色槽
}
// RightAsideHotPost 右栏热门帖
type RightAsideHotPost struct {
ID uint
Title string
Href string
}
// RightAsideTag 右栏标签
type RightAsideTag struct {
Name string
Count int
}
// RightAsideComment 右栏最新评论
type RightAsideComment struct {
PostID uint
PostHref string
Author string
Excerpt string
PostTitle string
}
// RightAsideUser 右栏最新用户
type RightAsideUser struct {
ID uint
Nickname string
Href string
}
// RightAsideFriendLink 右栏友链
type RightAsideFriendLink struct {
Name string
URL string
Logo string
}
// RightAsideWidget 按 aside_widgets 顺序的一块(仅已开启)
type RightAsideWidget struct {
Kind string // tag_cloud | recent_comments | recent_users | friend_links
Tags []RightAsideTag
Comments []RightAsideComment
Users []RightAsideUser
FriendLinks []RightAsideFriendLink
}
// RightAsideData 右栏:签到条 + 固定热门 + 可配置 widgets
type RightAsideData struct {
ShowCheckIn bool
CheckedIn bool
CheckInStreak int
CheckInPoints int // 已签实得或预计可得
LotteryDrawn bool
LotteryPoints int
LotteryCost int
HotPosts []RightAsideHotPost
Widgets []RightAsideWidget
}
// PageChrome 布局公共字段
type PageChrome struct {
Title string
Description string
SiteName string
Slogan string
LogoMark string
LogoURL string
FaviconURL string
OGImageURL string
PermalinkSuffix string // 如 ".html";未启用为空
OpenPostsInNewTab bool
LoggedIn bool
IsAdmin bool
ViewerName string
ViewerAvatar string
ViewerMark string
Boards []BoardView
ActiveBoard uint
Path string // 当前请求路径,供侧栏高亮
Inner string // 保留字段;入口模板已固定组合,不再动态 template
CSRF string
Flash string
Error string
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
ViewerPoints int // 登录用户当前积分;未登录为 0
ShowFriendLinksNav bool
ShowFriendLinksFooter bool
NavPages []SitePageLink
FooterPages []SitePageLink
RightAside RightAsideData
AssetVersion string
}
func (d Deps) ctx(c *gin.Context) *webctx.Context {
return webctx.New(c, d.JWTSecret)
}
func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome {
brand := d.Settings.SiteBranding()
if title == "" {
title = brand.DocumentTitle()
}
if desc == "" {
desc = brand.MetaDescription()
}
name := "我的"
if ctx.IsSigned() {
name = strings.TrimSpace(ctx.Doer.Nickname)
if name == "" {
name = ctx.Doer.Username
}
}
boards, _ := d.Board.ListWithStats()
bv := make([]BoardView, 0, len(boards))
pl := d.permalink()
for _, b := range boards {
slot := b.ColorIndex
if slot < 0 {
slot = int(b.ID % 8)
}
bv = append(bv, BoardView{
ID: b.ID, Name: b.Name, Href: pl.BoardPath(b.ID),
PostCount: b.PostCount, ColorSlot: slot % 8,
})
}
var unread int64
if ctx.IsSigned() && d.Message != nil {
unread, _ = d.Message.UnreadCount(ctx.UserID())
}
viewerPoints := 0
viewerAvatar := ""
viewerMark := "我"
if ctx.IsSigned() && ctx.Doer != nil {
viewerPoints = ctx.Doer.Points
viewerAvatar = strings.TrimSpace(ctx.Doer.Avatar)
viewerMark = firstRuneOr(name, "我")
}
path := ""
if ctx.C != nil && ctx.C.Request != nil && ctx.C.Request.URL != nil {
path = ctx.C.Request.URL.Path
}
navPages, footerPages := d.sitePageLinks()
assetVer := strings.TrimSpace(d.AssetVersion)
if assetVer == "" {
assetVer = "dev"
}
return PageChrome{
Title: title,
Description: desc,
SiteName: brand.Name,
Slogan: brand.Slogan,
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
LogoURL: strings.TrimSpace(brand.Logo),
FaviconURL: strings.TrimSpace(brand.Favicon),
OGImageURL: strings.TrimSpace(brand.DefaultShareImage()),
PermalinkSuffix: d.Settings.Permalink().Suffix(),
OpenPostsInNewTab: d.Settings.OpenPostsInNewTab(),
LoggedIn: ctx.IsSigned(),
IsAdmin: ctx.IsAdmin(),
ViewerName: name,
ViewerAvatar: viewerAvatar,
ViewerMark: viewerMark,
Boards: bv,
Path: path,
Inner: inner,
CSRF: ctx.EnsureCSRF(),
Flash: ctx.TakeFlash(),
UnreadCount: unread,
ViewerPoints: viewerPoints,
ShowFriendLinksNav: d.Settings.NavShowFriendLinks(),
ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(),
NavPages: navPages,
FooterPages: footerPages,
RightAside: d.loadRightAside(ctx, brand),
AssetVersion: assetVer,
}
}
func (d Deps) sitePageLinks() (nav, footer []SitePageLink) {
nav, footer = []SitePageLink{}, []SitePageLink{}
if d.SitePage == nil {
return
}
list, err := d.SitePage.ListPublished()
if err != nil {
return
}
for _, p := range list {
link := SitePageLink{Title: p.Title, Slug: p.Slug}
if p.ShowInNav {
nav = append(nav, link)
}
if p.ShowInFooter {
footer = append(footer, link)
}
}
return
}
const (
rightAsideHotLimit = 5
rightAsideTagLimit = 24
rightAsideCommentLimit = 8
rightAsideUserLimit = 8
)
func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) RightAsideData {
out := RightAsideData{
HotPosts: []RightAsideHotPost{},
Widgets: []RightAsideWidget{},
}
if ctx.IsSigned() && d.Points != nil {
out.ShowCheckIn = true
if st, err := d.Points.GetCheckInStatus(ctx.UserID()); err == nil {
out.CheckedIn = st.CheckedIn
out.CheckInStreak = st.Streak
out.CheckInPoints = st.TodayPoints
}
if st, err := d.Points.GetLotteryStatus(ctx.UserID()); err == nil {
out.LotteryDrawn = st.Drawn
out.LotteryPoints = st.Points
out.LotteryCost = st.Cost
}
}
if d.Post != nil {
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
pl := d.permalink()
for _, it := range items {
out.HotPosts = append(out.HotPosts, RightAsideHotPost{ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID)})
}
}
}
for _, w := range d.Settings.AsideWidgets() {
if !w.Enabled {
continue
}
block := RightAsideWidget{Kind: w.ID}
switch w.ID {
case services.AsideWidgetTagCloud:
if d.Post != nil {
if tags, err := d.Post.PopularTags(rightAsideTagLimit); err == nil {
for _, t := range tags {
block.Tags = append(block.Tags, RightAsideTag{Name: t.Name, Count: t.Count})
}
}
}
case services.AsideWidgetRecentComments:
if d.Comment != nil {
if list, err := d.Comment.ListRecentPublic(rightAsideCommentLimit); err == nil {
pl := d.permalink()
for _, c := range list {
block.Comments = append(block.Comments, RightAsideComment{
PostID: c.PostID, PostHref: pl.PostPath(c.PostID), Author: c.Author, Excerpt: c.Excerpt, PostTitle: c.PostTitle,
})
}
}
}
case services.AsideWidgetRecentUsers:
if d.User != nil {
if list, err := d.User.ListRecentRegistered(rightAsideUserLimit); err == nil {
pl := d.permalink()
for _, u := range list {
block.Users = append(block.Users, RightAsideUser{ID: u.ID, Nickname: u.Nickname, Href: pl.UserPath(u.ID)})
}
}
}
case services.AsideWidgetFriendLinks:
for _, l := range brand.FriendLinks {
block.FriendLinks = append(block.FriendLinks, RightAsideFriendLink{Name: l.Name, URL: l.URL, Logo: l.Logo})
}
default:
continue
}
out.Widgets = append(out.Widgets, block)
}
return out
}
func firstRuneOr(s, fallback string) string {
s = strings.TrimSpace(s)
if s == "" {
return fallback
}
for _, r := range s {
return string(r)
}
return fallback
}
func stripIDParam(raw, permalinkExt string) string {
raw = strings.TrimSpace(raw)
if permalinkExt != "" {
raw = strings.TrimSuffix(raw, "."+permalinkExt)
}
raw = strings.TrimSuffix(raw, ".html")
raw = strings.TrimSuffix(raw, ".htm")
return raw
}
func (d Deps) permalink() services.PermalinkConfig {
if d.Settings == nil {
return services.PermalinkConfig{}
}
return d.Settings.Permalink()
}
func (d Deps) postHref(id uint) string {
return d.permalink().PostPath(id)
}
func (d Deps) boardHref(id uint) string {
return d.permalink().BoardPath(id)
}
func (d Deps) userHref(id uint) string {
return d.permalink().UserPath(id)
}
// redirectIfNotCanonical 伪静态开启时把无后缀/错误后缀 301 到规范路径
func (d Deps) redirectIfNotCanonical(c *gin.Context, match services.PermalinkMatch) bool {
if !match.OK || !d.permalink().Enabled {
return false
}
path := c.Request.URL.Path
if !match.NeedsCanonicalRedirect(path) {
return false
}
loc := match.Canonical
if q := c.Request.URL.RawQuery; q != "" {
loc = loc + "?" + q
}
c.Redirect(http.StatusMovedPermanently, loc)
return true
}