docs+feat: 重构规格与 Gitea 式 SSR 骨架(首页)

在 rebuild/gitea-ssr 落地产品规格、Cursor 规则,以及 Go 模板 SSR 首页/板块列表;未迁移路径仍回落 SPA,便于对照 main。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 03:11:25 +08:00
parent 4dc3bbb13f
commit 1414c71dec
28 changed files with 2891 additions and 24 deletions

219
routers/web/home.go Normal file
View File

@@ -0,0 +1,219 @@
package web
import (
"net/http"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/modules/webrender"
"git.iioio.com/freefire/jiang13-forum/service"
"github.com/gin-gonic/gin"
)
// Deps 页面路由依赖(复用现有 service避免 Phase 1 大搬家)
type Deps struct {
Settings *service.ForumSettingsService
Board *service.BoardService
Post *service.PostService
}
// BoardView 侧栏板块
type BoardView struct {
ID uint
Name string
}
// PostView 列表项
type PostView struct {
ID uint
Title string
AuthorName string
BoardName string
Pinned bool
Featured bool
CommentCount int
CreatedLabel string
}
// HomePageData 首页 / 板块 Feed
type HomePageData struct {
Title string
Description string
SiteName string
Slogan string
LogoMark string
LoggedIn bool
IsAdmin bool
ViewerName string
Boards []BoardView
ActiveBoard uint
BoardName string
Sort string
Posts []PostView
Page int
PrevPage int
NextPage int
HasPrev bool
HasMore bool
}
// Register 注册已迁移的 SSR 页面(优先于 SPA
func Register(r *gin.Engine, deps Deps, authMW *middleware.AuthMiddleware) {
g := r.Group("/", authMW.OptionalAuth())
g.GET("/", deps.Home)
g.GET("/board/:id", deps.Home)
}
// Home SSR 首页与板块列表
func (d Deps) Home(c *gin.Context) {
brand := d.Settings.SiteBranding()
sort := c.DefaultQuery("sort", "latest")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size := d.Settings.PageSizeDefault()
var boardID uint
var boardName string
if idStr := c.Param("id"); idStr != "" {
// 兼容伪静态后缀 123.html
idStr = strings.TrimSuffix(idStr, "."+d.Settings.Permalink().Ext)
idStr = strings.TrimSuffix(idStr, ".html")
idStr = strings.TrimSuffix(idStr, ".htm")
if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
boardID = uint(n)
}
}
boards, _ := d.Board.List()
boardViews := make([]BoardView, 0, len(boards))
for _, b := range boards {
boardViews = append(boardViews, BoardView{ID: b.ID, Name: b.Name})
if b.ID == boardID {
boardName = b.Name
}
}
var uid uint
if v, ok := c.Get(middleware.CtxUserID); ok {
uid, _ = v.(uint)
}
isAdmin := false
if v, ok := c.Get(middleware.CtxRole); ok {
switch r := v.(type) {
case model.Role:
isAdmin = r == model.RoleAdmin
case string:
isAdmin = r == string(model.RoleAdmin)
}
}
username, _ := c.Get(middleware.CtxUsername)
q := service.PostListQuery{
BoardID: boardID,
Page: page,
Size: size,
Sort: sort,
ViewerID: uid,
ViewerIsAdmin: isAdmin,
}
items, total, err := d.Post.ListItems(q)
if err != nil {
c.String(http.StatusInternalServerError, "加载帖子失败")
return
}
posts := make([]PostView, 0, len(items))
for _, it := range items {
author := strings.TrimSpace(it.User.Nickname)
if author == "" {
author = it.User.Username
}
bname := ""
if it.Board.ID > 0 {
bname = it.Board.Name
}
posts = append(posts, PostView{
ID: it.ID,
Title: it.Title,
AuthorName: author,
BoardName: bname,
Pinned: it.Pinned,
Featured: it.Featured,
CommentCount: it.CommentCount,
CreatedLabel: formatTime(it.CreatedAt),
})
}
title := brand.DocumentTitle()
if boardName != "" {
title = boardName + " · " + brand.Name
}
data := HomePageData{
Title: title,
Description: brand.MetaDescription(),
SiteName: brand.Name,
Slogan: brand.Slogan,
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
LoggedIn: uid > 0,
IsAdmin: isAdmin,
ViewerName: fmtViewer(username),
Boards: boardViews,
ActiveBoard: boardID,
BoardName: boardName,
Sort: normalizeSort(sort),
Posts: posts,
Page: page,
PrevPage: page - 1,
NextPage: page + 1,
HasPrev: page > 1,
HasMore: int64(page*size) < total,
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
if err := webrender.Execute(c.Writer, "home", data); err != nil {
c.String(http.StatusInternalServerError, "模板渲染失败: %v", err)
}
}
func normalizeSort(s string) string {
switch s {
case "reply", "hot":
return s
default:
return "latest"
}
}
func formatTime(t time.Time) string {
return t.Local().Format("2006-01-02 15:04")
}
func firstRuneOr(s, fallback string) string {
s = strings.TrimSpace(s)
if s == "" {
return fallback
}
for _, r := range s {
return string(r)
}
return fallback
}
func fmtViewer(v any) string {
if v == nil {
return "我的"
}
s, _ := v.(string)
s = strings.TrimSpace(s)
if s == "" {
return "我的"
}
return s
}