From bee06fa962a04e0346ef526eace408ad6f1b82a3 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 12 Sep 2026 23:36:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E7=A4=BE=E5=8C=BA?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=8A=9F=E8=83=BD=E8=BF=AD=E4=BB=A3=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A6=96=E9=A1=B5=E8=81=9A=E5=90=88=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交实现了完整的社区首页功能: 1. 新增首页聚合接口与服务,包含社区统计、热门帖、活跃用户与板块数据 2. 实现帖子推荐排序、SSR缓存与点赞态同步逻辑 3. 重构导航与布局系统,新增工作台侧边栏与移动端适配 4. 优化主题色与UI样式,统一设计系统风格 5. 修复登录注册页自动跳转与组件状态同步问题 --- backend/handler/handlers.go | 1 + backend/handler/overview.go | 34 ++ backend/handler/post.go | 12 +- backend/router/router.go | 3 + backend/service/overview.go | 167 ++++++++ backend/service/post.go | 60 +-- frontend/app/board/[id]/page.tsx | 6 +- frontend/app/globals.css | 377 +++++++++++++++---- frontend/app/layout.tsx | 7 +- frontend/app/login/page.tsx | 16 +- frontend/app/page.tsx | 347 +++++++++-------- frontend/app/register/page.tsx | 15 +- frontend/components/Footer.tsx | 40 +- frontend/components/FooterTiming.tsx | 121 ++++++ frontend/components/Header.tsx | 155 ++++++-- frontend/components/LikeButton.tsx | 9 +- frontend/components/PinnedPosts.tsx | 7 +- frontend/components/PostRow.tsx | 60 ++- frontend/components/home/CommunityPanels.tsx | 142 +++++++ frontend/components/home/FeedTabs.tsx | 57 +++ frontend/components/home/WorkspaceNav.tsx | 122 ++++++ frontend/lib/api.ts | 37 ++ frontend/lib/serverData.ts | 6 + frontend/next-env.d.ts | 4 +- 24 files changed, 1446 insertions(+), 359 deletions(-) create mode 100644 backend/handler/overview.go create mode 100644 backend/service/overview.go create mode 100644 frontend/components/FooterTiming.tsx create mode 100644 frontend/components/home/CommunityPanels.tsx create mode 100644 frontend/components/home/FeedTabs.tsx create mode 100644 frontend/components/home/WorkspaceNav.tsx create mode 100644 frontend/lib/serverData.ts diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index a97c632..09ede57 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -14,4 +14,5 @@ type Handlers struct { Comment *service.CommentService Like *service.LikeService Notification *service.NotificationService + OverviewSvc *service.OverviewService } diff --git a/backend/handler/overview.go b/backend/handler/overview.go new file mode 100644 index 0000000..a2a5271 --- /dev/null +++ b/backend/handler/overview.go @@ -0,0 +1,34 @@ +package handler + +import ( + "net/http" + + "github.com/freefire/jiang13-bbs/middleware" + "github.com/gin-gonic/gin" +) + +// Overview 首页聚合数据:统计、热门榜、活跃用户、板块计数 +func (h *Handlers) Overview(c *gin.Context) { + data, err := h.OverviewSvc.Get() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // 热门榜按当前登录用户填充点赞态 + if claims := middleware.CurrentUser(c); claims != nil { + ids := make([]uint, 0, len(data.Hot)) + for _, p := range data.Hot { + ids = append(ids, p.ID) + } + likedMap := h.Like.BatchHasLiked(ids, claims.ID) + for i := range data.Hot { + data.Hot[i].Liked = likedMap[data.Hot[i].ID] + } + } + c.JSON(http.StatusOK, gin.H{ + "stats": data.Stats, + "hot": data.Hot, + "active_users": data.ActiveUsers, + "boards": data.Boards, + }) +} diff --git a/backend/handler/post.go b/backend/handler/post.go index 1dec8a9..41795c4 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -16,13 +16,15 @@ func (h *Handlers) Posts(c *gin.Context) { boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64) sort := c.DefaultQuery("sort", "latest") keyword := c.Query("keyword") + recommended := c.Query("recommended") == "true" items, total, err := h.Post.List(service.PostListQuery{ - BoardID: uint(boardID), - Page: page, - Size: size, - Sort: sort, - Keyword: keyword, + BoardID: uint(boardID), + Page: page, + Size: size, + Sort: sort, + Keyword: keyword, + Recommended: recommended, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/router/router.go b/backend/router/router.go index afa8dc8..8adad69 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -43,6 +43,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { commentSvc := service.NewCommentService(model.DB) likeSvc := service.NewLikeService(model.DB) notifSvc := service.NewNotificationService(model.DB) + overviewSvc := service.NewOverviewService(model.DB) limiter := service.DefaultRateLimiter() h := &handler.Handlers{ @@ -53,6 +54,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Comment: commentSvc, Like: likeSvc, Notification: notifSvc, + OverviewSvc: overviewSvc, } authMW := middleware.NewAuthMiddleware(authSvc) @@ -67,6 +69,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { { pubAPI.GET("/me", h.Me) pubAPI.GET("/boards", h.Boards) + pubAPI.GET("/overview", h.Overview) pubAPI.GET("/posts", h.Posts) pubAPI.GET("/posts/:id", h.PostDetail) pubAPI.GET("/posts/:id/comments", h.PostComments) diff --git a/backend/service/overview.go b/backend/service/overview.go new file mode 100644 index 0000000..26dcfaa --- /dev/null +++ b/backend/service/overview.go @@ -0,0 +1,167 @@ +package service + +import ( + "time" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// OverviewService 首页聚合服务:一次请求返回统计、热门榜、活跃用户、板块计数 +type OverviewService struct { + db *gorm.DB +} + +func NewOverviewService(db *gorm.DB) *OverviewService { + return &OverviewService{db: db} +} + +// OverviewStats 社区整体统计 +type OverviewStats struct { + Posts int64 `json:"posts"` + Users int64 `json:"users"` + Comments int64 `json:"comments"` + TodayPosts int64 `json:"today_posts"` +} + +// ActiveUser 近 14 天活跃用户 +type ActiveUser struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + ActivityCount int64 `json:"activity_count"` +} + +// BoardCount 板块及其已发布帖子计数(Board 字段被 encoding/json 展平) +type BoardCount struct { + model.Board + PostCount int64 `json:"post_count"` +} + +// OverviewData 首页聚合数据 +type OverviewData struct { + Stats OverviewStats `json:"stats"` + Hot []PostListItem `json:"hot"` + ActiveUsers []ActiveUser `json:"active_users"` + Boards []BoardCount `json:"boards"` +} + +const activeUserWindow = 14 * 24 * time.Hour + +// Get 聚合首页数据。hot 的 liked 态由 handler 层按当前用户填充。 +func (s *OverviewService) Get() (*OverviewData, error) { + data := &OverviewData{ + Hot: []PostListItem{}, + ActiveUsers: []ActiveUser{}, + Boards: []BoardCount{}, + } + + now := time.Now() + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + since := now.Add(-activeUserWindow) + + // 统计 + postQ := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished) + if err := postQ.Count(&data.Stats.Posts).Error; err != nil { + return nil, err + } + if err := s.db.Model(&model.User{}).Count(&data.Stats.Users).Error; err != nil { + return nil, err + } + if err := s.db.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&data.Stats.Comments).Error; err != nil { + return nil, err + } + if err := s.db.Model(&model.Post{}). + Where("status = ? AND created_at >= ?", model.ContentStatusPublished, dayStart). + Count(&data.Stats.TodayPosts).Error; err != nil { + return nil, err + } + + // 热门榜:非置顶,按 赞 + 评论×2 + var hotPosts []model.Post + if err := s.db.Where("status = ? AND pinned = 0", model.ContentStatusPublished). + Order("(like_count + comment_count * 2) DESC, created_at DESC"). + Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil { + return nil, err + } + data.Hot = toPostListItems(hotPosts) + + // 活跃用户:近 14 天发帖+评论合并计数,JOIN 用户过滤封禁/软删 + type activityRow struct { + UserID uint + ActivityCount int64 + } + var rows []activityRow + activitySQL := ` +SELECT a.user_id AS user_id, COUNT(*) AS activity_count +FROM ( + SELECT user_id FROM posts WHERE status = ? AND created_at >= ? AND deleted_at IS NULL + UNION ALL + SELECT user_id FROM comments WHERE status = ? AND created_at >= ? AND deleted_at IS NULL +) AS a +JOIN users AS u ON u.id = a.user_id +WHERE u.banned = ? AND u.deleted_at IS NULL +GROUP BY a.user_id +ORDER BY activity_count DESC, a.user_id ASC +LIMIT 5` + if err := s.db.Raw(activitySQL, + model.ContentStatusPublished, since, + model.ContentStatusPublished, since, + false).Scan(&rows).Error; err != nil { + return nil, err + } + if len(rows) > 0 { + ids := make([]uint, 0, len(rows)) + for _, r := range rows { + ids = append(ids, r.UserID) + } + var users []model.User + if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil { + return nil, err + } + userMap := make(map[uint]model.User, len(users)) + for _, u := range users { + userMap[u.ID] = u + } + for _, r := range rows { + u, ok := userMap[r.UserID] + if !ok { + continue + } + data.ActiveUsers = append(data.ActiveUsers, ActiveUser{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Avatar: u.Avatar, + ActivityCount: r.ActivityCount, + }) + } + } + + // 板块 + 各自帖子计数(一条 GROUP BY) + var boards []model.Board + if err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error; err != nil { + return nil, err + } + type countRow struct { + BoardID uint + Cnt int64 + } + var counts []countRow + if err := s.db.Model(&model.Post{}). + Select("board_id, COUNT(*) AS cnt"). + Where("status = ?", model.ContentStatusPublished). + Group("board_id").Scan(&counts).Error; err != nil { + return nil, err + } + countMap := make(map[uint]int64, len(counts)) + for _, c := range counts { + countMap[c.BoardID] = c.Cnt + } + for _, b := range boards { + data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]}) + } + + return data, nil +} diff --git a/backend/service/post.go b/backend/service/post.go index 37c38cb..38a42bb 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -20,11 +20,27 @@ func NewPostService(db *gorm.DB) *PostService { // PostListQuery 帖子列表查询参数 type PostListQuery struct { - BoardID uint - Page int - Size int - Sort string // latest | hot - Keyword string // 搜索关键词 + BoardID uint + Page int + Size int + Sort string // latest | hot + Keyword string // 搜索关键词 + Recommended bool // 仅精华帖 +} + +// toPostListItems 将 Post 模型批量转为不含正文的列表项 +func toPostListItems(posts []model.Post) []PostListItem { + items := make([]PostListItem, 0, len(posts)) + for _, p := range posts { + items = append(items, PostListItem{ + ID: p.ID, BoardID: p.BoardID, UserID: p.UserID, + Title: p.Title, Tags: p.Tags, PostType: p.PostType, + Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount, + CommentCount: p.CommentCount, CreatedAt: p.CreatedAt, + Board: p.Board, User: p.User, + }) + } + return items } // PostListItem 帖子列表项(不含正文) @@ -63,6 +79,9 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { kw := "%" + q.Keyword + "%" query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw) } + if q.Recommended { + query = query.Where("recommended = ?", true) + } var total int64 if err := query.Count(&total).Error; err != nil { @@ -71,7 +90,12 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { var posts []model.Post offset := (q.Page - 1) * q.Size - order := "pinned DESC, created_at DESC" + // latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。 + // 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引 + order := `pinned DESC, GREATEST(created_at, COALESCE(( + SELECT MAX(c.created_at) FROM comments c + WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL + ), created_at)) DESC` if q.Sort == "hot" { order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC" } @@ -80,17 +104,7 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { return nil, 0, err } - items := make([]PostListItem, 0, len(posts)) - for _, p := range posts { - items = append(items, PostListItem{ - ID: p.ID, BoardID: p.BoardID, UserID: p.UserID, - Title: p.Title, Tags: p.Tags, PostType: p.PostType, - Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount, - CommentCount: p.CommentCount, CreatedAt: p.CreatedAt, - Board: p.Board, User: p.User, - }) - } - return items, total, nil + return toPostListItems(posts), total, nil } // ListByUser 获取指定用户发布的帖子(公开已发布) @@ -112,17 +126,7 @@ func (s *PostService) ListByUser(userID uint, page, size int) ([]PostListItem, i Preload("Board").Preload("User").Find(&posts).Error; err != nil { return nil, 0, err } - items := make([]PostListItem, 0, len(posts)) - for _, p := range posts { - items = append(items, PostListItem{ - ID: p.ID, BoardID: p.BoardID, UserID: p.UserID, - Title: p.Title, Tags: p.Tags, PostType: p.PostType, - Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount, - CommentCount: p.CommentCount, CreatedAt: p.CreatedAt, - Board: p.Board, User: p.User, - }) - } - return items, total, nil + return toPostListItems(posts), total, nil } // GetAuthorID 获取帖子作者 ID(不增加浏览量) diff --git a/frontend/app/board/[id]/page.tsx b/frontend/app/board/[id]/page.tsx index f0e6f33..03bf282 100644 --- a/frontend/app/board/[id]/page.tsx +++ b/frontend/app/board/[id]/page.tsx @@ -29,7 +29,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) { const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); const cookie = authCookieHeader(await cookies()); const [postsData, boardsData] = await Promise.all([ - fetchPosts(page, 20, Number(id), "latest", "", cookie), + fetchPosts(page, 20, Number(id), "latest", "", false, cookie), fetchBoards(), ]); const boardIndex = boardsData.boards.findIndex((b) => b.id === Number(id)); @@ -88,8 +88,8 @@ export default async function BoardPage({ params, searchParams }: PageProps) { 全部讨论 第 {page} 页 - {rows.map((post) => ( - + {rows.map((post, i) => ( + ))} ) : ( diff --git a/frontend/app/globals.css b/frontend/app/globals.css index c0f4890..2f08886 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,40 +1,48 @@ @import "tailwindcss"; /* ============================================================ - 姜十三论坛 · WARM CORNER 设计系统 - 暖白纸面 / 圆润卡片 / 柔和投影 / 青绿主色 / 陶土辅色 + 姜十三论坛 · GRAPHITE INDIGO 设计系统 + 冷调石墨灰阶 / 纯白卡片 / 靛蓝主色 / 近黑锌灰暗色 ============================================================ */ :root { - /* 浅色(默认) */ - --bg: #f6f2ea; + /* 浅色(默认)——冷调石墨:底色微蓝灰,面板纯白浮起,层次干净锐利 */ + --bg: #f6f7f9; --panel: #ffffff; - --panel-2: #faf6ef; - --tint: #f1ebe0; - --ink: #2c2620; - --ink-2: #6b6257; - --ink-3: #7c7365; - --line: #ece4d8; - --line-2: #ddd2c1; + --panel-2: #f2f4f7; + --tint: #eceff3; + --ink: #161a22; + --ink-2: #4b5260; + --ink-3: #5b6270; + --line: #e4e7ec; /* 装饰性分隔/卡片边(非交互,配合阴影与底色高差) */ + --line-2: #7c8698; /* 功能性边界:输入框/按钮/分页等可交互控件,满足 1.4.11 ≥3:1 */ - --accent: #0e6f66; - --accent-hi: #0b5c54; - --accent-soft: rgba(14, 111, 102, 0.1); - --accent-on: #fdfbf6; /* 暖白,与暖纸底色协调,而非冷纯白 */ + --accent: #3b5bdb; + --accent-hi: #3451c8; + --accent-soft: rgba(59, 91, 219, 0.1); + --accent-on: #ffffff; - --clay: #b85c42; - --clay-soft: rgba(200, 105, 77, 0.12); - --gold: #9a6b1f; - --gold-soft: rgba(176, 124, 30, 0.13); + --clay: #a5420a; + --clay-soft: rgba(165, 66, 10, 0.12); + --gold: #825700; + --gold-soft: rgba(130, 87, 0, 0.13); - --danger: #d23b32; - --danger-soft: rgba(210, 59, 50, 0.09); - --ok: #2f8f4e; - --ok-soft: rgba(47, 143, 78, 0.1); + --danger: #c8281e; + --danger-soft: rgba(200, 40, 30, 0.09); + --ok: #137333; + --ok-soft: rgba(19, 115, 51, 0.1); - --shadow-sm: 0 1px 2px rgba(87, 66, 36, 0.05); - --shadow: 0 2px 8px rgba(87, 66, 36, 0.05), 0 10px 28px rgba(87, 66, 36, 0.05); - --shadow-lg: 0 6px 16px rgba(87, 66, 36, 0.07), 0 20px 48px rgba(87, 66, 36, 0.1); + /* 工作台语义:导航选中态 / 榜单奖牌色 */ + --nav-active-bg: var(--accent-soft); + --nav-active-ink: var(--accent); + --nav-active-line: rgba(59, 91, 219, 0.22); + --rank-1: #96620f; + --rank-2: #5d6676; + --rank-3: #a05a2c; + + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05); + --shadow: 0 2px 8px rgba(16, 24, 40, 0.05), 0 10px 28px rgba(16, 24, 40, 0.06); + --shadow-lg: 0 6px 16px rgba(16, 24, 40, 0.08), 0 20px 48px rgba(16, 24, 40, 0.12); /* next/font 构建期自托管,变量由 layout.tsx 注入; 本地系统字体仅作网络字体分片就绪前的回退(PingFang/雅黑与 Noto 观感接近) */ @@ -52,30 +60,39 @@ } .dark { - --bg: #181510; - --panel: #221e18; - --panel-2: #2a251e; - --tint: #312b22; - --ink: #f3ede2; - --ink-2: #c3b9a9; - --ink-3: #9d9383; - --line: rgba(243, 237, 226, 0.09); - --line-2: rgba(243, 237, 226, 0.17); + /* 近黑锌灰:面板逐层浮起,靛蓝提亮为长春花蓝 */ + --bg: #0f1115; + --panel: #181b21; + --panel-2: #20242c; + --tint: #282d37; + --ink: #eef1f6; + --ink-2: #b6bdc9; + --ink-3: #98a1b2; + --line: rgba(238, 241, 246, 0.08); /* 装饰性分隔/卡片边 */ + --line-2: rgba(238, 241, 246, 0.38); /* 功能性控件边界,1.4.11 ≥3:1 */ - --accent: #2dd4bf; - --accent-hi: #5eead4; - --accent-soft: rgba(45, 212, 191, 0.15); - --accent-on: #0c2420; + --accent: #8ea2ff; + --accent-hi: #a9b8ff; + --accent-soft: rgba(141, 163, 255, 0.16); + --accent-on: #10162b; - --clay: #e89070; - --clay-soft: rgba(232, 144, 112, 0.16); - --gold: #d8ae5e; - --gold-soft: rgba(216, 174, 94, 0.16); + --clay: #f0875a; + --clay-soft: rgba(240, 135, 90, 0.16); + --gold: #e0b259; + --gold-soft: rgba(224, 178, 89, 0.16); - --danger: #f26b5e; - --danger-soft: rgba(242, 107, 94, 0.15); - --ok: #6fcf8e; - --ok-soft: rgba(111, 207, 142, 0.15); + --danger: #ff6b60; + --danger-soft: rgba(255, 107, 96, 0.15); + --ok: #58d18a; + --ok-soft: rgba(88, 209, 138, 0.15); + + /* 工作台语义 */ + --nav-active-bg: rgba(141, 163, 255, 0.14); + --nav-active-ink: #a9b8ff; + --nav-active-line: rgba(141, 163, 255, 0.3); + --rank-1: #e3bf74; + --rank-2: #b9c1cd; + --rank-3: #e0a06e; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --shadow: 0 2px 8px rgba(0, 0, 0, 0.28), 0 12px 32px rgba(0, 0, 0, 0.22); @@ -84,10 +101,6 @@ color-scheme: dark; } -* { - border-color: var(--line); -} - html { scrollbar-color: var(--line-2) transparent; } @@ -118,6 +131,13 @@ body { /* 链接重置放入 base 层:避免未分层规则击败 components 层的 .btn-primary 等类 (Tailwind v4 中未分层样式优先级高于任何 @layer) */ @layer base { + /* 必须在 base 层:未分层的 * 选择器会击败 @layer components 中所有 + border-color(.field/.btn-line/.pager-btn 等曾因此实际渲染成 --line) */ + *, + ::before, + ::after { + border-color: var(--line); + } a { color: inherit; text-decoration: none; @@ -161,6 +181,19 @@ h1, h2, h3, h4 { border-color: var(--line-2); } + /* 整行可点:标题链接的 ::after 铺满最近的 relative 行容器, + 行内其他交互元素加 .above-link 抬到点击层之上 */ + .stretch-link::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + } + .above-link { + position: relative; + z-index: 2; + } + /* ----- 按钮(胶囊) ----- */ .btn { display: inline-flex; @@ -184,12 +217,12 @@ h1, h2, h3, h4 { .btn-primary { background: var(--accent); color: var(--accent-on); - box-shadow: 0 1px 2px rgba(87, 66, 36, 0.12), 0 2px 8px rgba(14, 111, 102, 0.16); + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12), 0 2px 8px rgba(59, 91, 219, 0.22); } .btn-primary:hover:not(:disabled) { background: var(--accent-hi); transform: translateY(-1px); - box-shadow: 0 2px 6px rgba(87, 66, 36, 0.1), 0 8px 20px rgba(14, 111, 102, 0.24); + box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1), 0 8px 20px rgba(59, 91, 219, 0.3); } .btn-primary:active:not(:disabled) { transform: translateY(0); @@ -213,6 +246,10 @@ h1, h2, h3, h4 { background: var(--danger); color: #fff; } + .dark .btn-danger:hover:not(:disabled) { + /* 暗色 danger 为亮红,白字仅 2.79:1,改用深色文字 */ + color: #1a0c0b; + } .btn-sm { padding: 7px 14px; font-size: 13px; } @@ -235,7 +272,8 @@ h1, h2, h3, h4 { font-family: var(--font-sans); transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; } - .field::placeholder { color: var(--ink-3); opacity: 0.8; } + /* 占位符不使用 opacity:透明度与底色合成后浅色仅 3.7:1,实色 ink-3 达 5.72:1 */ + .field::placeholder { color: var(--ink-3); } .field:focus { outline: none; border-color: var(--accent); @@ -295,7 +333,7 @@ h1, h2, h3, h4 { /* ----- 分页 ----- */ .pager-btn { background: var(--panel); - border-color: var(--line); + border-color: var(--line-2); color: var(--ink-2); } .pager-btn:hover { @@ -339,6 +377,199 @@ h1, h2, h3, h4 { font-size: 14px; font-weight: 500; } + + /* ============================================================ + 工作台组件(WORKSPACE EDITION) + ============================================================ */ + + /* 分段控件:凹陷轨道 + 凸起的白色选中胶囊 */ + .j13-seg { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 4px; + background: var(--tint); + border: 1px solid var(--line); + border-radius: 999px; + } + .j13-seg [role="tab"] { + appearance: none; + border: 1px solid transparent; + background: transparent; + border-radius: 999px; + padding: 6.5px 15px; + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 600; + color: var(--ink-3); + cursor: pointer; + white-space: nowrap; + transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease, + transform 0.12s ease; + } + .j13-seg [role="tab"]:hover { + color: var(--ink); + } + .j13-seg [role="tab"][aria-selected="true"] { + background: var(--panel); + color: var(--accent); + box-shadow: var(--shadow-sm); + font-weight: 700; + } + .j13-seg [role="tab"]:active { + transform: scale(0.97); + } + + /* 侧栏导航项 */ + .j13-raillink { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: var(--r-sm); + font-size: 14px; + font-weight: 500; + color: var(--ink-2); + transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease; + } + .j13-raillink:hover { + background: var(--panel-2); + color: var(--ink); + } + .j13-raillink[aria-current="page"] { + background: var(--nav-active-bg); + border-color: var(--nav-active-line); + color: var(--nav-active-ink); + font-weight: 700; + } + + /* 工作台模块卡 */ + .j13-module { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--r); + box-shadow: var(--shadow-sm); + padding: 16px; + } + .j13-module-title { + display: flex; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--ink); + } + + /* 统计数字格 */ + .j13-stat { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 11px 13px; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-sm); + } + .j13-stat .j13-stat-num { + font-size: 22px; + font-weight: 800; + line-height: 1.15; + letter-spacing: -0.015em; + color: var(--ink); + font-variant-numeric: tabular-nums; + } + .j13-stat .j13-stat-lab { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--ink-3); + white-space: nowrap; + } + + /* 热门榜单 */ + .j13-rank { + position: relative; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 8px; + border-radius: 10px; + transition: background 0.16s ease; + } + .j13-rank:hover { + background: var(--panel-2); + } + .j13-rank .j13-rank-no { + width: 18px; + padding-top: 2px; + text-align: center; + font-size: 13px; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: var(--ink-3); + flex-shrink: 0; + } + .j13-rank:nth-child(1) .j13-rank-no { color: var(--rank-1); } + .j13-rank:nth-child(2) .j13-rank-no { color: var(--rank-2); } + .j13-rank:nth-child(3) .j13-rank-no { color: var(--rank-3); } + + /* 精选置顶卡 */ + .j13-feature { + position: relative; + display: block; + padding: 15px 17px; + background: linear-gradient(135deg, var(--panel) 0%, var(--panel-2) 100%); + border: 1px solid var(--line); + border-radius: var(--r); + box-shadow: var(--shadow-sm); + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; + } + .j13-feature:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); + border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); + } + + /* 移动端横向板块条(滚动条隐藏,保留触控惯性) */ + .j13-chips { + display: flex; + gap: 8px; + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + padding-bottom: 2px; + } + .j13-chips::-webkit-scrollbar { + display: none; + } + .j13-chip { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + padding: 7px 14px; + border: 1px solid var(--line-2); + background: var(--panel); + border-radius: 999px; + font-size: 13px; + font-weight: 600; + color: var(--ink-2); + white-space: nowrap; + transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease; + } + .j13-chip:hover { + border-color: var(--line-2); + color: var(--ink); + } + .j13-chip[aria-current="page"] { + background: var(--nav-active-bg); + border-color: var(--nav-active-line); + color: var(--nav-active-ink); + } } /* ============================================================ @@ -401,22 +632,22 @@ h1, h2, h3, h4 { } /* 头像配色(浅/暗各自保证可读) */ -.av-0 { background: rgba(15, 118, 110, 0.12); color: #0f766e; } -.av-1 { background: rgba(200, 105, 77, 0.14); color: #b85c42; } -.av-2 { background: rgba(176, 124, 30, 0.15); color: #9a6b1f; } -.av-3 { background: rgba(91, 111, 181, 0.14); color: #4c61ab; } -.av-4 { background: rgba(122, 92, 168, 0.14); color: #6f50a0; } -.av-5 { background: rgba(79, 138, 95, 0.15); color: #477e55; } -.av-6 { background: rgba(192, 106, 139, 0.14); color: #b1557a; } -.av-7 { background: rgba(63, 134, 147, 0.14); color: #357985; } -.dark .av-0 { background: rgba(45, 212, 191, 0.16); color: #5eead4; } -.dark .av-1 { background: rgba(232, 144, 112, 0.18); color: #f0a484; } -.dark .av-2 { background: rgba(216, 174, 94, 0.18); color: #e3bf74; } -.dark .av-3 { background: rgba(130, 150, 224, 0.18); color: #a7b8f0; } -.dark .av-4 { background: rgba(178, 150, 224, 0.18); color: #c3abe8; } -.dark .av-5 { background: rgba(129, 201, 148, 0.18); color: #99ddac; } -.dark .av-6 { background: rgba(235, 148, 180, 0.18); color: #f0a5c2; } -.dark .av-7 { background: rgba(102, 190, 206, 0.18); color: #84d4e0; } +.av-0 { background: rgba(52, 81, 200, 0.12); color: #3451c8; } +.av-1 { background: rgba(10, 99, 117, 0.12); color: #0a6375; } +.av-2 { background: rgba(109, 71, 192, 0.12); color: #6d47c0; } +.av-3 { background: rgba(27, 99, 158, 0.12); color: #1b639e; } +.av-4 { background: rgba(12, 102, 95, 0.13); color: #0c665f; } +.av-5 { background: rgba(171, 56, 93, 0.12); color: #ab385d; } +.av-6 { background: rgba(133, 88, 0, 0.14); color: #855800; } +.av-7 { background: rgba(82, 96, 122, 0.13); color: #52607a; } +.dark .av-0 { background: rgba(169, 184, 255, 0.18); color: #a9b8ff; } +.dark .av-1 { background: rgba(92, 201, 224, 0.18); color: #5cc9e0; } +.dark .av-2 { background: rgba(192, 168, 245, 0.18); color: #c0a8f5; } +.dark .av-3 { background: rgba(125, 184, 238, 0.18); color: #7db8ee; } +.dark .av-4 { background: rgba(94, 211, 189, 0.18); color: #5ed3bd; } +.dark .av-5 { background: rgba(240, 147, 181, 0.18); color: #f093b5; } +.dark .av-6 { background: rgba(224, 178, 89, 0.18); color: #e0b259; } +.dark .av-7 { background: rgba(170, 182, 207, 0.18); color: #aab6cf; } /* ============================================================ 微交互 @@ -432,13 +663,13 @@ h1, h2, h3, h4 { 45% { transform: scale(1.4); } 100% { transform: scale(1); } } -.j13-heart-pop { animation: j13-heart-pop 0.26s ease-out; } +.j13-heart-pop { animation: j13-heart-pop 0.18s ease-out; } @keyframes j13-fade-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } -.j13-fade-up { animation: j13-fade-up 0.3s ease-out both; } +.j13-fade-up { animation: j13-fade-up 0.2s ease-out both; } /* 跳到主内容 */ .skip-link { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 9215232..cfdd511 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -5,7 +5,8 @@ import "./globals.css"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; import Toaster from "@/components/Toaster"; -import { fetchMe, type User } from "@/lib/api"; +import type { User } from "@/lib/api"; +import { getMeCached } from "@/lib/serverData"; import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies"; // 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正, @@ -63,7 +64,7 @@ export default async function RootLayout({ !!cookieStore.get(TOKEN_COOKIE)?.value || !!cookieStore.get(REFRESH_COOKIE)?.value; if (hasAuthCookie) { // 用户信息与未读数合并为一次请求;只转发最小化的 access cookie - const me = await fetchMe(authCookieHeader(cookieStore) || undefined); + const me = await getMeCached(authCookieHeader(cookieStore) || undefined); user = me.user; unread = me.unread_count; } @@ -81,7 +82,7 @@ export default async function RootLayout({
{children}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 9c7ef51..1ab7b09 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { MessagesSquare } from "lucide-react"; -import { apiLogin } from "@/lib/api"; +import { apiLogin, apiMe } from "@/lib/api"; +import { hasAuthCookieHint } from "@/lib/cookies"; // 仅允许同源相对路径跳转,拦截 //host、/\\host 与绝对 URL 形式的开放重定向 function safeRedirect(to: string | null): string { @@ -23,6 +24,17 @@ export default function LoginPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + // 已登录用户访问登录页:直接回落到安全的跳转目标 + useEffect(() => { + if (!hasAuthCookieHint()) return; + apiMe() + .then((res) => { + if (res.user) router.replace(redirect); + }) + .catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index d29d100..f95358e 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,12 +1,19 @@ -import Link from "next/link"; import type { Metadata } from "next"; import { cookies } from "next/headers"; -import { PenSquare, SearchX, MessagesSquare, Check, ArrowRight } from "lucide-react"; -import { fetchPosts, fetchBoards } from "@/lib/api"; +import Link from "next/link"; +import { PenSquare, SearchX, MessagesSquare } from "lucide-react"; +import { + fetchPosts, + fetchOverview, + type OverviewResponse, +} from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; +import { getMeCached } from "@/lib/serverData"; import Pagination from "@/components/Pagination"; import PostRow from "@/components/PostRow"; -import PinnedPosts from "@/components/PinnedPosts"; +import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs"; +import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav"; +import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels"; export const metadata: Metadata = { title: "姜十三论坛 · 技术讨论社区", @@ -14,97 +21,172 @@ export const metadata: Metadata = { }; interface HomePageProps { - searchParams: Promise<{ q?: string; page?: string }>; + searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>; +} + +function normalizeSort(v?: string): FeedSort { + return v === "hot" || v === "recommended" ? v : "latest"; +} + +// 空结果面板(无帖 / 无搜索结果) +function EmptyState({ keyword }: { keyword: string }) { + return ( +
+ + {keyword ? : } + +

