feat: 个人主页 /u/[username] (MVP)#284
Conversation
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 固化前后端分离约定
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds an MVP user profile page at /u/[username] and documents the intended frontend/backend separation contract for the project, aligning with the recent Agent Teams design discussion.
Changes:
- Introduce SSR profile route
/u/[username]that fetches user profile/preferences from the Java backend and combines it with build-time leaderboard data. - Add reusable client
ProfileCardcomponent with hover/tap expand behavior for projects/papers/docs. - Add a new architecture doc formalizing Next.js vs Java responsibilities, rewrites, and env var expectations.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| docs/architecture/frontend-backend-separation.md | New architecture/contract doc for frontend-backend separation and env/url conventions |
| app/u/[username]/page.tsx | New SSR profile page with ISR-style fetch revalidation and leaderboard-based doc contributions |
| app/u/[username]/ProfileCard.tsx | New interactive profile card component (expand on hover/tap) for profile content blocks |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * 失败或 404 返回 null,让页面走 notFound()。 | ||
| */ | ||
| async function fetchProfile( | ||
| username: string, | ||
| ): Promise<ProfileResponse["data"] | null> { | ||
| 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; |
There was a problem hiding this comment.
fetchProfile 里把所有非 2xx(包括 500/502/超时网关等)都映射成 null → notFound(),会把“后端故障/配置错误”伪装成“用户不存在”。建议至少区分 404 才 notFound,其他状态应抛错进入 error boundary 或返回一个可观测的失败状态(并在非生产环境打印告警)。
| * 失败或 404 返回 null,让页面走 notFound()。 | |
| */ | |
| async function fetchProfile( | |
| username: string, | |
| ): Promise<ProfileResponse["data"] | null> { | |
| 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; | |
| * 仅 404 返回 null,让页面走 notFound();其他失败抛错进入 error boundary。 | |
| */ | |
| function warnFetchProfile(message: string, details?: Record<string, unknown>) { | |
| if (process.env.NODE_ENV !== "production") { | |
| console.warn(`[fetchProfile] ${message}`, details ?? {}); | |
| } | |
| } | |
| async function fetchProfile( | |
| username: string, | |
| ): Promise<ProfileResponse["data"] | null> { | |
| const backendUrl = process.env.BACKEND_URL; | |
| if (!backendUrl) { | |
| warnFetchProfile("Missing BACKEND_URL", { username }); | |
| throw new Error("BACKEND_URL is not configured"); | |
| } | |
| try { | |
| const res = await fetch( | |
| `${backendUrl}/api/user-center/profile/${encodeURIComponent(username)}`, | |
| // 用户主页数据变化慢(preferences 手动编辑),缓 5 分钟已足够 | |
| { next: { revalidate: 300 } }, | |
| ); | |
| if (res.status === 404) return null; | |
| if (!res.ok) { | |
| warnFetchProfile("Backend returned a non-404 error response", { | |
| username, | |
| status: res.status, | |
| statusText: res.statusText, | |
| }); | |
| throw new Error( | |
| `Failed to fetch profile for "${username}": ${res.status} ${res.statusText}`, | |
| ); | |
| } | |
| const json = (await res.json()) as ProfileResponse; | |
| if (!json.success || !json.data) { | |
| warnFetchProfile("Backend returned an invalid profile payload", { | |
| username, | |
| success: json.success, | |
| hasData: !!json.data, | |
| message: json.message, | |
| }); | |
| throw new Error(`Invalid profile response for "${username}"`); | |
| } | |
| return json.data; | |
| } catch (error) { | |
| warnFetchProfile("Profile fetch failed", { | |
| username, | |
| error: error instanceof Error ? error.message : String(error), | |
| }); | |
| throw error; |
| <a | ||
| key={link.url} | ||
| href={link.url} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="font-mono text-[10px] uppercase tracking-widest px-2 py-1 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors" |
There was a problem hiding this comment.
这里直接把 link.url 渲染进 <a href>(且 target=_blank),如果后端返回了 javascript:/data: 之类的危险 scheme,会形成可点击的 XSS / 钓鱼向量。建议在渲染前做 URL 白名单校验(例如仅允许 http/https,必要时允许 mailto),不合法则不渲染或降级为纯文本。
| <article | ||
| // onClick 提供 mobile tap toggle;desktop 的 hover 通过 group-hover 样式实现 | ||
| onClick={() => 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", |
There was a problem hiding this comment.
这个卡片在 <article> 上绑定了 onClick,但目前会在 desktop 也触发“点击展开”并与注释里“mobile tap toggle”不一致,容易导致桌面端出现“离开 hover 仍保持展开”的意外状态。建议只在不支持 hover 的设备上启用 click toggle(例如用 CSS @media (hover: none) + 仅在该条件下挂事件,或在 handler 里通过 matchMedia 判断)。
| <article | ||
| // onClick 提供 mobile tap toggle;desktop 的 hover 通过 group-hover 样式实现 | ||
| onClick={() => 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(" ")} |
There was a problem hiding this comment.
当前整个卡片是可点击区域(onClick + cursor-pointer),但元素是 <article> 且没有 role, tabIndex, 键盘事件处理或 aria-expanded,键盘/读屏用户无法发现或触发展开交互。建议把可交互容器改成语义化的 <button>/<details>,或至少补齐 role="button"、tabIndex={0}、onKeyDown(Enter/Space) 与 aria-expanded。
| {href && ( | ||
| <div className="mt-auto pt-3"> | ||
| <Link | ||
| href={href} | ||
| target={href.startsWith("http") ? "_blank" : undefined} | ||
| rel={href.startsWith("http") ? "noopener noreferrer" : undefined} | ||
| onClick={(e) => e.stopPropagation()} |
There was a problem hiding this comment.
href 来自用户可编辑的 preferences(projects/papers),这里直接喂给 <Link href> 并可能 target=_blank。若 href 包含 javascript:/data: 等 scheme,会变成可点击的注入向量。建议在组件内(或上游统一)对 href 做协议白名单校验,仅允许 http/https(必要时 mailto),不合法则不渲染链接按钮。
| **不做硬编码 fallback**(`?? "http://localhost:8081"` 这种禁止)。理由:端口不一致(8080/8081/其他),fallback 到错的端口会让配置漏配变成静默失败。 | ||
|
|
||
| | 变量 | 前端读取场景 | 设置位置 | | ||
| | ------------------------- | --------------------------- | -------------------------------------- | | ||
| | `BACKEND_URL` | Server 端(SSR、API Route) | `.env.local` (dev) / Vercel env (prod) | | ||
| | `NEXT_PUBLIC_BACKEND_URL` | 浏览器端(HotDocsTab 这种) | 同上 | | ||
|
|
||
| `.env.sample` 里保留 `BACKEND_URL=http://localhost:8080` 作为模板,开发者 `cp .env.sample .env.local` 后按自己后端端口改。 |
There was a problem hiding this comment.
这里写了“不做硬编码 fallback(?? "http://localhost:8081" 这种禁止)”,但仓库当前的 next.config.mjs rewrites 以及至少 app/api/upload/route.ts 仍在对 BACKEND_URL 做 ?? "http://localhost:8080" fallback。文档约定与现状不一致会误导后续开发;建议要么同步改代码移除 fallback 并在缺失时显式报错/提示,要么把文档改成“生产环境禁止 fallback,开发环境可默认 8080”等与现状一致的表述。
| **不做硬编码 fallback**(`?? "http://localhost:8081"` 这种禁止)。理由:端口不一致(8080/8081/其他),fallback 到错的端口会让配置漏配变成静默失败。 | |
| | 变量 | 前端读取场景 | 设置位置 | | |
| | ------------------------- | --------------------------- | -------------------------------------- | | |
| | `BACKEND_URL` | Server 端(SSR、API Route) | `.env.local` (dev) / Vercel env (prod) | | |
| | `NEXT_PUBLIC_BACKEND_URL` | 浏览器端(HotDocsTab 这种) | 同上 | | |
| `.env.sample` 里保留 `BACKEND_URL=http://localhost:8080` 作为模板,开发者 `cp .env.sample .env.local` 后按自己后端端口改。 | |
| **生产环境不做硬编码 fallback**。当前仓库里仍有少量开发态/历史路径使用 `BACKEND_URL ?? "http://localhost:8080"` 的写法,目的是本地联调时减少启动门槛;但这只是过渡状态,**不能作为长期约定依赖**。原因是端口不一致(8080/8081/其他)时,fallback 很容易把“漏配”变成“静默请求到错误地址”,排查成本更高。 | |
| 因此,约定如下: | |
| - **prod / preview**:必须显式配置 `BACKEND_URL`,缺失应视为配置错误 | |
| - **dev**:允许现有代码中的临时 localhost fallback,但新代码默认不要继续扩散这种模式;优先通过 `.env.local` 明确配置 | |
| - 如果后续把现有 fallback 全部移除,以代码行为为准,并同步更新本文档 | |
| | 变量 | 前端读取场景 | 设置位置 | | |
| | ------------------------- | --------------------------- | -------------------------------------- | | |
| | `BACKEND_URL` | Server 端(SSR、API Route) | `.env.local` (dev) / Vercel env (prod) | | |
| | `NEXT_PUBLIC_BACKEND_URL` | 浏览器端(HotDocsTab 这种) | 同上 | | |
| `.env.sample` 里保留 `BACKEND_URL=http://localhost:8080` 作为模板,开发者 `cp .env.sample .env.local` 后按自己后端端口改;不要因为“本地某些路径暂时能 fallback”就省略这一步。 |
| "title": "项目名", | ||
| "description": "一段描述", | ||
| "url": "https://...", | ||
| "tags": ["TypeScript", "LLM"], | ||
| }, |
There was a problem hiding this comment.
这个 jsonc 示例里 projects[0].url 这一行缺少结尾的引号,导致整段示例不是合法的 JSONC(编辑器高亮/复制粘贴会出错)。建议补齐引号,并顺便检查示例中对象/数组的逗号是否符合期望的 JSONC 风格。
| } | ||
|
|
||
| /** | ||
| * SSR 获取用户主页数据。匿名请求,走 Next rewrite 到 Java 后端。 |
There was a problem hiding this comment.
注释写“走 Next rewrite 到 Java 后端”,但这里实际是服务端直接 fetch(${BACKEND_URL}/...),不会经过 next.config.mjs rewrites。建议把注释改成“服务端直连后端(使用 BACKEND_URL)”或改成请求相对路径以真正走 rewrites,避免后续排查时产生误解。
| * SSR 获取用户主页数据。匿名请求,走 Next rewrite 到 Java 后端。 | |
| * SSR 获取用户主页数据。匿名请求,服务端使用 BACKEND_URL 直连 Java 后端。 |
- UserMenu 登录态增加"我的主页"链接(基于 githubId) - AuthNav 透传 githubId 到 UserMenu - ContributorRow 弹窗加"VIEW DOSSIER"跳站内个人主页,GitHub 外链保留 - page.tsx 改按 user.githubId 从 leaderboard JSON 匹配贡献数据 (之前按 name 字符串匹配踩坑:leaderboard.name="longsizhuo" vs user.username="github_114939201") - 新增 EditLinkIfOwner 客户端小组件,只在访问者 == 主页主人时渲染编辑入口
…apers - 新 page.tsx 壳 + EditProfileForm 客户端表单 - useAuth 读当前用户,URL username 与 githubId / username 都不匹配时显示"只能编辑自己" - GET /api/user-center/preferences 拉现有值 → 表单 state - 提交走 PATCH /api/user-center/preferences(后端合并 JSONB 顶层 key 保留其他) - RepeatableList 通用泛型组件支持 links/projects/papers 增删 - editorial 风格:border 无圆角 / SEC 编号 / 硬阴影 submit 按钮
删 Next API Route,next.config 加 rewrite 把 /api/docs/history 透传到 Java, Vercel 不再跑 Node function 去调 GitHub API。后端带 Caffeine 10min 缓存 + GITHUB_TOKEN。配套后端 commit 见 involutionhell-backend#main。
- 从 noreply 邮箱(1234567+alice@users.noreply.github.com) 离线提取 id→login 映射,
14/21 命中直接拿到 login,剩下 7 才走 GitHub API。规避限流风险。
- DB 聚合时同时按日分桶贡献次数写进 dailyCounts: { "YYYY-MM-DD": count },
供前端活跃度热力图渲染,零运行时 DB 查询。
generated/site-leaderboard.json 一并重新生成带 dailyCounts。
- 新增 ActivityHeatmap server component,无 JS - 52 列 × 7 行小格子,色阶分 5 档(0 / 1-2 / 3-5 / 6-10 / 10+),硬红(#CC0000)系列 - 数据走 leaderboard.json 的 dailyCounts(零运行时 DB 查询,和 docs git-based 一致) - 月份刻度 + 周几标签(周一/四/六) - page.tsx Bento grid 下方独立一行展示,仅当有贡献数据时渲染
- FollowButton 客户端组件:拉 /api/user-center/follows/stats 填初始值,
点击走 POST/DELETE /api/user-center/follows/{id},乐观更新失败回滚
- 自己访问自己主页只显示统计,不显示按钮
- 匿名用户可见数字,按钮显示"登录后关注"置灰
- prisma/schema.prisma 加 user_follows model 同步(DB 已在 Neon 手动建表)
配套后端端点见 involutionhell-backend#main 30daf9d
- GithubRepos server component,fetch /api/user-center/github/repos/{identifier}
- 2 列 grid,每卡显示 name / stars / description / language / 更新时间
- 数据为空时返回 null 不渲染 section
配套后端 commit involutionhell-backend b6b48b2(Caffeine 1h,不需要 OAuth scope 扩展)
- UserPaperItem 加 itemKey 字段(可选) - page.tsx 提取所有 itemKey 批量调 /api/user-center/zotero/items, Zotero 拉到的元信息作为主数据源,手填字段作为离线 fallback - EditProfileForm papers 区增加 itemKey 输入框(提示可选),保存条件放宽为 itemKey 或 title 至少有一个 - 完全向后兼容:历史 pinned_papers(只有 title/authors 的)照常渲染 配套后端 commit involutionhell-backend 1fb697f(Zotero API 代理 + Caffeine 1h) V2 全部完成:#1 编辑页 / #2 docs/history 迁 Java / #3 活跃度热力图 / #4 关注系统 / #5 GitHub repos 同步 / #6 保留 analyticsEvent / #7 Zotero itemKey
1. leaderboard as Row[] → leaderboard as unknown as Row[] JSON 字面量的 dailyCounts 各自是不同 literal 类型,和 Row 的 Record<string, number> 索引签名不兼容,CI tsc --noEmit 报 TS2352。先经 unknown 绕开。 2. ProfileCard title prop 必填 string,但 UserPaperItem 引入 itemKey 后 title 变可选。 兜底为 p.title || p.itemKey || "(untitled paper)"。
安全修复(P0):
- fetchProfile 只在后端真返 404 或 success=false 时 notFound();其他非 2xx 抛错进 error boundary
(避免后端故障伪装成"用户不存在")
- links/projects/papers 的 URL 在渲染前过 sanitizeExternalUrl 白名单
(仅 http/https/mailto + 相对路径,拦 javascript:/data: 等 XSS 向量)
- ProfileCard 内部再加 safeHref 二次防御
UX / a11y(P1):
- ProfileCard 的 click toggle 用 matchMedia('(hover: none)') 限定触屏设备
(桌面端仍走 group-hover,避免"离开 hover 后仍保持展开"幽灵状态)
- 补 role="button" / tabIndex=0 / onKeyDown(Enter|Space) / aria-expanded,
键盘 / 读屏用户可用;只在有 detail 时才挂这些属性
文档(P2):
- docs/architecture/frontend-backend-separation.md
环境变量章节改成"生产禁 fallback,开发态过渡允许",与现状(next.config.mjs/upload 仍用 fallback)自洽
- fetchProfile 注释说"直连后端(BACKEND_URL)",不再错写"走 rewrite"
- 右侧 Bento 小卡区只保留 projects / papers,DOC 卡片抽出去 - 活跃度热力图从页底提到 Bento 之后立即显示(视觉重点前置) - 新增 SEC. DOCS 独立 section 放页尾,紧凑列表(每行 ~48px 而非 180px 小卡) - 默认只显示前 10 篇,超过的用 <details> 折叠"展开剩余 N 篇" 旧版顺序:Identity(bio/stats)+DOC 卡(8 条) → Heatmap → Repos 新版顺序:Identity(bio/stats)+projects/papers → Heatmap → Repos → Docs 列表(折叠) 文档特别多的用户(本作者 44 条)现在热力图和 repos 一屏可见,docs 列表放最后折叠。
- 去掉"首页":BrandMark 点击已回首页,避免冗余 - 去掉"特点":旧 Features 组件 2026-04 重构时删除,/#features 已失效 - 新增"文档"→ /docs 和"排行榜"→ /rank,把主要产品路由提到顶 - 保留"社区"→ /#community(DispatchNetwork bar) - "联系我们"缩写为"联系"→ /#contact(Footer 还在)
用户反馈"编辑个人主页这地方不知道 edit 什么,不懂 papers 是什么意思"。 根因是字段名是工程师视角(bio/tagline/pinned_papers),普通用户不理解。 改动: - 编辑页顶部加一段"About this page"总说明:填的东西去哪里显示、全都可选 - 每个 Section 从只有 SEC 编号,扩成「SEC 编号 + 中文大标题 + 一句话描述」 - PAPERS 块重命名为"最近在读 / 推荐的论文"(papers 太学术,降低理解门槛) - placeholder 从抽象("项目名")换成具体示例("involutionhell.com") - IDENTITY/LINKS/PROJECTS/PAPERS 4 块都加 heading + description 说明作用 主页空态文案: - 旧:"该用户还没有填写 projects / papers"(用户反馈看不懂) - 新:"Ta 还没填个人项目和最近在读的论文" + 小字补充"仍会显示 GitHub repos 和文档贡献"
基建:
- lib/i18n/messages.ts:扁平 key 字典,zh/en 同结构,{param} 占位填充
- lib/i18n/server.ts:server-only,getServerLocale + getServerT
- lib/i18n/client.tsx:LocaleProvider + useT hook
- app/layout.tsx:LocaleProvider 包在 ThemeProvider 外,locale 服务端读 cookie
注入客户端 Context,避免 SSR/CSR 水合抖动
接入(全部 profile 相关 UI):
- page.tsx:dossier / stats / empty state / docs 列表全部走 t(key)
- ActivityHeatmap:改 async server component,月份 Jan..Dec 走 activity.month.{1..12}
- GithubRepos:heading / subtitle / count 走 t
- FollowButton / EditLinkIfOwner / ProfileCard:client hook useT
- edit/page.tsx + EditProfileForm:全套(intro / 4 个 section heading+description
+ placeholders + CTA + auth gate + RepeatableList 增删按钮文字)
- RepeatableList 新增 addLabel/removeLabel props,调用方翻译好后传入
验证:
- pnpm run typecheck 通过
- locale=zh → "活跃度/文档贡献/粉丝/积分/贡献过的文档/GitHub 仓库"
- locale=en → "Activity/Docs/Followers/Points/Docs Contributed/GitHub Repositories"
Summary
MVP 个人主页,响应 Agent Teams 设计讨论。
/u/[username]SSR + ISR 300sGET /api/user-center/profile/{username}+ build-timesite-leaderboard.json配套后端改动(已直推 main,involutionhell-backend)
8efae54: 新增GET /api/user-center/profile/{username}公开读接口 + SaToken 白名单8224e74: 修复 OAuth callback 缺参时 500 白屏(required=false + null check)fdc376a: 新增POST /analytics/events埋点写入(前端 PR feat: i18n 双语系统 + 150 篇文档翻译 + Hero UX 迭代 #281 已配套切换)同步固化开发文档
docs/architecture/frontend-backend-separation.md首次写下前后端分离约定:MVP 砍刀记录(等 V2)
PATCH /preferences,UI 下一迭代)已知问题(不阻塞此 PR)
fcbf6a2、69727c5、fdc376a)都走了同样的失败路径自动回滚到上个版本。/u/[username]页面在生产环境暂时会显示 404(后端/api/user-center/profile/{username}还没上线)。等后端部署修好自然打通。BACKEND_URL指向 production Java(尚未更新),同样会 404。本地用.env.local指向本地 Java 可验证完整链路。Test plan
pnpm build通过pnpm lint/pnpm test通过/u/longsizhuo看 Bento 渲染