diff --git a/backend/handler/auth.go b/backend/handler/auth.go
index 8758cca..d813e09 100644
--- a/backend/handler/auth.go
+++ b/backend/handler/auth.go
@@ -224,11 +224,13 @@ func (h *Handlers) ChangePassword(c *gin.Context) {
// meUserBody 统一的用户信息响应体(Me/Login/Refresh 共用形状)
func meUserBody(user *model.User) gin.H {
return gin.H{
- "id": user.ID,
- "username": user.Username,
- "nickname": user.Nickname,
- "avatar": user.Avatar,
- "role": user.Role,
+ "id": user.ID,
+ "username": user.Username,
+ "nickname": user.Nickname,
+ "avatar": user.Avatar,
+ "signature": user.Signature,
+ "email": user.Email,
+ "role": user.Role,
}
}
diff --git a/backend/handler/user.go b/backend/handler/user.go
index e3d85cb..21c62bc 100644
--- a/backend/handler/user.go
+++ b/backend/handler/user.go
@@ -8,6 +8,43 @@ import (
"github.com/gin-gonic/gin"
)
+// UpdateProfileRequest 更新资料请求
+type UpdateProfileRequest struct {
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ Email string `json:"email"`
+ Signature string `json:"signature"`
+}
+
+// UpdateProfile 更新当前登录用户的资料(昵称、头像、邮箱、签名)
+func (h *Handlers) UpdateProfile(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ var req UpdateProfileRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数格式错误"})
+ return
+ }
+
+ user, err := h.Auth.UpdateProfile(claims.ID, req.Nickname, req.Avatar, req.Email, req.Signature)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // 返回更新后的完整资料(含 email/signature,供前端刷新展示)
+ c.JSON(http.StatusOK, gin.H{
+ "user": gin.H{
+ "id": user.ID,
+ "username": user.Username,
+ "nickname": user.Nickname,
+ "avatar": user.Avatar,
+ "signature": user.Signature,
+ "email": user.Email,
+ "role": user.Role,
+ },
+ })
+}
+
// UserProfile 获取用户资料:基本信息 + 统计 + 发帖列表(分页)
func (h *Handlers) UserProfile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -58,6 +95,7 @@ func (h *Handlers) UserProfile(c *gin.Context) {
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
+ "signature": user.Signature,
"role": user.Role,
"created_at": user.CreatedAt,
},
diff --git a/backend/model/models.go b/backend/model/models.go
index d212d21..2c2b83e 100644
--- a/backend/model/models.go
+++ b/backend/model/models.go
@@ -35,6 +35,7 @@ type User struct {
Password string `gorm:"size:128;not null" json:"-"`
Nickname string `gorm:"size:64" json:"nickname"`
Avatar string `gorm:"size:512" json:"avatar"`
+ Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"`
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
diff --git a/backend/router/router.go b/backend/router/router.go
index cf85284..544a1cd 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -92,6 +92,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
{
api.POST("/logout", h.Logout)
api.POST("/change-password", h.ChangePassword)
+ api.PUT("/profile", h.UpdateProfile)
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
api.PUT("/posts/:id", h.UpdatePost)
api.DELETE("/posts/:id", h.DeletePost)
diff --git a/backend/service/auth.go b/backend/service/auth.go
index de7ef0f..a2f72fe 100644
--- a/backend/service/auth.go
+++ b/backend/service/auth.go
@@ -10,6 +10,7 @@ import (
"errors"
"io"
"log"
+ "strings"
"time"
"github.com/freefire/jiang13-bbs/model"
@@ -491,3 +492,47 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
// 递增 token_version,使所有旧 JWT 和 refresh token 失效
return s.IncrementTokenVersion(userID)
}
+
+// UpdateProfile 更新用户资料(昵称、头像、邮箱、签名)
+// - nickname 非空且不超过 64 字符
+// - email 可为空;非空时需符合邮箱格式且不与他人重复
+// - avatar 可为空 URL
+// - signature 不超过 255 字符
+func (s *AuthService) UpdateProfile(userID uint, nickname, avatar, email, signature string) (*model.User, error) {
+ nickname = strings.TrimSpace(nickname)
+ if nickname == "" {
+ return nil, errors.New("昵称不能为空")
+ }
+ if len(nickname) > 64 {
+ return nil, errors.New("昵称不能超过 64 个字符")
+ }
+ email = strings.TrimSpace(email)
+ if email != "" {
+ if !strings.Contains(email, "@") || len(email) > 128 {
+ return nil, errors.New("邮箱格式不正确")
+ }
+ // 邮箱唯一性校验(排除自身)
+ var count int64
+ s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
+ if count > 0 {
+ return nil, errors.New("该邮箱已被使用")
+ }
+ }
+ if len(avatar) > 512 {
+ return nil, errors.New("头像链接过长")
+ }
+ if len(signature) > 255 {
+ return nil, errors.New("签名不能超过 255 个字符")
+ }
+
+ updates := map[string]any{
+ "nickname": nickname,
+ "avatar": avatar,
+ "email": email,
+ "signature": signature,
+ }
+ if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
+ return nil, err
+ }
+ return s.GetUserByID(userID)
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 162f557..0e6f0cd 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -559,6 +559,58 @@ h1, h2, h3, h4 {
font-weight: 700;
}
+ /* 首页左栏「板块目录」卡片:与面包屑的纯文字位置指引区分,
+ 承载板块介绍与讨论量等发现型信息,点击在工作台内筛选帖子流 */
+ .j13-bcard {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 9px 10px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--r-sm);
+ transition: border-color 0.16s ease, background 0.16s ease;
+ }
+ .j13-bcard:hover {
+ border-color: var(--nav-active-line);
+ background: var(--panel-2);
+ }
+ .j13-bcard:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+ .j13-bcard[aria-current="page"] {
+ border-color: var(--accent);
+ background: var(--nav-active-bg);
+ }
+ .j13-bcard-ico {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ font-weight: 800;
+ flex-shrink: 0;
+ }
+ .j13-bcard-desc {
+ display: block;
+ margin-top: 2px;
+ font-size: 11.5px;
+ line-height: 1.4;
+ color: var(--ink-3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .j13-bcard {
+ transition: none;
+ }
+ }
+
/* 工作台模块卡 */
.j13-module {
background: var(--panel);
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index b7f4e55..cfdd511 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -5,8 +5,8 @@ import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Toaster from "@/components/Toaster";
-import type { User, Board } from "@/lib/api";
-import { getMeCached, getBoardsCached } from "@/lib/serverData";
+import type { User } from "@/lib/api";
+import { getMeCached } from "@/lib/serverData";
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
@@ -69,9 +69,6 @@ export default async function RootLayout({
unread = me.unread_count;
}
- // 全站板块导航(Header 桌面下拉 + 移动抽屉共用)
- const boards: Board[] = await getBoardsCached();
-
return (
跳到主要内容
-
+
- {/* 三栏阅读布局:xl = 左导航240 + 正文 + 右辅助280;lg = 正文 + 右辅助260;以下单栏 */}
-
+ {/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
+
{/* 左栏:板块 / 作者 / 作者其他帖 */}
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
- {/* 窄屏作者卡(xl 起由左栏承载,避免重复展示) */}
+ {/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
{profile && (
-
+
)}
@@ -515,7 +515,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
-
+
{user.nickname}
@@ -40,7 +41,10 @@ export default async function SettingsPage() {
-
+
);
}
diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx
index cc33b7f..597493a 100644
--- a/frontend/app/u/[id]/page.tsx
+++ b/frontend/app/u/[id]/page.tsx
@@ -102,6 +102,11 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入
+ {u.signature && (
+
+ {u.signature}
+
+ )}
{stats.map((s) => (
}>
首页
-
@@ -285,39 +281,6 @@ export default function Header({
{!user &&
登录}
{!user &&
注册}
- {/* 全站板块入口:手机用户在任意页面都能切板块,不必回首页 */}
- {boards.length > 0 && (
-
-
- 板块
-
-
- {boards.map((b, i) => {
- const active = pathname === `/board/${b.id}`;
- return (
- setMenuOpen(false)}
- aria-current={active ? "page" : undefined}
- className="flex items-center gap-2 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors"
- style={{
- color: active ? "var(--accent)" : "var(--ink)",
- background: active ? "var(--accent-soft)" : "transparent",
- }}
- >
-
- {b.name.slice(0, 1)}
-
- {b.name}
-
- );
- })}
-
-
- )}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 07965f9..abe42a5 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -26,6 +26,9 @@ export interface User {
nickname: string;
avatar: string;
role: string;
+ // 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
+ email?: string;
+ signature?: string;
}
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
@@ -206,6 +209,7 @@ export interface UserProfile {
username: string;
nickname: string;
avatar: string;
+ signature: string;
role: string;
created_at: string;
};
@@ -510,6 +514,21 @@ export async function apiChangePassword(oldPassword: string, newPassword: string
return res.json();
}
+// 更新当前用户资料(昵称、头像、邮箱、签名)
+export async function apiUpdateProfile(data: {
+ nickname: string;
+ avatar?: string;
+ email?: string;
+ signature?: string;
+}): Promise<{ user?: User; error?: string }> {
+ const res = await fetchWithRefresh("/api/profile", {
+ method: "PUT",
+ headers: clientHeaders({ "Content-Type": "application/json" }),
+ body: JSON.stringify(data),
+ });
+ return res.json();
+}
+
export async function apiToggleLike(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/like`, {
method: "POST",
diff --git a/frontend/lib/serverData.ts b/frontend/lib/serverData.ts
index fef8654..26e55bd 100644
--- a/frontend/lib/serverData.ts
+++ b/frontend/lib/serverData.ts
@@ -1,16 +1,6 @@
import { cache } from "react";
-import { fetchBoards, fetchMe } from "./api";
+import { fetchMe } from "./api";
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader));
-
-// 全站导航需要板块列表:layout 每请求调用,失败降级为空列表(导航隐藏,不阻塞页面)
-export const getBoardsCached = cache(async () => {
- try {
- const { boards } = await fetchBoards();
- return boards;
- } catch {
- return [];
- }
-});
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.