+ {keyword ? "没有找到相关帖子" : "还没有帖子"} +

+

+ {keyword ? `换个关键词试试,或者发起“${keyword}”相关的话题` : "成为第一个发起讨论的人吧"} +

+ {keyword ? ( +
+ + 清除搜索 + + + 发起这个话题 + +
+ ) : ( + + 写新帖 + + )} +
+ ); } export default async function HomePage({ searchParams }: HomePageProps) { - const { q, page: pageStr } = await searchParams; - const keyword = q?.trim() || ""; - const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); - // 仅转发最小化的 access cookie 供后端填充 liked 状态(不携带 refresh 等) - const cookie = authCookieHeader(await cookies()); - const [postsData, boardsData] = await Promise.all([ - fetchPosts(page, 20, undefined, "latest", keyword, cookie), - fetchBoards(), - ]); + const sp = await searchParams; + const keyword = sp.q?.trim() || ""; + const page = Math.max(1, parseInt(sp.page || "1", 10) || 1); + const sort = normalizeSort(sp.sort); + const boardId = Math.max(0, parseInt(sp.board || "0", 10) || 0); + const isSearch = keyword.length > 0; - // 第一页且非搜索时:管理员置顶独立成区,其余进入普通列表 - const split = page === 1 && !keyword; - const pinned = split ? postsData.posts.filter((p) => p.pinned > 0) : []; - const rows = split ? postsData.posts.filter((p) => p.pinned <= 0) : postsData.posts; + const cookie = authCookieHeader(await cookies()); + // 与 layout 共享同一份 /me 请求(React cache 去重) + const mePromise = getMeCached(cookie || undefined); + const postsPromise = fetchPosts( + page, + 20, + isSearch || boardId <= 0 ? undefined : boardId, + sort === "recommended" ? "hot" : sort, + keyword, + sort === "recommended", + cookie + ); + // 聚合面板失败不阻塞帖子流 + const overviewPromise: Promise = fetchOverview(cookie).catch(() => null); + + const [me, postsData, overview] = await Promise.all([ + mePromise, + postsPromise, + overviewPromise, + ]); + const user = me.user; + const boards = overview?.boards ?? []; + const activeBoard = boards.find((b) => b.id === boardId) || null; + + // 搜索态:单栏占满,无 Tabs/侧栏 + if (isSearch) { + return ( +
+
+

