From 216724f464a549e5db72569bc1e5e2e4f3704d31 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sat, 18 Apr 2026 21:31:54 +0800 Subject: [PATCH] docs: add public profile preview and list implementation plan Made-with: Cursor --- ...profile-preview-and-list-implementation-plan.md | 514 +++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-18-public-profile-preview-and-list-implementation-plan.md diff --git a/docs/superpowers/plans/2026-04-18-public-profile-preview-and-list-implementation-plan.md b/docs/superpowers/plans/2026-04-18-public-profile-preview-and-list-implementation-plan.md new file mode 100644 index 0000000..a7db275 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-public-profile-preview-and-list-implementation-plan.md @@ -0,0 +1,514 @@ +# 公开主页预览 + 列表子页 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 公开主页「展示 / 阅读」双模式下文章、时光机、阅读仅预览各 5 条并显示总数;`total > 5` 时进入独立子页完整列表(10 条/页、URL `?page=`);聚合 profile 接口瘦身,新增三类分页 API。 + +**Architecture:** 在 `server/constants` 固定 `PUBLIC_PREVIEW_LIMIT` / `PUBLIC_LIST_PAGE_SIZE`;`server/utils/public-pagination.ts` 统一解析 `page`;`#server/service/posts|timeline|rss` 提供 `*PreviewBySlug` 与 `*PageBySlug`(count + `limit/offset`);Nitro 在 `server/api/public/profile/[publicSlug]/` 下增加 `posts/index.get.ts`、`timeline/index.get.ts`、`reading/index.get.ts`;Vue 侧重构 `@[publicSlug]/index.vue` 并新增三列表页,分页与 `route.query` 用 `router.replace` 同步。 + +**Tech Stack:** Nuxt 4、Nitro、h3、`drizzle-orm` + SQLite(`drizzle-pkg`)、Bun test、`@nuxt/ui`(`UPagination`、`UButton` 等)。 + +**Spec:** `docs/superpowers/specs/2026-04-18-public-profile-preview-and-list-design.md` + +--- + +## File map(创建 / 修改) + +| 路径 | 职责 | +|------|------| +| `server/constants/public-profile-lists.ts` | `PUBLIC_PREVIEW_LIMIT`、`PUBLIC_LIST_PAGE_SIZE` | +| `server/utils/public-pagination.ts` | `normalizePublicListPage(raw)` | +| `server/utils/public-pagination.test.ts` | 分页 query 解析单测 | +| `server/service/posts/index.ts` | 公开预览 + 分页;删除或内联替代原 `listPublicPostsBySlug` | +| `server/service/timeline/index.ts` | 同上 | +| `server/service/rss/index.ts` | 同上;去掉公开列表 `limit(200)` | +| `server/api/public/profile/[publicSlug].get.ts` | 返回 `posts/timeline/rssItems` 为 `{ items, total }` | +| `server/api/public/profile/[publicSlug]/posts/index.get.ts` | `GET .../posts?page=` | +| `server/api/public/profile/[publicSlug]/timeline/index.get.ts` | `GET .../timeline?page=` | +| `server/api/public/profile/[publicSlug]/reading/index.get.ts` | `GET .../reading?page=` | +| `app/pages/@[publicSlug]/index.vue` | 双模式预览 + 「查看全部」;侧栏 `total`;移除主栏内联分页 | +| `app/pages/@[publicSlug]/posts/index.vue` | 文章列表 + `UPagination` | +| `app/pages/@[publicSlug]/timeline/index.vue` | 时光机列表 | +| `app/pages/@[publicSlug]/reading/index.vue` | 阅读列表 | + +--- + +### Task 1: `normalizePublicListPage` + 常量文件 + +**Files:** +- Create: `server/constants/public-profile-lists.ts` +- Create: `server/utils/public-pagination.ts` +- Create: `server/utils/public-pagination.test.ts` + +- [ ] **Step 1: 写失败单测** + +创建 `server/utils/public-pagination.test.ts`: + +```typescript +import { describe, expect, test } from "bun:test"; +import { normalizePublicListPage } from "./public-pagination"; + +describe("normalizePublicListPage", () => { + test("invalid or <1 becomes 1", () => { + expect(normalizePublicListPage(undefined)).toBe(1); + expect(normalizePublicListPage(null)).toBe(1); + expect(normalizePublicListPage("")).toBe(1); + expect(normalizePublicListPage("0")).toBe(1); + expect(normalizePublicListPage("-3")).toBe(1); + expect(normalizePublicListPage("abc")).toBe(1); + expect(normalizePublicListPage(0)).toBe(1); + }); + + test("parses positive integers", () => { + expect(normalizePublicListPage("1")).toBe(1); + expect(normalizePublicListPage(2)).toBe(2); + expect(normalizePublicListPage("999")).toBe(999); + }); + + test("floors floats", () => { + expect(normalizePublicListPage(2.7)).toBe(2); + expect(normalizePublicListPage("3.9")).toBe(3); + }); +}); +``` + +- [ ] **Step 2: 运行单测确认失败** + +运行:`cd /home/dash/projects/person-panel && bun test server/utils/public-pagination.test.ts` + +预期:FAIL(模块不存在或函数未导出)。 + +- [ ] **Step 3: 实现常量与工具函数** + +创建 `server/constants/public-profile-lists.ts`: + +```typescript +/** 公开主页 profile 聚合接口中每类预览条数 */ +export const PUBLIC_PREVIEW_LIMIT = 5; + +/** 公开列表子页每页条数(仅服务端使用) */ +export const PUBLIC_LIST_PAGE_SIZE = 10; +``` + +创建 `server/utils/public-pagination.ts`: + +```typescript +/** + * 解析公开列表的 ?page= query:非有限数或 <1 时返回 1;否则返回正整数(float 向下取整)。 + */ +export function normalizePublicListPage(raw: unknown): number { + const n = + typeof raw === "string" + ? Number.parseInt(raw, 10) + : typeof raw === "number" + ? raw + : Number.NaN; + if (!Number.isFinite(n) || n < 1) { + return 1; + } + return Math.floor(n); +} +``` + +- [ ] **Step 4: 运行单测确认通过** + +运行:`bun test server/utils/public-pagination.test.ts` + +预期:全部 PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add server/constants/public-profile-lists.ts server/utils/public-pagination.ts server/utils/public-pagination.test.ts +git commit -m "feat(server): add public list pagination constants and page normalizer" +``` + +--- + +### Task 2: Posts 服务 — 公开预览与分页 + +**Files:** +- Modify: `server/service/posts/index.ts` + +**约定:** 与现 `listPublicPostsBySlug` 相同的 `where`(`users.publicSlug`、`users.status === active`、`posts.visibility === public`)、排序 `desc(publishedAt), desc(id)`。删除对外导出的 `listPublicPostsBySlug`,改为 `getPublicPostsPreviewBySlug`、`getPublicPostsPageBySlug`(仅 profile 与列表 API 使用)。 + +- [ ] **Step 1: 增加 `count` 与 `sql` 导入** + +在 `drizzle-orm` 导入中加入 `count`(若尚未使用)。 + +- [ ] **Step 2: 实现 `getPublicPostsPreviewBySlug`** + +逻辑:`Promise.all` 并行执行 + +1. `select({ value: count() }).from(posts).innerJoin(users, ...).where(同上)` +2. `select({ title, excerpt, slug, coverUrl, publishedAt }).from(...).where(...).orderBy(...).limit(PUBLIC_PREVIEW_LIMIT)` + +返回 `{ items: 查询2结果, total: 查询1的 value }`。 + +- [ ] **Step 3: 实现 `getPublicPostsPageBySlug`** + +签名:`getPublicPostsPageBySlug(publicSlug: string, pageRaw: unknown)`。 + +- `page = normalizePublicListPage(pageRaw)` +- `pageSize = PUBLIC_LIST_PAGE_SIZE` +- `total` 同上 count +- `offset = (page - 1) * pageSize`,`select` 同上字段 `limit(pageSize).offset(offset)` +- 返回 `{ items, total, page, pageSize }` + +- [ ] **Step 4: 删除 `listPublicPostsBySlug`** + +并确保仓库内无残留引用(`rg listPublicPostsBySlug`)。 + +- [ ] **Step 5: Commit** + +```bash +git add server/service/posts/index.ts +git commit -m "feat(server/posts): public preview and paginated list by slug" +``` + +--- + +### Task 3: Timeline 服务 — 公开预览与分页 + +**Files:** +- Modify: `server/service/timeline/index.ts` + +**约定:** 与现 `listPublicTimelineBySlug` 相同过滤与排序 `desc(occurredOn), desc(id)`。`items` 中每条为 `timelineEvents` 行对象(与现 `rows.map((r) => r.ev)` 一致)。 + +- [ ] **Step 1: 实现 `getPublicTimelinePreviewBySlug`** + +`count` + `select ev limit PUBLIC_PREVIEW_LIMIT`,返回 `{ items, total }`。 + +- [ ] **Step 2: 实现 `getPublicTimelinePageBySlug(publicSlug, pageRaw)`** + +`limit/offset` 使用 `PUBLIC_LIST_PAGE_SIZE`,返回 `{ items, total, page, pageSize }`。 + +- [ ] **Step 3: 删除 `listPublicTimelineBySlug`** + +`rg listPublicTimelineBySlug` 应为 0。 + +- [ ] **Step 4: Commit** + +```bash +git add server/service/timeline/index.ts +git commit -m "feat(server/timeline): public preview and paginated list by slug" +``` + +--- + +### Task 4: RSS 服务 — 公开预览与分页 + +**Files:** +- Modify: `server/service/rss/index.ts` + +**约定:** 与现 `listPublicRssItemsBySlug` 相同 `where`、`orderBy(desc(publishedAt), desc(id))`,**移除** `.limit(200)`。预览 `limit(PUBLIC_PREVIEW_LIMIT)`;分页使用 `PUBLIC_LIST_PAGE_SIZE` 与 `offset`。 + +- [ ] **Step 1: 实现 `getPublicRssPreviewBySlug`** + +返回 `{ items: rssItems 行数组(与现 map 一致), total }`。 + +- [ ] **Step 2: 实现 `getPublicRssPageBySlug(publicSlug, pageRaw)`** + +返回 `{ items, total, page, pageSize }`。 + +- [ ] **Step 3: 删除 `listPublicRssItemsBySlug`** + +- [ ] **Step 4: Commit** + +```bash +git add server/service/rss/index.ts +git commit -m "feat(server/rss): public preview and paginated list by slug" +``` + +--- + +### Task 5: 聚合 profile API + +**Files:** +- Modify: `server/api/public/profile/[publicSlug].get.ts` + +- [ ] **Step 1: 替换 import** + +改为 `getPublicPostsPreviewBySlug`、`getPublicTimelinePreviewBySlug`、`getPublicRssPreviewBySlug`。 + +- [ ] **Step 2: 构造 payload** + +```typescript +const [posts, timeline, rssItems] = await Promise.all([ + getPublicPostsPreviewBySlug(publicSlug), + getPublicTimelinePreviewBySlug(publicSlug), + getPublicRssPreviewBySlug(publicSlug), +]); +// payload.posts = posts; payload.timeline = timeline; payload.rssItems = rssItems; +``` + +删除原 `Awaited>` 类型,改为显式 `{ items; total }` 类型或 `typeof posts`。 + +- [ ] **Step 3: 手工验证** + +启动 `bun run dev`,`curl -s` 你的站点 `/api/public/profile/<有效slug>`,确认 `posts`/`timeline`/`rssItems` 均为 `{ items: [], total: number }` 形状。 + +- [ ] **Step 4: Commit** + +```bash +git add server/api/public/profile/[publicSlug].get.ts +git commit -m "feat(api): slim public profile to preview slices with totals" +``` + +--- + +### Task 6: 三个分页 API 路由 + +**Files:** +- Create: `server/api/public/profile/[publicSlug]/posts/index.get.ts` +- Create: `server/api/public/profile/[publicSlug]/timeline/index.get.ts` +- Create: `server/api/public/profile/[publicSlug]/reading/index.get.ts` + +**模式(以 posts 为例,三者仅 service 与 404 文案不同):** + +```typescript +import { getPublicPostsPageBySlug } from "#server/service/posts"; +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { users } from "drizzle-pkg/lib/schema/auth"; +import { and, eq } from "drizzle-orm"; +import { getQuery } from "h3"; +import { normalizePublicListPage } from "#server/utils/public-pagination"; + +export default defineEventHandler(async (event) => { + const publicSlug = event.context.params?.publicSlug; + if (!publicSlug || typeof publicSlug !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效主页" }); + } + + const [owner] = await dbGlobal + .select({ id: users.id }) + .from(users) + .where(and(eq(users.publicSlug, publicSlug), eq(users.status, "active"))) + .limit(1); + + if (!owner) { + throw createError({ statusCode: 404, statusMessage: "未找到" }); + } + + const q = getQuery(event); + const page = normalizePublicListPage(q.page); + const data = await getPublicPostsPageBySlug(publicSlug, page); + return R.success(data); +}); +``` + +- timeline:`getPublicTimelinePageBySlug` +- reading:`getPublicRssPageBySlug` + +- [ ] **Step 1: 创建三文件并按上式接线** + +- [ ] **Step 2: 确认与 `posts/[postSlug].get.ts` 无路由冲突** + +本地访问 `GET /api/public/profile/foo/posts` 与 `GET /api/public/profile/foo/posts/bar` 均 200(slug 有效时)。 + +- [ ] **Step 3: Commit** + +```bash +git add server/api/public/profile/[publicSlug]/posts/index.get.ts server/api/public/profile/[publicSlug]/timeline/index.get.ts server/api/public/profile/[publicSlug]/reading/index.get.ts +git commit -m "feat(api): public paginated posts, timeline, and reading lists" +``` + +--- + +### Task 7: 重构公开主页 `@[publicSlug]/index.vue` + +**Files:** +- Modify: `app/pages/@[publicSlug]/index.vue` + +- [ ] **Step 1: 更新 `Payload` 类型** + +`posts` / `timeline` / `rssItems` 为 `{ items: ...; total: number }`。 + +- [ ] **Step 2: 删除** `PAGE_SIZE`、`postsPage`、`timelinePage`、`rssPage`、`slicePage`、`postsChunk`、`timelineChunk`、`rssChunk`,以及 **detailed** 主栏内三个 `section` 中的 `UPagination` 与 `v-for` 对 chunk 的引用。 + +- [ ] **Step 3: 抽取共用区块渲染** + +**展示模式**与**阅读模式主栏**均使用同一套: + +- **文章:** `data.posts.items` 渲染;若 `data.posts.total > 5` 显示 `UButton` `to="\`/@${slug}/posts\`"`,文案 `查看全部(共 ${data.posts.total} 条)`;预览项展示 **日期**(`formatPublishedDateOnly`、`occurredOnToIsoAttr` 用于 `publishedAt`)。 +- **时光机:** `data.timeline.items`;`total > 5` → `/@slug/timeline`。 +- **阅读:** `data.rssItems.items`;块级链接样式(标题 + hostname);`total > 5` → `/@slug/reading`。 + +- [ ] **Step 4: 侧栏导航** + +所有 `data.posts.length` 改为 `data.posts.total`,`timeline`/`rssItems` 同理;`readingSectionValid` 用 `total > 0`。 + +- [ ] **Step 5: `firstReadingSection` / `watch`** + +依据 `total` 判断默认区块。 + +- [ ] **Step 6: Commit** + +```bash +git add app/pages/@[publicSlug]/index.vue +git commit -m "feat(public): profile preview slices and links to full list pages" +``` + +--- + +### Task 8: 文章列表页 `posts/index.vue` + +**Files:** +- Create: `app/pages/@[publicSlug]/posts/index.vue` + +- [ ] **Step 1: 页面骨架** + +```vue + +``` + +模板:顶栏下 `UContainer`,`UAlert`(error)、加载态、`ul` 列表结构 **复制** 现 `index.vue` detailed 文章 section 的 `NuxtLink` + 封面 + 日期 + 标题 + excerpt;底部 `UPagination`:`v-model:page="page"` 改为监听 `@update:page` 或在 `watch(page)` 里 `replace`(避免与 `useAsyncData` 死循环:以 **`page` ref 为唯一真源**,`watch(page, syncQuery)`)。 + +实现时推荐:`const listPage = computed({ get: () => pageFromRoute(), set: (p) => router.replace(...) })` 与 `useAsyncData` 的 `watch: [slug, () => route.query.page]` 二选一,**务必**在 spec 验收下能 **刷新保持页码**。 + +- [ ] **Step 2: 空列表 `UEmpty`** + +`!pending && !error && data?.total === 0`。 + +- [ ] **Step 3: Commit** + +```bash +git add app/pages/@[publicSlug]/posts/index.vue +git commit -m "feat(public): paginated public post list page" +``` + +--- + +### Task 9: 时光机列表页 `timeline/index.vue` + +**Files:** +- Create: `app/pages/@[publicSlug]/timeline/index.vue` + +- [ ] **Step 1:** 复制 Task 8 的分页与 `useAsyncData` 模式,请求 `/api/public/profile/${slug}/timeline?page=`。 + +- [ ] **Step 2:** 列表项 UI 对齐现 `index.vue` detailed 时光机 `article` 卡片(时间、标题、`bodyMarkdown`、`linkUrl`)。 + +- [ ] **Step 3:** `definePageMeta({ layout: 'public', title: '时光机' })` + +- [ ] **Step 4: Commit** + +```bash +git add app/pages/@[publicSlug]/timeline/index.vue +git commit -m "feat(public): paginated public timeline list page" +``` + +--- + +### Task 10: 阅读列表页 `reading/index.vue` + +**Files:** +- Create: `app/pages/@[publicSlug]/reading/index.vue` + +- [ ] **Step 1:** 同上,请求 `/api/public/profile/${slug}/reading?page=`。 + +- [ ] **Step 2:** 列表 UI 对齐 detailed **阅读** section(外链、`rssPublicTitle` / hostname 逻辑可抽成 `utils` 或内联复制 `index.vue` 中的函数)。 + +- [ ] **Step 3:** `definePageMeta({ layout: 'public', title: '阅读' })` + +- [ ] **Step 4: Commit** + +```bash +git add app/pages/@[publicSlug]/reading/index.vue +git commit -m "feat(public): paginated public reading list page" +``` + +--- + +### Task 11: 全量验证与收尾 + +- [ ] **Step 1: 运行单测** + +`bun test server/utils/public-pagination.test.ts` + +(若其它测试存在:`bun test`。) + +- [ ] **Step 2: 构建** + +`bun run build` + +预期:成功完成 `nuxt build`。 + +- [ ] **Step 3: 手工验收(对照 spec §7)** + +- 主页预览 ≤5,`total` 与侧栏一致;`total > 5` 出现「查看全部」;`1≤total≤5` 无按钮。 +- 三子页 `?page=` 与 `UPagination` 同步;`page` 超大时 **空列表**、**非 404**。 +- `/@slug/posts/old-slug` 单篇仍可打开。 + +- [ ] **Step 4: 若有未提交改动,合并为一次 `feat` 或 `fix` commit** + +--- + +## Plan self-review + +| Spec 条款 | 对应 Task | +|-----------|-----------| +| 预览 5 条 + total | Task 2–5、7 | +| 列表 10 条/页、仅服务端 pageSize | 常量 + Task 2–4、6 | +| `total > 5` 才「查看全部」 | Task 7 | +| 子路由 posts/timeline/reading | Task 8–10 | +| profile 破坏性 `{ items, total }` | Task 5、7 | +| 空页不 404 | Task 2–4 offset 行为 + 6 | +| 双布局一致 | Task 7 | +| 美化(日期、块级阅读、按钮) | Task 7–10 | + +**Placeholder 扫描:** 无 TBD。 +**命名一致:** `rssItems` 仅在 profile;分页路径 `/reading` 与页面一致。 + +--- + +**Plan 已保存至 `docs/superpowers/plans/2026-04-18-public-profile-preview-and-list-implementation-plan.md`。** + +**执行方式可选:** + +1. **Subagent-Driven(推荐)** — 每 Task 派生子代理并在任务间复核。需配合 **superpowers:subagent-driven-development**。 +2. **Inline Execution** — 本会话按 Task 顺序执行,配合 **superpowers:executing-plans** 与检查点。 + +你更倾向哪一种?