feat: 添加用户个人资料编辑功能,优化页面布局与导航
1. 后端新增用户资料更新接口,支持修改昵称、头像、邮箱和签名 2. 前端新增资料编辑表单页面,适配响应式布局 3. 优化帖子详情页布局与移动端适配逻辑 4. 重构首页侧边栏导航,使用卡片式板块展示 5. 移除全局板块缓存,调整Header导航移除板块菜单 6. 补充用户签名展示功能,完善用户信息返回字段
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<html
|
||||
lang="zh-CN"
|
||||
@@ -81,7 +78,7 @@ export default async function RootLayout({
|
||||
<a href="#main" className="skip-link">
|
||||
跳到主要内容
|
||||
</a>
|
||||
<Header initialUser={user} initialUnread={unread} initialTheme={theme} boards={boards} />
|
||||
<Header initialUser={user} initialUnread={unread} initialTheme={theme} />
|
||||
<main
|
||||
id="main"
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -379,13 +379,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
{/* 移动端浮动操作条:长文阅读中途可点赞 / 跳评论 / 回顶部 */}
|
||||
<MobilePostBar postId={id} liked={post.liked} likeCount={post.like_count} />
|
||||
|
||||
{/* 三栏阅读布局:xl = 左导航240 + 正文 + 右辅助280;lg = 正文 + 右辅助260;以下单栏 */}
|
||||
<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">
|
||||
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
|
||||
<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,其可粘行程
|
||||
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
|
||||
<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="帖子导航"
|
||||
>
|
||||
<BoardCard board={post.board} />
|
||||
@@ -395,7 +395,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
|
||||
{/* 中栏:正文主体 */}
|
||||
<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]">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||
<ChevronRight size={13} />
|
||||
@@ -489,9 +489,9 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 窄屏作者卡(xl 起由左栏承载,避免重复展示) */}
|
||||
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
|
||||
{profile && (
|
||||
<div className="xl:hidden mt-6">
|
||||
<div className="lg:hidden mt-6">
|
||||
<AuthorCardStacked profile={profile} />
|
||||
</div>
|
||||
)}
|
||||
@@ -515,7 +515,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
|
||||
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
|
||||
<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="阅读辅助"
|
||||
>
|
||||
{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 { getMeCached } from "@/lib/serverData";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import ProfileForm from "./ProfileForm";
|
||||
import PasswordForm from "./PasswordForm";
|
||||
|
||||
// SSR 直出账号信息:未登录直接跳登录页,登录后 F5 不再有"正在加载…"闪动
|
||||
@@ -31,7 +32,7 @@ export default async function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<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="font-bold truncate" style={{ color: "var(--ink)" }}>
|
||||
{user.nickname}
|
||||
@@ -40,7 +41,10 @@ export default async function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PasswordForm />
|
||||
<div className="space-y-7">
|
||||
<ProfileForm user={user} />
|
||||
<PasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,11 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
<p className="meta mt-1.5 text-[13px]">
|
||||
@{u.username} · {new Date(u.created_at).toLocaleDateString("zh-CN")} 加入
|
||||
</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">
|
||||
{stats.map((s) => (
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user