+ 搜索“{keyword}” +

+

共 {postsData.total} 篇结果 · 按相关度与时间排序

+
+
+ {postsData.posts.length > 0 ? ( + postsData.posts.map((post, i) => ( + + )) + ) : ( + + )} +
+ +
+ ); + } + + // 置顶帖直接按后端 pinned DESC 顺序并入统一信息流,同样的行布局 + const heading = activeBoard + ? activeBoard.name + : sort === "hot" + ? "热门讨论" + : sort === "recommended" + ? "精华内容" + : "最新讨论"; + const headingHint = activeBoard + ? sort === "hot" + ? "该板块按点赞与评论热度排序" + : sort === "recommended" + ? "该板块管理员精选的优质内容" + : "该板块新发帖与新评论都会浮到顶部" + : sort === "hot" + ? "按点赞与评论热度排序" + : sort === "recommended" + ? "管理员精选的优质内容" + : "新发帖与新评论都会浮到顶部"; + + const pageQuery: Record = {}; + if (boardId > 0) pageQuery.board = String(boardId); + if (sort !== "latest") pageQuery.sort = sort; return (
- {/* 页头 */} -
-
-

- {keyword ? ( - <> - 搜索“{keyword}” - - ) : ( - "最新讨论" - )} -

-

- {keyword - ? `共找到 ${postsData.total} 篇相关帖子` - : `共 ${postsData.total} 篇帖子 · ${boardsData.boards.length} 个板块,欢迎加入讨论`} -

