From be3fd774f5edc833fc3032c7c0bd687e7968c50e Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Wed, 15 Apr 2026 19:42:58 +0000 Subject: [PATCH 01/15] =?UTF-8?q?feat(profile):=20=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E4=B8=BB=E9=A1=B5=20/u/[username]=20SSR=20+=20Bento=20?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP 范围(UX + PM 对齐后砍刀): - 路由:/u/[username] SSR(ISR 300s) - 数据源:后端 GET /api/user-center/profile/{username} - 布局:12-col bento,左 col-span-5 Identity,右 col-span-7 小卡网格 - 间距:gap-8 统一,用户明确要求松散 - 三种卡片:PROJ / PAPER / DOC,前两者读 preferences,DOC 读 leaderboard - hover 展开用 max-height 原地展开;mobile 改 tap toggle 砍掉(V2): - sticky 左大块(滚动竞争) - 绝对定位浮层(移动端 mess) - 编辑页(复用后端 PATCH /preferences) - Zotero 实时关联(pinned_papers 直接存 title/author/year) 配套后端改动见 involutionhell-backend main commit 8efae54 (新增 GET /api/user-center/profile/{username} + SaToken 白名单) 同步新增: - docs/architecture/frontend-backend-separation.md 固化前后端分离约定 --- app/u/[username]/ProfileCard.tsx | 114 +++++++ app/u/[username]/page.tsx | 288 ++++++++++++++++++ .../frontend-backend-separation.md | 185 +++++++++++ 3 files changed, 587 insertions(+) create mode 100644 app/u/[username]/ProfileCard.tsx create mode 100644 app/u/[username]/page.tsx create mode 100644 docs/architecture/frontend-backend-separation.md diff --git a/app/u/[username]/ProfileCard.tsx b/app/u/[username]/ProfileCard.tsx new file mode 100644 index 0000000..f403337 --- /dev/null +++ b/app/u/[username]/ProfileCard.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; + +interface ProfileCardProps { + kind: "PROJ" | "PAPER" | "DOC"; + index: number; + title: string; + meta?: string; + summary?: string; + /** 展开态显示的详细内容(hover / tap 触发),没有则复用 summary */ + detail?: string; + href?: string; + /** DOC 汇总卡占两列;其他卡片单列 */ + spanFull?: boolean; +} + +/** + * 个人主页小卡:静态展示 SEC 编号 + 标题 + meta + 概述; + * 悬停(desktop)或点击(mobile)展开详情段落,用 max-height 原地展开避免布局抖动。 + * + * 设计刻意用 editorial 硬朗风格:无圆角、1px border、hover 加硬阴影, + * 和站内 Classified Archives / Top Rank 小卡统一。 + */ +export function ProfileCard({ + kind, + index, + title, + meta, + summary, + detail, + href, + spanFull, +}: ProfileCardProps) { + const [expanded, setExpanded] = useState(false); + + const kindLabel = { + PROJ: "Project", + PAPER: "Paper", + DOC: "Docs", + }[kind]; + + return ( +
setExpanded((v) => !v)} + className={[ + "group relative border border-[var(--foreground)] bg-[var(--background)]", + "p-6 flex flex-col gap-3 min-h-[180px] cursor-pointer", + "transition-shadow duration-200 ease-out", + "hover:shadow-[6px_6px_0_var(--foreground)]", + spanFull ? "sm:col-span-2" : "", + ].join(" ")} + > +
+ + SEC. {kind} · {String(index).padStart(3, "0")} + + + {kindLabel} + +
+ +

+ {title} +

+ + {meta && ( +

+ {meta} +

+ )} + + {summary && ( +

+ {summary} +

+ )} + + {/* 展开态:hover(desktop)或点击(mobile)打开;用 max-height 过渡 */} + {detail && ( +
+

+ {detail} +

+
+ )} + + {href && ( +
+ e.stopPropagation()} + className="font-mono text-[10px] uppercase tracking-widest text-[#CC0000] hover:underline" + > + View → + +
+ )} +
+ ); +} diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx new file mode 100644 index 0000000..bc3652f --- /dev/null +++ b/app/u/[username]/page.tsx @@ -0,0 +1,288 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import Image from "next/image"; +import type { Metadata } from "next"; +import leaderboard from "@/generated/site-leaderboard.json"; +import { Header } from "@/app/components/Header"; +import { Footer } from "@/app/components/Footer"; +import { ProfileCard } from "./ProfileCard"; + +interface UserView { + id: number; + username: string; + displayName: string | null; + avatarUrl: string | null; + email?: string | null; + githubId?: number | null; +} + +interface UserProjectItem { + title: string; + description?: string; + url?: string; + tags?: string[]; +} + +interface UserPaperItem { + title: string; + authors?: string; + year?: string | number; + url?: string; + abstract?: string; +} + +interface UserLinkItem { + label: string; + url: string; +} + +interface Preferences { + bio?: string; + tagline?: string; + links?: UserLinkItem[]; + projects?: UserProjectItem[]; + pinned_papers?: UserPaperItem[]; +} + +interface ProfileResponse { + success: boolean; + data?: { + user: UserView; + preferences: Preferences; + }; + message?: string; +} + +/** + * SSR 获取用户主页数据。匿名请求,走 Next rewrite 到 Java 后端。 + * 失败或 404 返回 null,让页面走 notFound()。 + */ +async function fetchProfile( + username: string, +): Promise { + const backendUrl = process.env.BACKEND_URL; + if (!backendUrl) return null; + try { + const res = await fetch( + `${backendUrl}/api/user-center/profile/${encodeURIComponent(username)}`, + // 用户主页数据变化慢(preferences 手动编辑),缓 5 分钟已足够 + { next: { revalidate: 300 } }, + ); + if (!res.ok) return null; + const json = (await res.json()) as ProfileResponse; + if (!json.success || !json.data) return null; + return json.data; + } catch { + return null; + } +} + +/** + * 在 leaderboard JSON 里按 name(GitHub login)匹配贡献文档列表。 + * leaderboard 脚本跑在 build 时,所以这里是静态数据。 + */ +function findContributedDocs(username: string) { + type Row = { + name: string; + points?: number; + commits?: number; + contributedDocs?: Array<{ title: string; url: string }>; + }; + const rows = leaderboard as Row[]; + const match = rows.find( + (r) => r.name.toLowerCase() === username.toLowerCase(), + ); + if (!match) return { docs: [], points: 0, commits: 0 }; + return { + docs: match.contributedDocs ?? [], + points: match.points ?? 0, + commits: match.commits ?? 0, + }; +} + +interface Param { + params: Promise<{ username: string }>; +} + +export async function generateMetadata({ params }: Param): Promise { + const { username } = await params; + const data = await fetchProfile(username); + if (!data) return { title: `@${username}` }; + const displayName = data.user.displayName || data.user.username; + return { + title: `${displayName} (@${data.user.username})`, + description: + data.preferences.bio || + `${displayName} 在 Involution Hell 的个人主页 — 项目、论文与文档贡献。`, + }; +} + +export default async function UserProfilePage({ params }: Param) { + const { username } = await params; + const data = await fetchProfile(username); + if (!data) notFound(); + + const { docs, points, commits } = findContributedDocs(data.user.username); + const { preferences } = data; + const projects = preferences.projects ?? []; + const papers = preferences.pinned_papers ?? []; + const links = preferences.links ?? []; + + return ( + <> +
+
+
+ {/* Section header(和站内其他模块视觉对齐) */} +
+
+
+
+ User Dossier · Vol. 1 Issue {data.user.id} +
+

+ {data.user.displayName || data.user.username} +

+
+ + Full Rank → + +
+
+ + {/* Bento 12-col grid */} +
+ {/* 左大块:Identity */} +
+ + SEC. PROFILE · 001 + + {data.user.avatarUrl ? ( + {data.user.username} + ) : ( +
+ {data.user.username.charAt(0).toUpperCase()} +
+ )} +
+

+ {data.user.displayName || data.user.username} +

+

+ @{data.user.username} +

+ {preferences.tagline && ( +

+ {preferences.tagline} +

+ )} +
+ {preferences.bio && ( +

+ {preferences.bio} +

+ )} +
+ + + +
+ {links.length > 0 && ( +
+ {links.slice(0, 5).map((link) => ( + + {link.label} + + ))} +
+ )} +
+ + {/* 右侧小卡区 */} +
+ {projects.map((p, idx) => ( + + ))} + {papers.map((p, idx) => ( + + ))} + {docs.length > 0 && ( + d.title) + .join(" · ")} + detail={docs + .slice(0, 5) + .map((d) => d.title) + .join("\n")} + href="/rank" + // DOC 卡占满一行(span-2),其他卡片 gridAutoFlow 自动填充 + spanFull + /> + )} + {projects.length === 0 && + papers.length === 0 && + docs.length === 0 && ( +
+ 该用户还没有填写 projects / papers,也没有文档贡献记录。 +
+ )} +
+
+
+
+