Browse Source

docs: add public profile preview and list implementation plan

Made-with: Cursor
main
npmrun 8 hours ago
parent
commit
216724f464
  1. 514
      docs/superpowers/plans/2026-04-18-public-profile-preview-and-list-implementation-plan.md

514
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<ReturnType<typeof listPublic...>>` 类型,改为显式 `{ 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
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
import { formatPublishedDateOnly, occurredOnToIsoAttr } from '../../../utils/timeline-datetime'
definePageMeta({ layout: 'public', title: '文章' })
const route = useRoute()
const router = useRouter()
const slug = computed(() => route.params.publicSlug as string)
function pageFromRoute(): number {
const raw = route.query.page
const n = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number.NaN
if (!Number.isFinite(n) || n < 1) return 1
return Math.floor(n)
}
const page = ref(pageFromRoute())
watch(
() => route.query.page,
() => { page.value = pageFromRoute() },
)
type Row = { title: string; excerpt: string; slug: string; publishedAt: Date | null; coverUrl?: string | null }
type Payload = { items: Row[]; total: number; page: number; pageSize: number }
const { data, pending, error } = await useAsyncData(
() => `public-posts-${slug.value}-${page.value}`,
async () => {
const q = page.value > 1 ? `?page=${page.value}` : ''
const res = await $fetch<ApiResponse<Payload>>(
`/api/public/profile/${encodeURIComponent(slug.value)}/posts${q}`,
)
return unwrapApiBody(res)
},
{ watch: [slug, page] },
)
function onPageChange(p: number) {
page.value = p
router.replace({ query: p <= 1 ? {} : { ...route.query, page: String(p) } })
}
</script>
```
模板:顶栏下 `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** 与检查点。
你更倾向哪一种?
Loading…
Cancel
Save