-
- - 写新帖 - -
+ -
- {/* 主列 */} -
- +
+ + + {/* 中栏:帖子流(左栏切板块只替换这一列) */} +
+
+
+

+ {heading} +

+

+ 共 {postsData.total} 篇 · {headingHint} +

+
+ +
- {rows.length > 0 ? ( - <> -
- - {keyword ? "搜索结果" : "全部讨论"} - - 共 {postsData.total} 篇 -
- {rows.map((post) => ( - - ))} - + {postsData.posts.length > 0 ? ( + postsData.posts.map((post, i) => ( + + )) ) : ( -
- - {keyword ? : } - -

- {keyword ? "没有找到相关帖子" : "还没有帖子"} -

-

- {keyword ? `换个关键词试试,或者发起“${keyword}”相关的话题` : "成为第一个发起讨论的人吧"} -

- {keyword ? ( -
- - 清除搜索 - - - 发起这个话题 - -
- ) : ( - - 写新帖 - - )} -
+ )}
@@ -113,87 +195,30 @@ export default async function HomePage({ searchParams }: HomePageProps) { page={page} size={20} basePath="/" - query={keyword ? { q: keyword } : undefined} + query={Object.keys(pageQuery).length > 0 ? pageQuery : undefined} />
- {/* 侧栏 */} - + {/* 右栏:社区面板 */} + {overview && ( + + )}
+ + {/* 窄屏:面板下沉到帖子流之后 */} + {overview && ( +
+ +
+ )}
); } diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index d05b138..f1d0575 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { UserPlus } from "lucide-react"; -import { apiRegister } from "@/lib/api"; +import { apiRegister, apiMe } from "@/lib/api"; +import { hasAuthCookieHint } from "@/lib/cookies"; export default function RegisterPage() { const router = useRouter(); @@ -14,6 +15,16 @@ export default function RegisterPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + // 已登录用户不必再注册,直接回首页 + useEffect(() => { + if (!hasAuthCookieHint()) return; + apiMe() + .then((res) => { + if (res.user) router.replace("/"); + }) + .catch(() => {}); + }, [router]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); diff --git a/frontend/components/Footer.tsx b/frontend/components/Footer.tsx index 01b235a..753f515 100644 --- a/frontend/components/Footer.tsx +++ b/frontend/components/Footer.tsx @@ -1,34 +1,20 @@ -import Link from "next/link"; +import pkg from "../package.json"; +import FooterTiming from "./FooterTiming"; +// 仅把明确发布过的版本视为"真实版本";脚手架默认号视为未设计,传空显示占位 +function realVersion(v: string): string { + return !v || v === "0.0.0" || v === "0.1.0" ? "" : v; +} + +// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成) export default function Footer() { + const year = new Date().getFullYear(); + return (
-
-
- - - - - - - 姜十三社区 - -
- - - © 2026 姜十三 - +
+ © {year} 姜十三社区 +
); diff --git a/frontend/components/FooterTiming.tsx b/frontend/components/FooterTiming.tsx new file mode 100644 index 0000000..9c833e0 --- /dev/null +++ b/frontend/components/FooterTiming.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Suspense, useEffect, useRef, useState } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; + +// 版本由 Footer(RSC)从 package.json 注入;未正式设计版本号时传入空串,显示占位 +const PLACEHOLDER = "—"; + +interface Metrics { + page: number; // 页面:整页加载/客户端导航总耗时 + tpl: number; // 模板:服务端响应(SSR / RSC)耗时 +} + +// 硬加载:页面=loadEventEnd,模板≈TTFB(localhost 下基本等于服务端生成时间) +function readHardNav(): Metrics { + const n = performance.getEntriesByType("navigation")[0] as + | PerformanceNavigationTiming + | undefined; + if (!n) return { page: 0, tpl: 0 }; + return { + page: Math.round((n.loadEventEnd || n.domComplete || n.responseEnd) - n.startTime), + tpl: Math.max(0, Math.round(n.responseStart - n.requestStart)), + }; +} + +function TimingInner({ version }: { version: string }) { + const pathname = usePathname(); + const search = useSearchParams().toString(); + // 初始必须为 null:performance 仅客户端可读,直接参与首帧渲染会导致水合不一致 + const [metrics, setMetrics] = useState(null); + // 记录一次站内链接点击,作为客户端导航计时起点 + const pending = useRef<{ start: number; path: string; search: string } | null>(null); + + // 挂载后读取硬加载指标;load 事件后 loadEventEnd 才有完整值,再补一次 + useEffect(() => { + setMetrics(readHardNav()); + const apply = () => setMetrics(readHardNav()); + if (document.readyState !== "complete") { + window.addEventListener("load", apply, { once: true }); + return () => window.removeEventListener("load", apply); + } + }, []); + + // 捕获阶段监听站内点击(不干预任何默认行为) + useEffect(() => { + const onClick = (e: MouseEvent) => { + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.defaultPrevented) return; + const a = (e.target as HTMLElement).closest("a"); + if (!a || !a.href || a.target === "_blank" || a.hasAttribute("download")) return; + let u: URL; + try { + u = new URL(a.href); + } catch { + return; + } + if (u.origin !== location.origin) return; + pending.current = { start: performance.now(), path: u.pathname, search: u.search }; + }; + document.addEventListener("click", onClick, true); + return () => document.removeEventListener("click", onClick, true); + }, []); + + // 路由切换后两帧(绘制完成)结算;模板耗时取本次 RSC fetch 的资源条目 + useEffect(() => { + const p = pending.current; + if (!p) return; + if (p.path !== pathname || p.search !== (search ? `?${search}` : "")) return; + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + const page = Math.round(performance.now() - p.start); + let tpl = 0; + const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; + for (let i = resources.length - 1; i >= 0; i--) { + const r = resources[i]; + if ( + r.initiatorType === "fetch" && + r.startTime >= p.start - 10 && + r.name.startsWith(location.origin + p.path) + ) { + tpl = Math.round(r.responseEnd - r.startTime); + break; + } + } + setMetrics((prev) => ({ page: Math.max(1, page), tpl: tpl || prev?.tpl || 0 })); + pending.current = null; + }); + }); + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [pathname, search]); + + return ( + + 版本:{version || PLACEHOLDER} + + 页面:{metrics ? `${metrics.page}ms` : PLACEHOLDER} + + 模板:{metrics ? `${metrics.tpl}ms` : PLACEHOLDER} + + ); +} + +export default function FooterTiming({ version = "" }: { version?: string }) { + return ( + + 版本:{version || PLACEHOLDER} + + } + > + + + ); +} diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index 98e0767..937f9d1 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -16,6 +16,8 @@ import { Settings, Bell, Home, + User as UserIcon, + ChevronDown, } from "lucide-react"; import { apiLogout, apiMe, type User } from "@/lib/api"; import { hasAuthCookieHint } from "@/lib/cookies"; @@ -140,7 +142,7 @@ export default function Header({ WebkitBackdropFilter: "blur(14px)", }} > -
+
{/* 品牌 */} }> 首页 - - 板块 -
-
- {user && } {user ? ( <> - + 发帖 - - - - {user.nickname} - - - - + ) : ( - <> +
登录 注册 - +
)} -
- - {/* 移动端按钮 */} -
- {user && } + + {open && ( +
+
+ +
+

+ {user.nickname} +

+

@ {user.username}

+
+
+
+ setOpen(false)} className={itemCls} role="menuitem"> + 个人主页 + + setOpen(false)} className={itemCls} role="menuitem"> + 账号设置 + +
+
+ +
+
+ )} +
+ ); +} diff --git a/frontend/components/LikeButton.tsx b/frontend/components/LikeButton.tsx index fdfe37d..00f6118 100644 --- a/frontend/components/LikeButton.tsx +++ b/frontend/components/LikeButton.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter, usePathname } from "next/navigation"; import { IconHeart } from "./Icons"; import { apiToggleLike } from "@/lib/api"; @@ -25,6 +25,13 @@ export default function LikeButton({ const [count, setCount] = useState(initialCount); const [loading, setLoading] = useState(false); + // router.refresh()(登录/登出后)服务端会重新下发 liked 态,同步到本地, + // 避免客户端状态停留在刷新前的用户视角 + useEffect(() => { + setLiked(initialLiked); + setCount(initialCount); + }, [initialLiked, initialCount]); + const handleClick = async () => { if (loading) return; setLoading(true); diff --git a/frontend/components/PinnedPosts.tsx b/frontend/components/PinnedPosts.tsx index 1f996a3..a94919a 100644 --- a/frontend/components/PinnedPosts.tsx +++ b/frontend/components/PinnedPosts.tsx @@ -44,8 +44,11 @@ export default function PinnedPosts({ 0 ? { borderTop: "1px solid var(--line)" } : undefined} + className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)] j13-fade-up" + style={{ + ...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}), + animationDelay: `${i * 35}ms`, + }} >
diff --git a/frontend/components/PostRow.tsx b/frontend/components/PostRow.tsx index 6f0d52c..8ac4292 100644 --- a/frontend/components/PostRow.tsx +++ b/frontend/components/PostRow.tsx @@ -18,18 +18,35 @@ function formatTime(dateStr: string) { export default function PostRow({ post, boardHref, + index = 0, + compact = false, + showViews = true, + showLike = true, }: { post: PostListItem; index?: number; boardHref?: boolean; + compact?: boolean; + showViews?: boolean; + showLike?: boolean; }) { return (
- - + +
@@ -39,7 +56,7 @@ export default function PostRow({ {post.recommended && 推荐}
)} - +

-
+
-
- +
+ {post.comment_count} - - - {post.view_count} - - + {showViews && ( + + + {post.view_count} + + )} + {showLike && ( + + )}
); diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx new file mode 100644 index 0000000..35f4938 --- /dev/null +++ b/frontend/components/home/CommunityPanels.tsx @@ -0,0 +1,142 @@ +import Link from "next/link"; +import { BarChart3, Flame, Users, MessagesSquare, MessageCircle, Heart, Sparkles } from "lucide-react"; +import type { OverviewStats, PostListItem, ActiveUser } from "@/lib/api"; +import Avatar from "@/components/Avatar"; + +/* ---------- 统计 2×2 ---------- */ + +function StatsGrid({ stats }: { stats: OverviewStats }) { + const items = [ + { icon: MessagesSquare, label: "讨论", value: stats.posts }, + { icon: Users, label: "成员", value: stats.users }, + { icon: MessageCircle, label: "评论", value: stats.comments }, + { icon: Sparkles, label: "今日", value: stats.today_posts }, + ]; + return ( +
+

+ 社区数据 +

+
+ {items.map(({ icon: Icon, label, value }) => ( +
+ {value} + + {label} + +
+ ))} +
+
+ ); +} + +/* ---------- 热门榜 ---------- */ + +function HotRanking({ posts }: { posts: PostListItem[] }) { + return ( +
+

+ 热门讨论 +

+ {posts.length === 0 ? ( +

还没有热门讨论

+ ) : ( +
    + {posts.map((p, i) => ( +
  1. + + {i + 1} + + + {p.title} + + + + {p.comment_count} + + + {p.like_count} + + {p.board.name} + + + +
  2. + ))} +
+ )} +
+ ); +} + +/* ---------- 活跃用户 ---------- */ + +function ActiveUsers({ users }: { users: ActiveUser[] }) { + return ( +
+

+ 活跃成员 +

+ {users.length === 0 ? ( +

近 14 天还没有人互动

+ ) : ( +
    + {users.map((u) => ( +
  • + + + {u.nickname || u.username} + {u.activity_count} 次 + +
  • + ))} +
+ )} +

按近 14 天发帖与评论计数

+
+ ); +} + +/* ---------- 桌面 / 移动两种装配 ---------- */ + +export function CommunityPanels({ + stats, + hot, + activeUsers, +}: { + stats: OverviewStats; + hot: PostListItem[]; + activeUsers: ActiveUser[]; +}) { + return ( + + ); +} + +export function CommunityPanelsMobile({ + stats, + hot, + activeUsers, +}: { + stats: OverviewStats; + hot: PostListItem[]; + activeUsers: ActiveUser[]; +}) { + return ( +
+ + + +
+ ); +} diff --git a/frontend/components/home/FeedTabs.tsx b/frontend/components/home/FeedTabs.tsx new file mode 100644 index 0000000..47ff383 --- /dev/null +++ b/frontend/components/home/FeedTabs.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useRef } from "react"; + +export type FeedSort = "latest" | "hot" | "recommended"; + +const TAB_KEYS: FeedSort[] = ["latest", "hot", "recommended"]; +const TAB_LABELS: Record = { + latest: "最新", + hot: "热门", + recommended: "精华", +}; + +// 在指定板块内切换排序时保留 ?board= +function tabHref(key: FeedSort, boardId: number) { + const params = new URLSearchParams(); + if (boardId > 0) params.set("board", String(boardId)); + if (key !== "latest") params.set("sort", key); + const qs = params.toString(); + return qs ? `/?${qs}` : "/"; +} + +// 中栏排序分段控件:URL query 驱动、SSR 直出,键盘 ←/→ 可切换 +export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort; boardId?: number }) { + const router = useRouter(); + const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]); + + const handleKeyDown = (e: React.KeyboardEvent, i: number) => { + if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; + e.preventDefault(); + const next = (i + (e.key === "ArrowRight" ? 1 : TAB_KEYS.length - 1)) % TAB_KEYS.length; + tabRefs.current[next]?.focus(); + router.push(tabHref(TAB_KEYS[next], boardId)); + }; + + return ( +
+ {TAB_KEYS.map((key, i) => ( + { + tabRefs.current[i] = el; + }} + href={tabHref(key, boardId)} + role="tab" + aria-selected={current === key} + tabIndex={current === key ? 0 : -1} + onKeyDown={(e) => handleKeyDown(e, i)} + > + {TAB_LABELS[key]} + + ))} +
+ ); +} diff --git a/frontend/components/home/WorkspaceNav.tsx b/frontend/components/home/WorkspaceNav.tsx new file mode 100644 index 0000000..0b78116 --- /dev/null +++ b/frontend/components/home/WorkspaceNav.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { PenSquare, Home, Bell, Settings, LogIn, UserPlus } from "lucide-react"; +import type { BoardWithCount, User } from "@/lib/api"; + +// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台 +function boardHref(id: number) { + return `/?board=${id}`; +} + +// 桌面左栏:发帖 CTA + 板块导航 + 登录态快捷入口 +export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user: User | null }) { + const params = useSearchParams(); + const activeBoard = Number(params.get("board")) || 0; + const composeHref = user ? "/compose" : "/login?redirect=/compose"; + + return ( + + ); +} + +// 移动端:发帖入口 + 横向滑动板块条 +export function WorkspaceNavMobile({ + boards, + user, +}: { + boards: BoardWithCount[]; + user: User | null; +}) { + const params = useSearchParams(); + const activeBoard = Number(params.get("board")) || 0; + const composeHref = user ? "/compose" : "/login?redirect=/compose"; + + return ( +
+
+ + 发帖 + + + 全部 + + {boards.map((b) => ( + + {b.name} + + ))} +
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index eeb49d4..ea213b9 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -39,9 +39,37 @@ export interface Board { name: string; description: string; icon: string; + color_index?: number; sort_order: number; } +// 首页聚合接口 +export interface OverviewStats { + posts: number; + users: number; + comments: number; + today_posts: number; +} + +export interface ActiveUser { + id: number; + username: string; + nickname: string; + avatar: string; + activity_count: number; +} + +export interface BoardWithCount extends Board { + post_count: number; +} + +export interface OverviewResponse { + stats: OverviewStats; + hot: PostListItem[]; + active_users: ActiveUser[]; + boards: BoardWithCount[]; +} + export interface PostListItem { id: number; board_id: number; @@ -222,16 +250,25 @@ export async function fetchPosts( boardId?: number, sort = "latest", keyword = "", + recommended = false, cookieHeader?: string ): Promise { const params = new URLSearchParams({ page: String(page), size: String(size), sort }); if (boardId) params.set("board_id", String(boardId)); if (keyword) params.set("keyword", keyword); + if (recommended) params.set("recommended", "true"); const res = await fetch(`${API_BASE}/api/posts?${params}`, ssrInit(cookieHeaders(cookieHeader))); if (!res.ok) throw new Error("获取帖子失败"); return res.json(); } +// SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块 +export async function fetchOverview(cookieHeader?: string): Promise { + const res = await fetch(`${API_BASE}/api/overview`, ssrInit(cookieHeaders(cookieHeader))); + if (!res.ok) throw new Error("获取社区概览失败"); + return res.json(); +} + export async function fetchBoards(): Promise<{ boards: Board[] }> { const res = await fetch(`${API_BASE}/api/boards`, ssrInit()); if (!res.ok) throw new Error("获取板块失败"); diff --git a/frontend/lib/serverData.ts b/frontend/lib/serverData.ts new file mode 100644 index 0000000..26e55bd --- /dev/null +++ b/frontend/lib/serverData.ts @@ -0,0 +1,6 @@ +import { cache } from "react"; +import { fetchMe } from "./api"; + +// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户, +// 相同 Cookie 头参数下只产生一次 /api/me 调用。 +export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader)); diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index a419cbe..ce4e94a 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.