# 公开主页预览 + 列表子页 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** 与检查点。 你更倾向哪一种?