feat: 添加用户个人资料编辑功能,优化页面布局与导航
1. 后端新增用户资料更新接口,支持修改昵称、头像、邮箱和签名 2. 前端新增资料编辑表单页面,适配响应式布局 3. 优化帖子详情页布局与移动端适配逻辑 4. 重构首页侧边栏导航,使用卡片式板块展示 5. 移除全局板块缓存,调整Header导航移除板块菜单 6. 补充用户签名展示功能,完善用户信息返回字段
This commit is contained in:
@@ -224,11 +224,13 @@ func (h *Handlers) ChangePassword(c *gin.Context) {
|
|||||||
// meUserBody 统一的用户信息响应体(Me/Login/Refresh 共用形状)
|
// meUserBody 统一的用户信息响应体(Me/Login/Refresh 共用形状)
|
||||||
func meUserBody(user *model.User) gin.H {
|
func meUserBody(user *model.User) gin.H {
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"nickname": user.Nickname,
|
"nickname": user.Nickname,
|
||||||
"avatar": user.Avatar,
|
"avatar": user.Avatar,
|
||||||
"role": user.Role,
|
"signature": user.Signature,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,43 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"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 获取用户资料:基本信息 + 统计 + 发帖列表(分页)
|
// UserProfile 获取用户资料:基本信息 + 统计 + 发帖列表(分页)
|
||||||
func (h *Handlers) UserProfile(c *gin.Context) {
|
func (h *Handlers) UserProfile(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
@@ -58,6 +95,7 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
|||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"nickname": user.Nickname,
|
"nickname": user.Nickname,
|
||||||
"avatar": user.Avatar,
|
"avatar": user.Avatar,
|
||||||
|
"signature": user.Signature,
|
||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
"created_at": user.CreatedAt,
|
"created_at": user.CreatedAt,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type User struct {
|
|||||||
Password string `gorm:"size:128;not null" json:"-"`
|
Password string `gorm:"size:128;not null" json:"-"`
|
||||||
Nickname string `gorm:"size:64" json:"nickname"`
|
Nickname string `gorm:"size:64" json:"nickname"`
|
||||||
Avatar string `gorm:"size:512" json:"avatar"`
|
Avatar string `gorm:"size:512" json:"avatar"`
|
||||||
|
Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
|
||||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
{
|
{
|
||||||
api.POST("/logout", h.Logout)
|
api.POST("/logout", h.Logout)
|
||||||
api.POST("/change-password", h.ChangePassword)
|
api.POST("/change-password", h.ChangePassword)
|
||||||
|
api.PUT("/profile", h.UpdateProfile)
|
||||||
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
|
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
|
||||||
api.PUT("/posts/:id", h.UpdatePost)
|
api.PUT("/posts/:id", h.UpdatePost)
|
||||||
api.DELETE("/posts/:id", h.DeletePost)
|
api.DELETE("/posts/:id", h.DeletePost)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -491,3 +492,47 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
|
|||||||
// 递增 token_version,使所有旧 JWT 和 refresh token 失效
|
// 递增 token_version,使所有旧 JWT 和 refresh token 失效
|
||||||
return s.IncrementTokenVersion(userID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -559,6 +559,58 @@ h1, h2, h3, h4 {
|
|||||||
font-weight: 700;
|
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 {
|
.j13-module {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import "./globals.css";
|
|||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import Toaster from "@/components/Toaster";
|
import Toaster from "@/components/Toaster";
|
||||||
import type { User, Board } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { getMeCached, getBoardsCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
|
import { TOKEN_COOKIE, REFRESH_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||||
|
|
||||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||||
@@ -69,9 +69,6 @@ export default async function RootLayout({
|
|||||||
unread = me.unread_count;
|
unread = me.unread_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全站板块导航(Header 桌面下拉 + 移动抽屉共用)
|
|
||||||
const boards: Board[] = await getBoardsCached();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="zh-CN"
|
lang="zh-CN"
|
||||||
@@ -81,7 +78,7 @@ export default async function RootLayout({
|
|||||||
<a href="#main" className="skip-link">
|
<a href="#main" className="skip-link">
|
||||||
跳到主要内容
|
跳到主要内容
|
||||||
</a>
|
</a>
|
||||||
<Header initialUser={user} initialUnread={unread} initialTheme={theme} boards={boards} />
|
<Header initialUser={user} initialUnread={unread} initialTheme={theme} />
|
||||||
<main
|
<main
|
||||||
id="main"
|
id="main"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|||||||
@@ -379,13 +379,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
{/* 移动端浮动操作条:长文阅读中途可点赞 / 跳评论 / 回顶部 */}
|
{/* 移动端浮动操作条:长文阅读中途可点赞 / 跳评论 / 回顶部 */}
|
||||||
<MobilePostBar postId={id} liked={post.liked} likeCount={post.like_count} />
|
<MobilePostBar postId={id} liked={post.liked} likeCount={post.like_count} />
|
||||||
|
|
||||||
{/* 三栏阅读布局:xl = 左导航240 + 正文 + 右辅助280;lg = 正文 + 右辅助260;以下单栏 */}
|
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
|
||||||
<div className="grid grid-cols-1 items-start lg:grid-cols-[minmax(0,1fr)_260px] xl:grid-cols-[240px_minmax(0,1fr)_280px] gap-x-6 xl:gap-x-7">
|
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
|
||||||
{/* 左栏:板块 / 作者 / 作者其他帖 */}
|
{/* 左栏:板块 / 作者 / 作者其他帖 */}
|
||||||
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
|
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
|
||||||
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
|
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
|
||||||
<aside
|
<aside
|
||||||
className="hidden xl:flex w-[240px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
||||||
aria-label="帖子导航"
|
aria-label="帖子导航"
|
||||||
>
|
>
|
||||||
<BoardCard board={post.board} />
|
<BoardCard board={post.board} />
|
||||||
@@ -395,7 +395,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
|
|
||||||
{/* 中栏:正文主体 */}
|
{/* 中栏:正文主体 */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<article className="w-full max-w-[760px] mx-auto">
|
<article className="w-full">
|
||||||
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
|
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
|
||||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||||
<ChevronRight size={13} />
|
<ChevronRight size={13} />
|
||||||
@@ -489,9 +489,9 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 窄屏作者卡(xl 起由左栏承载,避免重复展示) */}
|
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
|
||||||
{profile && (
|
{profile && (
|
||||||
<div className="xl:hidden mt-6">
|
<div className="lg:hidden mt-6">
|
||||||
<AuthorCardStacked profile={profile} />
|
<AuthorCardStacked profile={profile} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -515,7 +515,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
|
|
||||||
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
|
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
|
||||||
<aside
|
<aside
|
||||||
className="hidden lg:flex w-[260px] xl:w-[280px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
||||||
aria-label="阅读辅助"
|
aria-label="阅读辅助"
|
||||||
>
|
>
|
||||||
{toc.length > 0 && <PostToc items={toc} />}
|
{toc.length > 0 && <PostToc items={toc} />}
|
||||||
|
|||||||
145
frontend/app/settings/ProfileForm.tsx
Normal file
145
frontend/app/settings/ProfileForm.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiUpdateProfile, type User } from "@/lib/api";
|
||||||
|
|
||||||
|
interface ProfileFormProps {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人资料编辑表单:昵称、头像 URL、邮箱、签名。
|
||||||
|
* 提交成功后刷新 layout 使 Header 同步最新昵称/头像,并在页面内显示成功提示。
|
||||||
|
*/
|
||||||
|
export default function ProfileForm({ user }: ProfileFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [nickname, setNickname] = useState(user.nickname || "");
|
||||||
|
const [avatar, setAvatar] = useState(user.avatar || "");
|
||||||
|
const [email, setEmail] = useState(user.email || "");
|
||||||
|
const [signature, setSignature] = useState(user.signature || "");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
|
||||||
|
const trimmed = nickname.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError("昵称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trimmed.length > 64) {
|
||||||
|
setError("昵称不能超过 64 个字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (signature.length > 255) {
|
||||||
|
setError("签名不能超过 255 个字符");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateProfile({
|
||||||
|
nickname: trimmed,
|
||||||
|
avatar: avatar.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
if (res.user) {
|
||||||
|
setSuccess("资料已保存");
|
||||||
|
// 刷新 layout 让 Header 同步最新昵称/头像
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setError(res.error || "保存失败");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("网络错误,请重试");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="font-bold text-[15px] mb-3.5" style={{ color: "var(--ink)" }}>
|
||||||
|
个人资料
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="panel p-5 sm:p-6 space-y-5">
|
||||||
|
{success && <div className="alert-ok" role="status">{success}</div>}
|
||||||
|
{error && <div className="alert-error" role="alert">{error}</div>}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="pf-nickname">昵称</label>
|
||||||
|
<input
|
||||||
|
id="pf-nickname"
|
||||||
|
type="text"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
className="field"
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="pf-avatar">头像链接</label>
|
||||||
|
<input
|
||||||
|
id="pf-avatar"
|
||||||
|
type="url"
|
||||||
|
value={avatar}
|
||||||
|
onChange={(e) => setAvatar(e.target.value)}
|
||||||
|
maxLength={512}
|
||||||
|
className="field"
|
||||||
|
placeholder="https://example.com/avatar.png"
|
||||||
|
/>
|
||||||
|
<p className="meta mt-2">填写图片 URL,留空则使用默认字母头像</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="pf-email">邮箱</label>
|
||||||
|
<input
|
||||||
|
id="pf-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
maxLength={128}
|
||||||
|
className="field"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="pf-signature">
|
||||||
|
个性签名
|
||||||
|
<span className="font-normal ml-1.5" style={{ color: "var(--ink-3)" }}>
|
||||||
|
({signature.length}/255)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="pf-signature"
|
||||||
|
value={signature}
|
||||||
|
onChange={(e) => setSignature(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
rows={3}
|
||||||
|
className="field"
|
||||||
|
placeholder="介绍一下自己吧"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="btn btn-primary w-full justify-center mt-1"
|
||||||
|
>
|
||||||
|
{submitting ? "保存中..." : "保存资料"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ChevronRight } from "lucide-react";
|
|||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
|
import ProfileForm from "./ProfileForm";
|
||||||
import PasswordForm from "./PasswordForm";
|
import PasswordForm from "./PasswordForm";
|
||||||
|
|
||||||
// SSR 直出账号信息:未登录直接跳登录页,登录后 F5 不再有"正在加载…"闪动
|
// SSR 直出账号信息:未登录直接跳登录页,登录后 F5 不再有"正在加载…"闪动
|
||||||
@@ -31,7 +32,7 @@ export default async function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel p-5 mb-7 flex items-center gap-4">
|
<div className="panel p-5 mb-7 flex items-center gap-4">
|
||||||
<Avatar name={user.nickname || user.username} size={46} />
|
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={46} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-bold truncate" style={{ color: "var(--ink)" }}>
|
<div className="font-bold truncate" style={{ color: "var(--ink)" }}>
|
||||||
{user.nickname}
|
{user.nickname}
|
||||||
@@ -40,7 +41,10 @@ export default async function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PasswordForm />
|
<div className="space-y-7">
|
||||||
|
<ProfileForm user={user} />
|
||||||
|
<PasswordForm />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
<p className="meta mt-1.5 text-[13px]">
|
<p className="meta mt-1.5 text-[13px]">
|
||||||
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入
|
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入
|
||||||
</p>
|
</p>
|
||||||
|
{u.signature && (
|
||||||
|
<p className="mt-3 text-[14px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{u.signature}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2.5 mt-4 flex-wrap">
|
<div className="flex gap-2.5 mt-4 flex-wrap">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ import {
|
|||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Layers,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiLogout, apiMe, type User, type Board } from "@/lib/api";
|
import { apiLogout, apiMe, type User } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
import NotificationBell from "./NotificationBell";
|
import NotificationBell from "./NotificationBell";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
@@ -30,12 +29,10 @@ export default function Header({
|
|||||||
initialUser,
|
initialUser,
|
||||||
initialUnread = 0,
|
initialUnread = 0,
|
||||||
initialTheme = "light",
|
initialTheme = "light",
|
||||||
boards = [],
|
|
||||||
}: {
|
}: {
|
||||||
initialUser: User | null;
|
initialUser: User | null;
|
||||||
initialUnread?: number;
|
initialUnread?: number;
|
||||||
initialTheme?: "light" | "dark";
|
initialTheme?: "light" | "dark";
|
||||||
boards?: Board[];
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -171,7 +168,6 @@ export default function Header({
|
|||||||
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
<NavPill href="/" active={pathname === "/"} icon={<Home size={14} />}>
|
||||||
首页
|
首页
|
||||||
</NavPill>
|
</NavPill>
|
||||||
<BoardsMenu boards={boards} />
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -285,39 +281,6 @@ export default function Header({
|
|||||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||||
{!user && <MobileLink href="/register">注册</MobileLink>}
|
{!user && <MobileLink href="/register">注册</MobileLink>}
|
||||||
</div>
|
</div>
|
||||||
{/* 全站板块入口:手机用户在任意页面都能切板块,不必回首页 */}
|
|
||||||
{boards.length > 0 && (
|
|
||||||
<div className="mt-3 pt-3 border-t" style={{ borderColor: "var(--line)" }}>
|
|
||||||
<p className="px-3 mb-1.5 text-[11.5px] font-bold tracking-[0.08em] uppercase" style={{ color: "var(--ink-3)" }}>
|
|
||||||
板块
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-1">
|
|
||||||
{boards.map((b, i) => {
|
|
||||||
const active = pathname === `/board/${b.id}`;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={b.id}
|
|
||||||
href={`/board/${b.id}`}
|
|
||||||
onClick={() => 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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`av-${Math.max(0, b.color_index ?? i) % 8} w-6 h-6 rounded-lg flex items-center justify-center text-[11px] font-bold shrink-0`}
|
|
||||||
>
|
|
||||||
{b.name.slice(0, 1)}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{b.name}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
||||||
<button onClick={toggleTheme} className="btn btn-line btn-sm">
|
<button onClick={toggleTheme} className="btn btn-line btn-sm">
|
||||||
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
|
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
|
||||||
@@ -362,96 +325,6 @@ function NavPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 板块下拉菜单(桌面端):全站任意页面的 Header 都能直达板块,
|
|
||||||
* 替代原先只有首页左栏才有板块导航的布局局限
|
|
||||||
*/
|
|
||||||
function BoardsMenu({ boards }: { boards: Board[] }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onDown = (e: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", onDown);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
if (boards.length === 0) return null;
|
|
||||||
const active = pathname.startsWith("/board/");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={menuRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13.5px] font-semibold transition-colors"
|
|
||||||
style={
|
|
||||||
active || open
|
|
||||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
|
||||||
: { color: "var(--ink-2)" }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Layers size={14} />
|
|
||||||
板块
|
|
||||||
<ChevronDown
|
|
||||||
size={12}
|
|
||||||
className="transition-transform duration-200"
|
|
||||||
style={{ transform: open ? "rotate(180deg)" : "none" }}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
role="menu"
|
|
||||||
className="absolute left-0 mt-2.5 w-60 max-h-[60vh] overflow-y-auto p-1.5 z-50 j13-toast-in"
|
|
||||||
style={{
|
|
||||||
background: "var(--panel)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
boxShadow: "var(--shadow-lg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{boards.map((b, i) => {
|
|
||||||
const isActive = pathname === `/board/${b.id}`;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={b.id}
|
|
||||||
href={`/board/${b.id}`}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
role="menuitem"
|
|
||||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors hover:bg-[var(--panel-2)]"
|
|
||||||
style={{
|
|
||||||
color: isActive ? "var(--accent)" : "var(--ink)",
|
|
||||||
background: isActive ? "var(--nav-active-bg)" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`av-${Math.max(0, b.color_index ?? i) % 8} w-7 h-7 rounded-lg flex items-center justify-center text-[12px] font-bold shrink-0`}
|
|
||||||
>
|
|
||||||
{b.name.slice(0, 1)}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 truncate">{b.name}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileLink({
|
function MobileLink({
|
||||||
href,
|
href,
|
||||||
active,
|
active,
|
||||||
|
|||||||
@@ -23,35 +23,64 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user:
|
|||||||
<PenSquare size={16} /> 发起讨论
|
<PenSquare size={16} /> 发起讨论
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示 */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="j13-raillink"
|
||||||
|
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<Home size={16} className="shrink-0" />
|
||||||
|
<span className="flex-1 truncate">全部讨论</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 板块目录:发现型入口(介绍 + 讨论量),区别于帖子页面包屑的位置指引;
|
||||||
|
点击不离开工作台,仅在中栏筛选该板块帖子流 */}
|
||||||
<div>
|
<div>
|
||||||
<p className="px-2.5 mb-1.5 text-[11.5px] font-bold tracking-[0.08em] uppercase" style={{ color: "var(--ink-3)" }}>
|
<p
|
||||||
板块
|
className="px-1 mb-2 flex items-center justify-between text-[11.5px] font-bold tracking-[0.08em] uppercase"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
<span>板块目录</span>
|
||||||
|
{boards.length > 0 && (
|
||||||
|
<span className="tracking-normal font-semibold tabular-nums">{boards.length} 个</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-2">
|
||||||
<Link
|
{boards.map((b, i) => {
|
||||||
href="/"
|
const desc = b.description?.trim();
|
||||||
className="j13-raillink"
|
return (
|
||||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
<Link
|
||||||
>
|
key={b.id}
|
||||||
<Home size={16} className="shrink-0" />
|
href={boardHref(b.id)}
|
||||||
<span className="flex-1 truncate">全部讨论</span>
|
className="j13-bcard"
|
||||||
</Link>
|
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||||
{boards.map((b, i) => (
|
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||||
<Link
|
|
||||||
key={b.id}
|
|
||||||
href={boardHref(b.id)}
|
|
||||||
className="j13-raillink"
|
|
||||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`av-${i % 8} w-7 h-7 rounded-lg flex items-center justify-center text-[12px] font-bold shrink-0`}
|
|
||||||
>
|
>
|
||||||
{b.name.slice(0, 1)}
|
<span
|
||||||
</span>
|
className={`av-${i % 8} j13-bcard-ico`}
|
||||||
<span className="flex-1 truncate">{b.name}</span>
|
aria-hidden="true"
|
||||||
<span className="meta text-[11.5px]">{b.post_count}</span>
|
>
|
||||||
</Link>
|
{b.name.slice(0, 1)}
|
||||||
))}
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
className="truncate text-[13px] font-bold"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{b.name}
|
||||||
|
</span>
|
||||||
|
<span className="meta shrink-0 text-[11px] tabular-nums">
|
||||||
|
{b.post_count} 篇
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{desc && <span className="j13-bcard-desc">{desc}</span>}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface User {
|
|||||||
nickname: string;
|
nickname: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
|
||||||
|
email?: string;
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
|
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
|
||||||
@@ -206,6 +209,7 @@ export interface UserProfile {
|
|||||||
username: string;
|
username: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
signature: string;
|
||||||
role: string;
|
role: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
@@ -510,6 +514,21 @@ export async function apiChangePassword(oldPassword: string, newPassword: string
|
|||||||
return res.json();
|
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) {
|
export async function apiToggleLike(postId: string) {
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}/like`, {
|
const res = await fetchWithRefresh(`/api/posts/${postId}/like`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { fetchBoards, fetchMe } from "./api";
|
import { fetchMe } from "./api";
|
||||||
|
|
||||||
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
|
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
|
||||||
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
|
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
|
||||||
export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader));
|
export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader));
|
||||||
|
|
||||||
// 全站导航需要板块列表:layout 每请求调用,失败降级为空列表(导航隐藏,不阻塞页面)
|
|
||||||
export const getBoardsCached = cache(async () => {
|
|
||||||
try {
|
|
||||||
const { boards } = await fetchBoards();
|
|
||||||
return boards;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
import "./.next/dev/types/root-params.d.ts";
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
Reference in New Issue
Block a user