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 (