18 KiB
公开主页预览 + 列表子页 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:
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:
/** 公开主页 profile 聚合接口中每类预览条数 */
export const PUBLIC_PREVIEW_LIMIT = 5;
/** 公开列表子页每页条数(仅服务端使用) */
export const PUBLIC_LIST_PAGE_SIZE = 10;
创建 server/utils/public-pagination.ts:
/**
* 解析公开列表的 ?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
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 并行执行
select({ value: count() }).from(posts).innerJoin(users, ...).where(同上)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
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
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
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
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
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 文案不同):
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
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显示UButtonto="\/@${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
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: 页面骨架
<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
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.vuedetailed 时光机article卡片(时间、标题、bodyMarkdown、linkUrl)。 -
Step 3:
definePageMeta({ layout: 'public', title: '时光机' }) -
Step 4: Commit
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
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或fixcommit
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。
执行方式可选:
- Subagent-Driven(推荐) — 每 Task 派生子代理并在任务间复核。需配合 superpowers:subagent-driven-development。
- Inline Execution — 本会话按 Task 顺序执行,配合 superpowers:executing-plans 与检查点。
你更倾向哪一种?