You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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_SIZEserver/utils/public-pagination.ts 统一解析 page#server/service/posts|timeline|rss 提供 *PreviewBySlug*PageBySlug(count + limit/offset);Nitro 在 server/api/public/profile/[publicSlug]/ 下增加 posts/index.get.tstimeline/index.get.tsreading/index.get.ts;Vue 侧重构 @[publicSlug]/index.vue 并新增三列表页,分页与 route.queryrouter.replace 同步。

Tech Stack: Nuxt 4、Nitro、h3、drizzle-orm + SQLite(drizzle-pkg)、Bun test、@nuxt/uiUPaginationUButton 等)。

Spec: docs/superpowers/specs/2026-04-18-public-profile-preview-and-list-design.md


File map(创建 / 修改)

路径 职责
server/constants/public-profile-lists.ts PUBLIC_PREVIEW_LIMITPUBLIC_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 相同的 whereusers.publicSlugusers.status === activeposts.visibility === public)、排序 desc(publishedAt), desc(id)。删除对外导出的 listPublicPostsBySlug,改为 getPublicPostsPreviewBySluggetPublicPostsPageBySlug(仅 profile 与列表 API 使用)。

  • Step 1: 增加 countsql 导入

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) * pageSizeselect 同上字段 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 相同 whereorderBy(desc(publishedAt), desc(id))移除 .limit(200)。预览 limit(PUBLIC_PREVIEW_LIMIT);分页使用 PUBLIC_LIST_PAGE_SIZEoffset

  • 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

改为 getPublicPostsPreviewBySluggetPublicTimelinePreviewBySluggetPublicRssPreviewBySlug

  • 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 devcurl -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/postsGET /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_SIZEpostsPagetimelinePagerssPageslicePagepostsChunktimelineChunkrssChunk,以及 detailed 主栏内三个 section 中的 UPaginationv-for 对 chunk 的引用。

  • Step 3: 抽取共用区块渲染

展示模式阅读模式主栏均使用同一套:

  • 文章: data.posts.items 渲染;若 data.posts.total > 5 显示 UButton to="\/@${slug}/posts`",文案 查看全部(共 ${data.posts.total} 条);预览项展示 **日期**(formatPublishedDateOnlyoccurredOnToIsoAttr用于publishedAt`)。

  • 时光机: data.timeline.itemstotal > 5/@slug/timeline

  • 阅读: data.rssItems.items;块级链接样式(标题 + hostname);total > 5/@slug/reading

  • Step 4: 侧栏导航

所有 data.posts.length 改为 data.posts.totaltimeline/rssItems 同理;readingSectionValidtotal > 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>

模板:顶栏下 UContainerUAlert(error)、加载态、ul 列表结构 复制index.vue detailed 文章 section 的 NuxtLink + 封面 + 日期 + 标题 + excerpt;底部 UPaginationv-model:page="page" 改为监听 @update:page 或在 watch(page)replace(避免与 useAsyncData 死循环:以 page ref 为唯一真源watch(page, syncQuery))。

实现时推荐:const listPage = computed({ get: () => pageFromRoute(), set: (p) => router.replace(...) })useAsyncDatawatch: [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.vue detailed 时光机 article 卡片(时间、标题、bodyMarkdownlinkUrl)。

  • 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: 若有未提交改动,合并为一次 featfix 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 与检查点。

你更倾向哪一种?