From 9bae6f251375167e91f315b7412840e232b8f57b Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 13 Sep 2026 06:03:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E7=AD=BE=E5=88=B0=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B8=96=E5=AD=90=E6=8E=92=E5=BA=8F=E4=B8=8E=E7=A4=BE=E5=8C=BA?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交完成了多个核心功能迭代: 1. 新增用户每日签到系统,包含签到状态查询、连续天数统计、积分累计逻辑 2. 新增"新帖子"排序选项,替换原有的latest排序为新评论排序 3. 重构首页信息流布局,优化移动端与桌面端展示样式 4. 新增最新评论、最新注册用户模块,完善社区面板数据展示 5. 新增静态友情链接配置模块 6. 提取通用时间格式化工具函数,统一全站相对时间展示 7. 补充数据库签到模型与后端API接口,完善前后端交互链路 --- backend/handler/checkin.go | 36 ++++ backend/handler/handlers.go | 1 + backend/handler/overview.go | 16 +- backend/model/db.go | 2 +- backend/model/models.go | 9 + backend/router/router.go | 5 + backend/service/checkin.go | 136 ++++++++++++ backend/service/overview.go | 116 +++++++++- backend/service/post.go | 4 + frontend/app/globals.css | 113 ++-------- frontend/app/page.tsx | 67 +++--- frontend/components/PostRow.tsx | 41 ++-- frontend/components/home/CheckinCard.tsx | 113 ++++++++++ frontend/components/home/CommunityPanels.tsx | 216 +++++++++++++++++-- frontend/components/home/FeedTabs.tsx | 47 ++-- frontend/components/home/HomeHero.tsx | 76 ------- frontend/lib/api.ts | 47 ++++ frontend/lib/format.ts | 14 ++ frontend/lib/friendLinks.ts | 11 + 19 files changed, 790 insertions(+), 280 deletions(-) create mode 100644 backend/handler/checkin.go create mode 100644 backend/service/checkin.go create mode 100644 frontend/components/home/CheckinCard.tsx delete mode 100644 frontend/components/home/HomeHero.tsx create mode 100644 frontend/lib/format.ts create mode 100644 frontend/lib/friendLinks.ts diff --git a/backend/handler/checkin.go b/backend/handler/checkin.go new file mode 100644 index 0000000..6029e87 --- /dev/null +++ b/backend/handler/checkin.go @@ -0,0 +1,36 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" +) + +// GetCheckin 当前用户签到状态 +func (h *Handlers) GetCheckin(c *gin.Context) { + claims := middleware.CurrentUser(c) + status, err := h.Checkin.Status(claims.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取签到状态失败"}) + return + } + c.JSON(http.StatusOK, status) +} + +// DoCheckin 执行今日签到 +func (h *Handlers) DoCheckin(c *gin.Context) { + claims := middleware.CurrentUser(c) + status, err := h.Checkin.CheckIn(claims.ID) + if err != nil { + if errors.Is(err, service.ErrAlreadyCheckedIn) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "签到失败"}) + return + } + c.JSON(http.StatusOK, status) +} diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 09ede57..1257058 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -15,4 +15,5 @@ type Handlers struct { Like *service.LikeService Notification *service.NotificationService OverviewSvc *service.OverviewService + Checkin *service.CheckinService } diff --git a/backend/handler/overview.go b/backend/handler/overview.go index a2a5271..b1cf4e0 100644 --- a/backend/handler/overview.go +++ b/backend/handler/overview.go @@ -14,7 +14,7 @@ func (h *Handlers) Overview(c *gin.Context) { 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 { @@ -24,11 +24,17 @@ func (h *Handlers) Overview(c *gin.Context) { for i := range data.Hot { data.Hot[i].Liked = likedMap[data.Hot[i].ID] } + if status, err := h.Checkin.Status(claims.ID); err == nil { + data.Checkin = status + } } c.JSON(http.StatusOK, gin.H{ - "stats": data.Stats, - "hot": data.Hot, - "active_users": data.ActiveUsers, - "boards": data.Boards, + "stats": data.Stats, + "hot": data.Hot, + "active_users": data.ActiveUsers, + "boards": data.Boards, + "latest_comments": data.LatestComments, + "new_users": data.NewUsers, + "checkin": data.Checkin, }) } diff --git a/backend/model/db.go b/backend/model/db.go index 98a2184..8233f11 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -30,7 +30,7 @@ func InitDB(dsn string) error { } if err := db.AutoMigrate( - &User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, + &User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } diff --git a/backend/model/models.go b/backend/model/models.go index 83b23f4..7f5190d 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -120,6 +120,15 @@ type Like struct { CreatedAt time.Time `json:"created_at"` } +// Checkin 每日签到记录(用户+日期联合唯一,防止重复签到) +type Checkin struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex:idx_checkin_user_date;not null" json:"user_id"` + CheckinDate time.Time `gorm:"type:date;uniqueIndex:idx_checkin_user_date;not null" json:"checkin_date"` + Points int `gorm:"not null;default:5" json:"points"` // 当日所得积分 + CreatedAt time.Time `json:"created_at"` +} + // 通知类型 const ( NotificationTypeComment = "comment" // 评论了你的帖子 diff --git a/backend/router/router.go b/backend/router/router.go index 8adad69..5dcb7f6 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -44,6 +44,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { likeSvc := service.NewLikeService(model.DB) notifSvc := service.NewNotificationService(model.DB) overviewSvc := service.NewOverviewService(model.DB) + checkinSvc := service.NewCheckinService(model.DB) limiter := service.DefaultRateLimiter() h := &handler.Handlers{ @@ -55,6 +56,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Like: likeSvc, Notification: notifSvc, OverviewSvc: overviewSvc, + Checkin: checkinSvc, } authMW := middleware.NewAuthMiddleware(authSvc) @@ -99,6 +101,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.GET("/notifications/unread-count", h.UnreadCount) api.PUT("/notifications/:id/read", h.MarkRead) api.PUT("/notifications/read-all", h.MarkAllRead) + // 每日签到 + api.GET("/checkin", h.GetCheckin) + api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin) } // 管理员 API(同样需要 CSRF 防护) diff --git a/backend/service/checkin.go b/backend/service/checkin.go new file mode 100644 index 0000000..51ccd21 --- /dev/null +++ b/backend/service/checkin.go @@ -0,0 +1,136 @@ +package service + +import ( + "errors" + "time" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// DailyCheckinPoints 每日签到固定积分 +const DailyCheckinPoints = 5 + +// maxStreakScan 连续天数最多向前回溯的天数 +const maxStreakScan = 400 + +// ErrAlreadyCheckedIn 今日已签到 +var ErrAlreadyCheckedIn = errors.New("今日已签到") + +// CheckinStatus 签到状态(首页右栏与签到接口共用) +type CheckinStatus struct { + CheckedToday bool `json:"checked_today"` + Streak int `json:"streak"` // 连续签到天数 + TotalPoints int `json:"total_points"` // 累计积分(签到所得) + TodayPoints int `json:"today_points"` // 今日签到可得积分 +} + +// CheckinService 每日签到 +type CheckinService struct { + db *gorm.DB +} + +func NewCheckinService(db *gorm.DB) *CheckinService { + return &CheckinService{db: db} +} + +// localDay 取本地时区零点日期 +func localDay(t time.Time) time.Time { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) +} + +// streakFrom 从 anchor(含)起按日倒推连续签到天数 +func streakFrom(dates map[string]bool, anchor time.Time) int { + streak := 0 + for i := 0; i < maxStreakScan; i++ { + day := anchor.AddDate(0, 0, -i) + if !dates[day.Format("2006-01-02")] { + break + } + streak++ + } + return streak +} + +// Status 查询用户签到状态 +func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) { + today := localDay(time.Now()) + + var todayRow model.Checkin + err := s.db.Where("user_id = ? AND checkin_date::date = ?::date", userID, today). + First(&todayRow).Error + checkedToday := true + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + checkedToday = false + } + + // 连续天数:今日已签从今天起算,未签从昨天起算(今日尚未签不打断连续) + anchor := today + if !checkedToday { + anchor = today.AddDate(0, 0, -1) + } + var dates []time.Time + if err := s.db.Model(&model.Checkin{}). + Where("user_id = ? AND checkin_date >= ?", userID, anchor.AddDate(0, 0, -maxStreakScan)). + Pluck("checkin_date", &dates).Error; err != nil { + return nil, err + } + dateSet := make(map[string]bool, len(dates)) + for _, d := range dates { + dateSet[localDay(d).Format("2006-01-02")] = true + } + + var total int64 + if err := s.db.Model(&model.Checkin{}). + Where("user_id = ?", userID). + Select("COALESCE(SUM(points), 0)").Scan(&total).Error; err != nil { + return nil, err + } + + return &CheckinStatus{ + CheckedToday: checkedToday, + Streak: streakFrom(dateSet, anchor), + TotalPoints: int(total), + TodayPoints: DailyCheckinPoints, + }, nil +} + +// CheckIn 执行今日签到(每日一条,唯一索引兜底并发) +func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) { + today := localDay(time.Now()) + + var status *CheckinStatus + err := s.db.Transaction(func(tx *gorm.DB) error { + var count int64 + if err := tx.Model(&model.Checkin{}). + Where("user_id = ? AND checkin_date::date = ?::date", userID, today). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return ErrAlreadyCheckedIn + } + row := model.Checkin{ + UserID: userID, + CheckinDate: today, + Points: DailyCheckinPoints, + } + if err := tx.Create(&row).Error; err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + + status, err = s.Status(userID) + if err != nil { + return nil, err + } + return status, nil +} diff --git a/backend/service/overview.go b/backend/service/overview.go index 26dcfaa..680df05 100644 --- a/backend/service/overview.go +++ b/backend/service/overview.go @@ -1,6 +1,7 @@ package service import ( + "strings" "time" "github.com/freefire/jiang13-bbs/model" @@ -39,12 +40,51 @@ type BoardCount struct { PostCount int64 `json:"post_count"` } +// LatestCommentItem 首页"最新评论"条目(纯文本摘要 + 所属帖子) +type LatestCommentItem struct { + ID uint `json:"id"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + User struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + } `json:"user"` + Post struct { + ID uint `json:"id"` + Title string `json:"title"` + } `json:"post"` +} + +// NewUserItem 最新注册成员 +type NewUserItem struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + CreatedAt time.Time `json:"created_at"` +} + // OverviewData 首页聚合数据 type OverviewData struct { - Stats OverviewStats `json:"stats"` - Hot []PostListItem `json:"hot"` - ActiveUsers []ActiveUser `json:"active_users"` - Boards []BoardCount `json:"boards"` + Stats OverviewStats `json:"stats"` + Hot []PostListItem `json:"hot"` + ActiveUsers []ActiveUser `json:"active_users"` + Boards []BoardCount `json:"boards"` + LatestComments []LatestCommentItem `json:"latest_comments"` + NewUsers []NewUserItem `json:"new_users"` + Checkin *CheckinStatus `json:"checkin,omitempty"` +} + +// truncateText 折叠空白并按 rune 截断(评论为纯文本,无 HTML 注入面) +func truncateText(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + r := []rune(s) + if len(r) > n { + return string(r[:n]) + "…" + } + return s } const activeUserWindow = 14 * 24 * time.Hour @@ -52,9 +92,11 @@ const activeUserWindow = 14 * 24 * time.Hour // Get 聚合首页数据。hot 的 liked 态由 handler 层按当前用户填充。 func (s *OverviewService) Get() (*OverviewData, error) { data := &OverviewData{ - Hot: []PostListItem{}, - ActiveUsers: []ActiveUser{}, - Boards: []BoardCount{}, + Hot: []PostListItem{}, + ActiveUsers: []ActiveUser{}, + Boards: []BoardCount{}, + LatestComments: []LatestCommentItem{}, + NewUsers: []NewUserItem{}, } now := time.Now() @@ -163,5 +205,65 @@ LIMIT 5` data.Boards = append(data.Boards, BoardCount{Board: b, PostCount: countMap[b.ID]}) } + // 最新评论:JOIN 未封禁用户与已发布帖,一条 SQL 取齐 + type latestCommentRow struct { + ID uint + Content string + CreatedAt time.Time + UID uint + Username string + Nickname string + Avatar string + PID uint + Title string + } + var commentRows []latestCommentRow + commentSQL := ` +SELECT c.id, c.content, c.created_at, + u.id AS uid, u.username AS username, u.nickname AS nickname, u.avatar AS avatar, + p.id AS pid, p.title AS title +FROM comments c +JOIN users u ON u.id = c.user_id AND u.banned = ? AND u.deleted_at IS NULL +JOIN posts p ON p.id = c.post_id AND p.status = ? AND p.deleted_at IS NULL +WHERE c.status = ? AND c.deleted_at IS NULL +ORDER BY c.created_at DESC +LIMIT 6` + if err := s.db.Raw(commentSQL, + false, model.ContentStatusPublished, model.ContentStatusPublished). + Scan(&commentRows).Error; err != nil { + return nil, err + } + for _, r := range commentRows { + item := LatestCommentItem{ + ID: r.ID, + Content: truncateText(r.Content, 60), + CreatedAt: r.CreatedAt, + } + item.User.ID = r.UID + item.User.Username = r.Username + item.User.Nickname = r.Nickname + item.User.Avatar = r.Avatar + item.Post.ID = r.PID + item.Post.Title = r.Title + data.LatestComments = append(data.LatestComments, item) + } + + // 最新注册成员 + var newUsers []model.User + if err := s.db.Select("id, username, nickname, avatar, created_at"). + Where("banned = ?", false). + Order("created_at DESC").Limit(8).Find(&newUsers).Error; err != nil { + return nil, err + } + for _, u := range newUsers { + data.NewUsers = append(data.NewUsers, NewUserItem{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Avatar: u.Avatar, + CreatedAt: u.CreatedAt, + }) + } + return data, nil } diff --git a/backend/service/post.go b/backend/service/post.go index ccd5aa4..4c04520 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -176,6 +176,10 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) { if q.Sort == "hot" { order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC" } + // new:纯按发帖时间(新帖子) + if q.Sort == "new" { + order = "pinned DESC, created_at DESC" + } if err := query.Order(order).Offset(offset).Limit(q.Size). Preload("Board").Preload("User").Find(&posts).Error; err != nil { return nil, 0, err diff --git a/frontend/app/globals.css b/frontend/app/globals.css index e0cf4e8..697c297 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -430,41 +430,34 @@ h1, h2, h3, h4 { 工作台组件(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"] { + /* 信息流小 tab:描边小圆角按钮,选中为 accent 浅底描边(布局类走 Tailwind,此处不写 display) */ + .j13-tab { appearance: none; - border: 1px solid transparent; - background: transparent; + border: 1px solid var(--line); + background: var(--panel); border-radius: 999px; - padding: 6.5px 15px; + padding: 4.5px 12px; font-family: var(--font-sans); - font-size: 13.5px; + font-size: 12.5px; font-weight: 600; + line-height: 1.35; 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; + transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease; } - .j13-seg [role="tab"]:hover { + .j13-tab:hover { color: var(--ink); + border-color: var(--line-2); + background: var(--panel-2); } - .j13-seg [role="tab"][aria-selected="true"] { - background: var(--panel); + .j13-tab[aria-selected="true"] { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); color: var(--accent); - box-shadow: var(--shadow-sm); font-weight: 700; } - .j13-seg [role="tab"]:active { + .j13-tab:active { transform: scale(0.97); } @@ -582,77 +575,6 @@ h1, h2, h3, h4 { border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); } - /* 首页欢迎横幅 */ - .j13-hero { - position: relative; - overflow: hidden; - padding: 20px 22px; - border-radius: var(--r); - border: 1px solid var(--line); - box-shadow: var(--shadow-sm); - background: - linear-gradient(115deg, color-mix(in srgb, var(--accent-soft) 75%, var(--panel)) 0%, var(--panel) 60%); - } - .j13-hero-glow { - position: absolute; - top: -72px; - right: -52px; - width: 200px; - height: 200px; - border-radius: 9999px; - background: var(--accent-soft); - pointer-events: none; - } - .j13-hero-stats { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px 10px; - width: 100%; - } - .j13-hero-stat { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; - } - .j13-hero-stat-num { - font-size: 21px; - font-weight: 800; - line-height: 1.15; - letter-spacing: -0.015em; - color: var(--ink); - font-variant-numeric: tabular-nums; - } - .j13-hero-stat-num.is-hot { color: var(--accent); } - .j13-hero-stat-lab { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 12px; - color: var(--ink-3); - white-space: nowrap; - } - .j13-dot-pulse { - width: 6px; - height: 6px; - border-radius: 9999px; - background: var(--accent); - animation: j13-dot-pulse 2.4s ease-in-out infinite; - } - @media (min-width: 1024px) { - .j13-hero { padding: 22px 26px; } - .j13-hero-stats { - display: flex; - width: auto; - gap: 0; - } - .j13-hero-stat + .j13-hero-stat { - margin-left: 22px; - padding-left: 22px; - border-left: 1px solid var(--line); - } - } - /* 移动端横向板块条(滚动条隐藏,保留触控惯性) */ .j13-chips { display: flex; @@ -810,11 +732,6 @@ h1, h2, h3, h4 { } .j13-fade-up { animation: j13-fade-up 0.2s ease-out both; } -@keyframes j13-dot-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.35; transform: scale(0.8); } -} - /* 跳到主内容 */ .skip-link { position: fixed; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 4ace582..1e71208 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -14,7 +14,6 @@ import PostRow from "@/components/PostRow"; import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs"; import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav"; import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels"; -import HomeHero from "@/components/home/HomeHero"; export const metadata: Metadata = { title: "姜十三论坛 · 技术讨论社区", @@ -26,7 +25,10 @@ interface HomePageProps { } function normalizeSort(v?: string): FeedSort { - return v === "hot" || v === "recommended" ? v : "latest"; + if (v === "new" || v === "recommended") return v; + // 兼容旧的 ?sort=hot 链接:落到最接近的推荐帖 + if (v === "hot") return "recommended"; + return "latest"; } // 空结果面板(无帖 / 无搜索结果) @@ -125,25 +127,14 @@ export default async function HomePage({ searchParams }: HomePageProps) { ); } - // 置顶帖直接按后端 pinned DESC 顺序并入统一信息流,同样的行布局 + // 置顶帖直接按后端 pinned DESC 顺序并入统一信息流;标题仅供屏幕阅读器 const heading = activeBoard ? activeBoard.name - : sort === "hot" - ? "热门讨论" + : sort === "new" + ? "最新发布" : sort === "recommended" - ? "精华内容" + ? "推荐帖" : "最新讨论"; - const headingHint = activeBoard - ? sort === "hot" - ? "该板块按点赞与评论热度排序" - : sort === "recommended" - ? "该板块管理员精选的优质内容" - : "该板块新发帖与新评论都会浮到顶部" - : sort === "hot" - ? "按点赞与评论热度排序" - : sort === "recommended" - ? "管理员精选的优质内容" - : "新发帖与新评论都会浮到顶部"; const pageQuery: Record = {}; if (boardId > 0) pageQuery.board = String(boardId); @@ -151,31 +142,22 @@ export default async function HomePage({ searchParams }: HomePageProps) { return (
- -
- {/* 中栏:帖子流(左栏切板块只替换这一列) */} + {/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
-
-
-

- {heading} -

-

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

-
- -
+

{heading}

+
+ + + 共 {postsData.total} 篇 + +
{postsData.posts.length > 0 ? ( postsData.posts.map((post, i) => ( + )}
{/* 窄屏:面板下沉到帖子流之后 */} {overview && (
- +
)}
diff --git a/frontend/components/PostRow.tsx b/frontend/components/PostRow.tsx index 5f0f987..b7bca66 100644 --- a/frontend/components/PostRow.tsx +++ b/frontend/components/PostRow.tsx @@ -1,25 +1,11 @@ import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import type { PostListItem } from "@/lib/api"; +import { formatRelative } from "@/lib/format"; import LikeButton from "./LikeButton"; import Avatar from "./Avatar"; import { IconComment, IconEye } from "./Icons"; -/** 发帖/回复时间统一为相对时间:刚刚 / N分钟前 / N小时前 / N天前 / N个月前 / N年前 */ -function formatRelative(dateStr: string) { - const t = new Date(dateStr).getTime(); - const diff = Date.now() - t; - const min = 60_000, - hour = 60 * min, - day = 24 * hour; - if (diff < min) return "刚刚"; - if (diff < hour) return `${Math.floor(diff / min)}分钟前`; - if (diff < day) return `${Math.floor(diff / hour)}小时前`; - if (diff < 30 * day) return `${Math.floor(diff / day)}天前`; - if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月前`; - return `${Math.floor(diff / (365 * day))}年前`; -} - /** * 帖子列表行(参考社区信息流样式): * 大头像 + 加粗标题(徽章内联)+ 第二行「板块 · 作者 · 发布时间 ← 最后回复」+ @@ -45,11 +31,13 @@ export default function PostRow({ return (
@@ -59,7 +47,7 @@ export default function PostRow({ aria-label={post.user.nickname} tabIndex={-1} > - +
@@ -72,14 +60,20 @@ export default function PostRow({ aria-label={post.title} >

{post.title}

-
+
{boardHref && ( {formatRelative(lastReply!.created_at)} )} + {!replied && post.comment_count === 0 && ( + + · 暂无回复 + + )}
diff --git a/frontend/components/home/CheckinCard.tsx b/frontend/components/home/CheckinCard.tsx new file mode 100644 index 0000000..b20bbab --- /dev/null +++ b/frontend/components/home/CheckinCard.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { CalendarDays, CalendarCheck, LogIn, Loader2 } from "lucide-react"; +import { apiDoCheckin, type CheckinStatus } from "@/lib/api"; + +/** + * 每日签到卡(右栏)。 + * initial 为 undefined 表示游客;否则为 SSR 注入的登录用户签到状态。 + */ +export default function CheckinCard({ initial }: { initial?: CheckinStatus | null }) { + const [status, setStatus] = useState(initial ?? null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + // 游客态 + if (initial === undefined) { + return ( +
+
+ + + +
+

+ 每日签到 +

+

登录后每日签到领积分

+
+
+ + 登录后签到 + +
+ ); + } + + const checked = status?.checked_today ?? false; + + async function doCheckin() { + if (loading || checked) return; + setLoading(true); + setError(""); + try { + setStatus(await apiDoCheckin()); + } catch (e) { + setError(e instanceof Error ? e.message : "签到失败"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + {checked ? : } + +
+

+ 每日签到 +

+

+ {checked + ? `已连续签到 ${status?.streak ?? 0} 天 · 累计 ${status?.total_points ?? 0} 积分` + : `连续 ${status?.streak ?? 0} 天 · 今日可得 ${status?.today_points ?? 5} 积分`} +

+
+ {!checked && ( + + +{status?.today_points ?? 5} + + )} +
+ + + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx index 481e4db..cc73fc6 100644 --- a/frontend/components/home/CommunityPanels.tsx +++ b/frontend/components/home/CommunityPanels.tsx @@ -1,7 +1,106 @@ import Link from "next/link"; -import { Flame, Users, MessageCircle, Heart } from "lucide-react"; -import type { PostListItem, ActiveUser } from "@/lib/api"; +import { + Flame, + Users, + MessageCircle, + Heart, + MessageSquareText, + UserPlus, + Link2, +} from "lucide-react"; +import type { + PostListItem, + ActiveUser, + OverviewStats, + LatestComment, + NewUser, + CheckinStatus, +} from "@/lib/api"; import Avatar from "@/components/Avatar"; +import CheckinCard from "./CheckinCard"; +import { FRIEND_LINKS } from "@/lib/friendLinks"; +import { formatRelative } from "@/lib/format"; + +/* ---------- 品牌卡 ---------- */ + +function BrandCard({ stats }: { stats: OverviewStats }) { + const items: { num: number; label: string }[] = [ + { num: stats.posts, label: "帖子" }, + { num: stats.comments, label: "回复" }, + { num: stats.users, label: "用户" }, + ]; + return ( +
+

+ 姜十三论坛 +

+

分享技术、讨论问题、交流心得

+
+ {items.map((it) => ( +
+

+ {it.num} +

+

{it.label}

+
+ ))} +
+
+ ); +} + +/* ---------- 最新评论 ---------- */ + +function LatestComments({ comments }: { comments: LatestComment[] }) { + if (comments.length === 0) return null; + return ( +
+

+ 最新评论 +

+
    + {comments.map((c) => { + const name = c.user.nickname || c.user.username; + return ( +
  • + + + + + + {name} + + + {c.content} + + + + {c.post.title} + + + + {formatRelative(c.created_at)} + + +
  • + ); + })} +
+
+ ); +} /* ---------- 热门榜 ---------- */ @@ -45,6 +144,41 @@ function HotRanking({ posts }: { posts: PostListItem[] }) { ); } +/* ---------- 最新注册 ---------- */ + +function NewUsers({ users }: { users: NewUser[] }) { + if (users.length === 0) return null; + return ( +
+

+ 最新注册 +

+
    + {users.map((u) => { + const name = u.nickname || u.username; + return ( +
  • + + + + {name} + + +
  • + ); + })} +
+
+ ); +} + /* ---------- 活跃用户 ---------- */ function ActiveUsers({ users }: { users: ActiveUser[] }) { @@ -73,20 +207,72 @@ function ActiveUsers({ users }: { users: ActiveUser[] }) { ); } +/* ---------- 友情链接 ---------- */ + +function FriendLinks() { + return ( +
+

+ 友情链接 + + 申请 + +

+
+ {FRIEND_LINKS.map((l) => + l.href.startsWith("http") ? ( + + {l.name} + + ) : ( + + {l.name} + + ) + )} +
+
+ ); +} + /* ---------- 桌面 / 移动两种装配 ---------- */ -export function CommunityPanels({ - hot, - activeUsers, -}: { +type PanelsProps = { + stats: OverviewStats; hot: PostListItem[]; activeUsers: ActiveUser[]; -}) { + latestComments: LatestComment[]; + newUsers: NewUser[]; + checkin?: CheckinStatus | null; +}; + +export function CommunityPanels({ + stats, + hot, + activeUsers, + latestComments, + newUsers, + checkin, +}: PanelsProps) { return ( -